| 1 | #!/usr/bin/env python3
|
|---|
| 2 | """Screenshots for src/bnchelp.html: BNC's GUI on a virtual X display.
|
|---|
| 3 |
|
|---|
| 4 | Start the display first (Xvfb 2600x1800 + openbox, see start_display.sh), then
|
|---|
| 5 |
|
|---|
| 6 | shoot.py [options] [job ...] jobs from jobs.py, default: all
|
|---|
| 7 | shoot.py --list
|
|---|
| 8 |
|
|---|
| 9 | Options:
|
|---|
| 10 | --user NAME account for the live streams, replaces 'Example:Configs' in
|
|---|
| 11 | the example configurations; the password is taken from the
|
|---|
| 12 | credentials file (~/.netrc), never from the command line
|
|---|
| 13 | --work DIR working directory (default /tmp/bnc_screenshots); BNC runs
|
|---|
| 14 | in DIR/run/<job>, the images are written to DIR/out
|
|---|
| 15 | --install copy the produced FigureNN.png files into src/IMG
|
|---|
| 16 |
|
|---|
| 17 | The screenshots use Qt's Fusion style and an English locale; the main window
|
|---|
| 18 | has a client size of 1200x850. The figure numbers are those of bnchelp.html.
|
|---|
| 19 | Environment: BNC_SHOT_DISPLAY (default :99), BNC_SHOT_PREFIX (directory of
|
|---|
| 20 | the unpacked packages if xdotool is not installed, see start_display.sh).
|
|---|
| 21 | """
|
|---|
| 22 |
|
|---|
| 23 | import argparse
|
|---|
| 24 | import glob
|
|---|
| 25 | import os
|
|---|
| 26 | import shutil
|
|---|
| 27 | import subprocess
|
|---|
| 28 | import sys
|
|---|
| 29 | import time
|
|---|
| 30 |
|
|---|
| 31 | HERE = os.path.dirname(os.path.abspath(__file__))
|
|---|
| 32 | BNC_ROOT = os.path.dirname(os.path.dirname(HERE))
|
|---|
| 33 | BNC = os.path.join(BNC_ROOT, "bnc")
|
|---|
| 34 | DISPLAY = os.environ.get("BNC_SHOT_DISPLAY", ":99")
|
|---|
| 35 | PREFIX = os.environ.get("BNC_SHOT_PREFIX", os.path.expanduser("~/.cache/bnc_screenshot_tools"))
|
|---|
| 36 |
|
|---|
| 37 | if shutil.which("xdotool"):
|
|---|
| 38 | XDOTOOL, XLIBS = "xdotool", ""
|
|---|
| 39 | else:
|
|---|
| 40 | XDOTOOL = os.path.join(PREFIX, "root/usr/bin/xdotool")
|
|---|
| 41 | XLIBS = os.path.join(PREFIX, "root/usr/lib/x86_64-linux-gnu")
|
|---|
| 42 | XENV = dict(os.environ, DISPLAY=DISPLAY, LD_LIBRARY_PATH=XLIBS)
|
|---|
| 43 | BNCENV = dict(os.environ, DISPLAY=DISPLAY, QT_STYLE_OVERRIDE="Fusion", LC_ALL="C.UTF-8",
|
|---|
| 44 | LANG="C.UTF-8", TZ="UTC")
|
|---|
| 45 |
|
|---|
| 46 | WORK = "/tmp/bnc_screenshots" # set by main()
|
|---|
| 47 | OUT = os.path.join(WORK, "out")
|
|---|
| 48 | USER = None
|
|---|
| 49 |
|
|---|
| 50 |
|
|---|
| 51 | def xdo(*args):
|
|---|
| 52 | r = subprocess.run([XDOTOOL] + [str(a) for a in args], env=XENV, capture_output=True,
|
|---|
| 53 | text=True)
|
|---|
| 54 | return r.stdout.strip()
|
|---|
| 55 |
|
|---|
| 56 |
|
|---|
| 57 | def wait_window(title, timeout=30, count=1):
|
|---|
| 58 | """id of the (last) visible window whose title contains title; with
|
|---|
| 59 | count > 1 the list of ids once that many exist"""
|
|---|
| 60 | t0 = time.time()
|
|---|
| 61 | while time.time() - t0 < timeout:
|
|---|
| 62 | ids = xdo("search", "--onlyvisible", "--name", title).split()
|
|---|
| 63 | if len(ids) >= count:
|
|---|
| 64 | time.sleep(0.5)
|
|---|
| 65 | return ids[-1] if count == 1 else ids
|
|---|
| 66 | time.sleep(0.3)
|
|---|
| 67 | raise RuntimeError("window '%s' not found" % title)
|
|---|
| 68 |
|
|---|
| 69 |
|
|---|
| 70 | def new_window(before, timeout=60):
|
|---|
| 71 | """first visible window with a title that was not in before"""
|
|---|
| 72 | for _ in range(timeout):
|
|---|
| 73 | new = set(xdo("search", "--onlyvisible", "--name", "").split()) - before
|
|---|
| 74 | new = [w for w in new if xdo("getwindowname", w)]
|
|---|
| 75 | if new:
|
|---|
| 76 | return new[0]
|
|---|
| 77 | time.sleep(1)
|
|---|
| 78 | raise RuntimeError("no new window")
|
|---|
| 79 |
|
|---|
| 80 |
|
|---|
| 81 | def visible_windows():
|
|---|
| 82 | return set(xdo("search", "--onlyvisible", "--name", "").split())
|
|---|
| 83 |
|
|---|
| 84 |
|
|---|
| 85 | def geometry(wid):
|
|---|
| 86 | out = xdo("getwindowgeometry", "--shell", wid)
|
|---|
| 87 | g = dict(l.split("=") for l in out.splitlines())
|
|---|
| 88 | return int(g["X"]), int(g["Y"]), int(g["WIDTH"]), int(g["HEIGHT"])
|
|---|
| 89 |
|
|---|
| 90 |
|
|---|
| 91 | def place(wid, x, y, w=None, h=None):
|
|---|
| 92 | """move (and resize) a window; repeated until the window manager applied it"""
|
|---|
| 93 | time.sleep(1.0)
|
|---|
| 94 | for _ in range(3):
|
|---|
| 95 | if w:
|
|---|
| 96 | xdo("windowsize", "--sync", wid, w, h)
|
|---|
| 97 | xdo("windowmove", "--sync", wid, x, y)
|
|---|
| 98 | time.sleep(0.5)
|
|---|
| 99 | gx, gy, gw, gh = geometry(wid)
|
|---|
| 100 | if (not w or (gw, gh) == (w, h)) and abs(gx - x) < 30 and abs(gy - y) < 40:
|
|---|
| 101 | break
|
|---|
| 102 |
|
|---|
| 103 |
|
|---|
| 104 | def click(wid, x, y, button=1):
|
|---|
| 105 | """click at x, y relative to the client area of window wid"""
|
|---|
| 106 | xdo("mousemove", "--window", wid, x, y)
|
|---|
| 107 | time.sleep(0.2)
|
|---|
| 108 | xdo("click", button)
|
|---|
| 109 | time.sleep(0.8)
|
|---|
| 110 |
|
|---|
| 111 |
|
|---|
| 112 | def key(wid, *keys):
|
|---|
| 113 | xdo("windowactivate", "--sync", wid)
|
|---|
| 114 | for k in keys:
|
|---|
| 115 | xdo("key", "--clearmodifiers", k)
|
|---|
| 116 | time.sleep(0.2)
|
|---|
| 117 |
|
|---|
| 118 |
|
|---|
| 119 | def typetext(wid, text):
|
|---|
| 120 | xdo("windowactivate", "--sync", wid)
|
|---|
| 121 | xdo("type", "--delay", "40", text)
|
|---|
| 122 |
|
|---|
| 123 |
|
|---|
| 124 | def minimize(wid):
|
|---|
| 125 | xdo("windowminimize", wid)
|
|---|
| 126 | time.sleep(0.5)
|
|---|
| 127 |
|
|---|
| 128 |
|
|---|
| 129 | def activate(wid):
|
|---|
| 130 | xdo("windowactivate", "--sync", wid)
|
|---|
| 131 | time.sleep(0.5)
|
|---|
| 132 |
|
|---|
| 133 |
|
|---|
| 134 | def pointer_away():
|
|---|
| 135 | w, h = xdo("getdisplaygeometry").split()
|
|---|
| 136 | xdo("mousemove", int(w) - 10, int(h) - 10)
|
|---|
| 137 | time.sleep(0.5)
|
|---|
| 138 |
|
|---|
| 139 |
|
|---|
| 140 | def saved(path):
|
|---|
| 141 | size = subprocess.run(["identify", "-format", "%wx%h", path], capture_output=True,
|
|---|
| 142 | text=True).stdout
|
|---|
| 143 | print(" saved", os.path.relpath(path, WORK), size)
|
|---|
| 144 | return path
|
|---|
| 145 |
|
|---|
| 146 |
|
|---|
| 147 | def frame():
|
|---|
| 148 | """screen rectangle of everything shown (windows incl. title bars) =
|
|---|
| 149 | bounding box of the non-black part of the root window"""
|
|---|
| 150 | raw = os.path.join(OUT, "_bbox.png")
|
|---|
| 151 | subprocess.run(["import", "-window", "root", raw], env=XENV, check=True)
|
|---|
| 152 | bb = subprocess.run(["convert", raw, "-format", "%@", "info:"], capture_output=True,
|
|---|
| 153 | text=True).stdout.strip() # WxH+X+Y
|
|---|
| 154 | wh, x, y = bb.split("+")
|
|---|
| 155 | w, h = wh.split("x")
|
|---|
| 156 | return int(x), int(y), int(w), int(h)
|
|---|
| 157 |
|
|---|
| 158 |
|
|---|
| 159 | def shot(name, crop=None):
|
|---|
| 160 | """screenshot of all windows shown, or of crop=(x, y, w, h) in screen
|
|---|
| 161 | coordinates (e.g. from frame()); saved as OUT/<name>.png"""
|
|---|
| 162 | pointer_away()
|
|---|
| 163 | raw = os.path.join(OUT, "_root.png")
|
|---|
| 164 | subprocess.run(["import", "-window", "root", raw], env=XENV, check=True)
|
|---|
| 165 | path = os.path.join(OUT, name + ".png")
|
|---|
| 166 | if crop:
|
|---|
| 167 | x, y, w, h = crop
|
|---|
| 168 | subprocess.run(["convert", raw, "-crop", "%dx%d+%d+%d" % (w, h, x, y), "+repage", path],
|
|---|
| 169 | check=True)
|
|---|
| 170 | else:
|
|---|
| 171 | subprocess.run(["convert", raw, "-trim", "+repage", path], check=True)
|
|---|
| 172 | return saved(path)
|
|---|
| 173 |
|
|---|
| 174 |
|
|---|
| 175 | def wshot(name, wid):
|
|---|
| 176 | """screenshot of one window incl. its window manager frame"""
|
|---|
| 177 | pointer_away()
|
|---|
| 178 | path = os.path.join(OUT, name + ".png")
|
|---|
| 179 | subprocess.run(["import", "-frame", "-window", wid, path], env=XENV, check=True)
|
|---|
| 180 | return saved(path)
|
|---|
| 181 |
|
|---|
| 182 |
|
|---|
| 183 | def crop_file(src, name, geometry):
|
|---|
| 184 | """crop an image file, geometry 'WxH+X+Y'"""
|
|---|
| 185 | path = os.path.join(OUT, name + ".png")
|
|---|
| 186 | subprocess.run(["convert", src, "-crop", geometry, "+repage", path], check=True)
|
|---|
| 187 | return saved(path)
|
|---|
| 188 |
|
|---|
| 189 |
|
|---|
| 190 | class Bnc:
|
|---|
| 191 | """BNC instance in its own working directory (Input link, Output dir),
|
|---|
| 192 | started with a copy of an example configuration"""
|
|---|
| 193 |
|
|---|
| 194 | def __init__(self, job, conf=None, keys=(), live=False):
|
|---|
| 195 | self.dir = os.path.join(WORK, "run", job)
|
|---|
| 196 | shutil.rmtree(self.dir, ignore_errors=True)
|
|---|
| 197 | os.makedirs(os.path.join(self.dir, "Output"))
|
|---|
| 198 | os.symlink(os.path.join(BNC_ROOT, "Example_Configs", "Input"), os.path.join(self.dir, "Input"))
|
|---|
| 199 | cmd = [BNC]
|
|---|
| 200 | if conf:
|
|---|
| 201 | text = open(os.path.join(BNC_ROOT, "Example_Configs", conf), errors="replace").read()
|
|---|
| 202 | if live and USER:
|
|---|
| 203 | text = text.replace("Example:Configs@", USER + "@")
|
|---|
| 204 | open(os.path.join(self.dir, conf), "w").write(text)
|
|---|
| 205 | cmd += ["--conf", conf]
|
|---|
| 206 | for k, v in keys:
|
|---|
| 207 | if live and USER:
|
|---|
| 208 | v = str(v).replace("Example:Configs@", USER + "@")
|
|---|
| 209 | cmd += ["-key", k, str(v)]
|
|---|
| 210 | self.out = open(os.path.join(self.dir, "bnc.out"), "w")
|
|---|
| 211 | self.p = subprocess.Popen(cmd, cwd=self.dir, env=BNCENV, stdout=self.out,
|
|---|
| 212 | stderr=subprocess.STDOUT)
|
|---|
| 213 | self.main = wait_window("BKG Ntrip Client")
|
|---|
| 214 |
|
|---|
| 215 | def stop(self):
|
|---|
| 216 | if self.p.poll() is None:
|
|---|
| 217 | self.p.terminate()
|
|---|
| 218 | try:
|
|---|
| 219 | self.p.wait(10)
|
|---|
| 220 | except subprocess.TimeoutExpired:
|
|---|
| 221 | self.p.kill()
|
|---|
| 222 | time.sleep(1)
|
|---|
| 223 |
|
|---|
| 224 |
|
|---|
| 225 | def cleanup():
|
|---|
| 226 | """end leftover BNC instances of this driver: processes with a window on
|
|---|
| 227 | the virtual display that run BNC_ROOT/bnc with this DISPLAY (nothing else)"""
|
|---|
| 228 | pids = set()
|
|---|
| 229 | for w in xdo("search", "--name", "").split():
|
|---|
| 230 | p = xdo("getwindowpid", w)
|
|---|
| 231 | if p:
|
|---|
| 232 | pids.add(int(p))
|
|---|
| 233 | for p in pids:
|
|---|
| 234 | try:
|
|---|
| 235 | cmd = open("/proc/%d/cmdline" % p, "rb").read().split(b"\0")[0].decode()
|
|---|
| 236 | env = open("/proc/%d/environ" % p, "rb").read().split(b"\0")
|
|---|
| 237 | except OSError:
|
|---|
| 238 | continue
|
|---|
| 239 | if cmd == BNC and ("DISPLAY=" + DISPLAY).encode() in env:
|
|---|
| 240 | os.kill(p, 15)
|
|---|
| 241 | print(" ended leftover BNC", p)
|
|---|
| 242 | time.sleep(1)
|
|---|
| 243 |
|
|---|
| 244 |
|
|---|
| 245 | def main():
|
|---|
| 246 | global WORK, OUT, USER
|
|---|
| 247 | ap = argparse.ArgumentParser(description=__doc__,
|
|---|
| 248 | formatter_class=argparse.RawDescriptionHelpFormatter)
|
|---|
| 249 | ap.add_argument("jobs", nargs="*")
|
|---|
| 250 | ap.add_argument("--user")
|
|---|
| 251 | ap.add_argument("--work", default=WORK)
|
|---|
| 252 | ap.add_argument("--install", action="store_true")
|
|---|
| 253 | ap.add_argument("--list", action="store_true")
|
|---|
| 254 | args = ap.parse_args()
|
|---|
| 255 |
|
|---|
| 256 | # jobs.py imports this file as module 'shoot' (a second instance besides
|
|---|
| 257 | # __main__): the settings have to be made there
|
|---|
| 258 | sys.path.insert(0, HERE)
|
|---|
| 259 | import shoot as S
|
|---|
| 260 | S.WORK, S.USER = os.path.abspath(args.work), args.user
|
|---|
| 261 | S.OUT = os.path.join(S.WORK, "out")
|
|---|
| 262 | os.makedirs(S.OUT, exist_ok=True)
|
|---|
| 263 | WORK, OUT, USER = S.WORK, S.OUT, S.USER
|
|---|
| 264 | import jobs
|
|---|
| 265 | if args.list:
|
|---|
| 266 | for n in sorted(jobs.JOBS):
|
|---|
| 267 | print("%-10s %s" % (n, (jobs.JOBS[n].__doc__ or "").strip().splitlines()[0]))
|
|---|
| 268 | return 0
|
|---|
| 269 | if not os.path.exists("/tmp/.X11-unix/X" + DISPLAY.lstrip(":")):
|
|---|
| 270 | sys.exit("display %s not running, start it with start_display.sh" % DISPLAY)
|
|---|
| 271 |
|
|---|
| 272 | failed = []
|
|---|
| 273 | for n in args.jobs or sorted(jobs.JOBS):
|
|---|
| 274 | print("job", n)
|
|---|
| 275 | S.cleanup()
|
|---|
| 276 | try:
|
|---|
| 277 | jobs.JOBS[n]()
|
|---|
| 278 | except Exception as e:
|
|---|
| 279 | print(" FAILED:", e)
|
|---|
| 280 | failed.append(n)
|
|---|
| 281 | S.cleanup()
|
|---|
| 282 |
|
|---|
| 283 | if args.install:
|
|---|
| 284 | for f in sorted(glob.glob(os.path.join(OUT, "Figure[0-9][0-9].png"))):
|
|---|
| 285 | shutil.copy(f, os.path.join(BNC_ROOT, "src", "IMG"))
|
|---|
| 286 | print(" installed", os.path.basename(f))
|
|---|
| 287 | if failed:
|
|---|
| 288 | print("failed:", " ".join(failed))
|
|---|
| 289 | return 1 if failed else 0
|
|---|
| 290 |
|
|---|
| 291 |
|
|---|
| 292 | if __name__ == "__main__":
|
|---|
| 293 | sys.exit(main())
|
|---|