commit 9a7bf649f31ac89e194093a4b668ff0c7a6038f3 Author: Lucas Schumacher Date: Thu Mar 6 20:23:44 2025 -0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23c1b8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +*.csv +*.png diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7a97d86 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,446 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "cc" +version = "1.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.170" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1f1914ce909e1658d9907913b4b91947430c7d9be598b15a1912935b8c04801" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rrtlpower" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02e925281e18ffd9d640e234264753c43edc62d64b2d4cf898f1bc5e75f3fc2" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..188306e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "rrtlpower" +version = "0.1.0" +edition = "2021" + +[dependencies] +chrono = "0.4.40" +clap = { version = "4.5.31", features = ["derive"] } diff --git a/Vera.ttf b/Vera.ttf new file mode 100644 index 0000000..58cd6b5 Binary files /dev/null and b/Vera.ttf differ diff --git a/heatmap.py b/heatmap.py new file mode 100755 index 0000000..5abac8e --- /dev/null +++ b/heatmap.py @@ -0,0 +1,628 @@ +#! /usr/bin/env python + +from PIL import Image, ImageDraw, ImageFont +import os, sys, gzip, math, argparse, colorsys, datetime +from collections import defaultdict +from itertools import * + +urlretrieve = lambda a, b: None +try: + import urllib.request + urlretrieve = urllib.request.urlretrieve +except: + import urllib + urlretrieve = urllib.urlretrieve + +# todo: +# matplotlib powered --interactive +# arbitrary freq marker spacing +# ppm +# blue-less marker grid +# fast summary thing +# gain normalization +# check pil version for brokenness + +vera_url = "https://github.com/keenerd/rtl-sdr-misc/raw/master/heatmap/Vera.ttf" +vera_path = os.path.join(sys.path[0], "Vera.ttf") + +tape_height = 25 +tape_pt = 10 + +if not os.path.isfile(vera_path): + urlretrieve(vera_url, vera_path) + +try: + font = ImageFont.truetype(vera_path, 10) +except: + print('Please download the Vera.ttf font and place it in the current directory.') + sys.exit(1) + +def build_parser(): + parser = argparse.ArgumentParser(description='Convert rtl_power CSV files into graphics.') + parser.add_argument('input_path', metavar='INPUT', type=str, + help='Input CSV file. (may be a .csv.gz)') + parser.add_argument('output_path', metavar='OUTPUT', type=str, + help='Output image. (various extensions supported)') + parser.add_argument('--offset', dest='offset_freq', default=None, + help='Shift the entire frequency range, for up/down converters.') + parser.add_argument('--ytick', dest='time_tick', default=None, + help='Place ticks along the Y axis every N seconds/minutes/hours/days.') + parser.add_argument('--db', dest='db_limit', nargs=2, default=None, + help='Minimum and maximum db values.') + parser.add_argument('--compress', dest='compress', default=0, + help='Apply a gradual asymptotic time compression. Values > 1 are the new target height, values < 1 are a scaling factor.') + slicegroup = parser.add_argument_group('Slicing', + 'Efficiently render a portion of the data. (optional) Frequencies can take G/M/k suffixes. Timestamps look like "YYYY-MM-DD HH:MM:SS" Durations take d/h/m/s suffixes.') + slicegroup.add_argument('--low', dest='low_freq', default=None, + help='Minimum frequency for a subrange.') + slicegroup.add_argument('--high', dest='high_freq', default=None, + help='Maximum frequency for a subrange.') + slicegroup.add_argument('--begin', dest='begin_time', default=None, + help='Timestamp to start at.') + slicegroup.add_argument('--end', dest='end_time', default=None, + help='Timestamp to stop at.') + slicegroup.add_argument('--head', dest='head_time', default=None, + help='Duration to use, starting at the beginning.') + slicegroup.add_argument('--tail', dest='tail_time', default=None, + help='Duration to use, stopping at the end.') + parser.add_argument('--palette', dest='palette', default='default', + help='Set Color Palette: default, extended, charolastra, twente') + return parser + +def frange(start, stop, step): + i = 0 + while (i*step + start <= stop): + yield i*step + start + i += 1 + +def min_filter(row): + size = 3 + result = [] + for i in range(size): + here = row[i] + near = row[0:i] + row[i+1:size] + if here > min(near): + result.append(here) + continue + result.append(min(near)) + for i in range(size-1, len(row)): + here = row[i] + near = row[i-(size-1):i] + if here > min(near): + result.append(here) + continue + result.append(min(near)) + return result + +def floatify(zs): + # nix errors with -inf, windows errors with -1.#J + zs2 = [] + previous = 0 # awkward for single-column rows + for z in zs: + try: + z = float(z) + except ValueError: + z = previous + if math.isinf(z): + z = previous + if math.isnan(z): + z = previous + zs2.append(z) + previous = z + return zs2 + +def freq_parse(s): + suffix = 1 + if s.lower().endswith('k'): + suffix = 1e3 + if s.lower().endswith('m'): + suffix = 1e6 + if s.lower().endswith('g'): + suffix = 1e9 + if suffix != 1: + s = s[:-1] + return float(s) * suffix + +def duration_parse(s): + suffix = 1 + if s.lower().endswith('s'): + suffix = 1 + if s.lower().endswith('m'): + suffix = 60 + if s.lower().endswith('h'): + suffix = 60 * 60 + if s.lower().endswith('d'): + suffix = 24 * 60 * 60 + if suffix != 1 or s.lower().endswith('s'): + s = s[:-1] + return float(s) * suffix + +def date_parse(s): + if '-' not in s: + return datetime.datetime.fromtimestamp(int(s)) + return datetime.datetime.strptime(s, '%Y-%m-%d %H:%M:%S') + +def palette_parse(s): + palettes = {'default': default_palette, + 'extended': extended_palette, + 'charolastra': charolastra_palette, + 'twente': twente_palette, + } + if s not in palettes: + print('WARNING: %s not a valid palette' % s) + return palettes.get(s, default_palette) + +def gzip_wrap(path): + "hides silly CRC errors" + iterator = gzip.open(path, 'rb') + running = True + while running: + try: + line = next(iterator) + if type(line) == bytes: + line = line.decode('utf-8') + yield line + except IOError: + running = False + +def time_compression(y, decay): + return int(round((1/decay)*math.exp(y*decay) - 1/decay)) + +def reparse(args, label, fn): + if args.__getattribute__(label) is None: + return + args.__setattr__(label, fn(args.__getattribute__(label))) + +def prepare_args(): + # hack, http://stackoverflow.com/questions/9025204/ + for i, arg in enumerate(sys.argv): + if (arg[0] == '-') and arg[1].isdigit(): + sys.argv[i] = ' ' + arg + parser = build_parser() + args = parser.parse_args() + + reparse(args, 'low_freq', freq_parse) + reparse(args, 'high_freq', freq_parse) + reparse(args, 'offset_freq', freq_parse) + if args.offset_freq is None: + args.offset_freq = 0 + reparse(args, 'time_tick', duration_parse) + reparse(args, 'begin_time', date_parse) + reparse(args, 'end_time', date_parse) + reparse(args, 'head_time', duration_parse) + reparse(args, 'tail_time', duration_parse) + reparse(args, 'palette', palette_parse) + reparse(args, 'head_time', lambda s: datetime.timedelta(seconds=s)) + reparse(args, 'tail_time', lambda s: datetime.timedelta(seconds=s)) + args.compress = float(args.compress) + + if args.db_limit: + a,b = args.db_limit + args.db_limit = (float(a), float(b)) + + if args.begin_time and args.tail_time: + print("Can't combine --begin and --tail") + sys.exit(2) + if args.end_time and args.head_time: + print("Can't combine --end and --head") + sys.exit(2) + if args.head_time and args.tail_time: + print("Can't combine --head and --tail") + sys.exit(2) + return args + +def open_raw_data(path): + raw_data = lambda: open(path) + if path.endswith('.gz'): + raw_data = lambda: gzip_wrap(path) + return raw_data + +def slice_columns(columns, low_freq, high_freq): + start_col = 0 + stop_col = len(columns) + if low_freq is not None and low <= low_freq <= high: + start_col = sum(f args.end_time: + break + times.add(t) + columns = list(frange(low, high, step)) + start_col, stop_col = slice_columns(columns, args.low_freq, args.high_freq) + f_key = (columns[start_col], columns[stop_col], step) + zs = line[6+start_col:6+stop_col+1] + if not zs: + continue + if f_key not in f_cache: + freq2 = list(frange(*f_key))[:len(zs)] + freqs.update(freq2) + #freqs.add(f_key[1]) # high + #labels.add(f_key[0]) # low + f_cache.add(f_key) + + if not args.db_limit: + zs = floatify(zs) + min_z = min(min_z, min(zs)) + max_z = max(max_z, max(zs)) + + if start is None: + start = date_parse(t) + stop = date_parse(t) + if args.head_time is not None and args.end_time is None: + args.end_time = start + args.head_time + + if not args.db_limit: + args.db_limit = (min_z, max_z) + + if args.tail_time is not None: + times = [t for t in times if date_parse(t) >= (stop - args.tail_time)] + start = date_parse(min(times)) + + freqs = list(sorted(list(freqs))) + times = list(sorted(list(times))) + labels = list(sorted(list(labels))) + + if len(labels) == 1: + delta = (max(freqs) - min(freqs)) / (len(freqs) / 500.0) + delta = round(delta / 10**int(math.log10(delta))) * 10**int(math.log10(delta)) + delta = int(delta) + lower = int(math.ceil(min(freqs) / delta) * delta) + labels = list(range(lower, int(max(freqs)), delta)) + + height = len(times) + pix_height = height + if args.compress: + if args.compress > height: + args.compress = 0 + print("Image too short, disabling time compression") + if 0 < args.compress < 1: + args.compress *= height + if args.compress: + args.compress = -1 / args.compress + pix_height = time_compression(height, args.compress) + + print("x: %i, y: %i, z: (%f, %f)" % (len(freqs), pix_height, args.db_limit[0], args.db_limit[1])) + args.freqs = freqs + args.times = times + args.labels = labels + args.pix_height = pix_height + args.start_stop = (start, stop) + args.pixel_bandwidth = step + +def default_palette(): + return [(i, i, 50) for i in range(256)] + +def extended_palette(): + p = [(0,0,50)] + for i in range(1, 256): + p.append((i, i-1, 50)) + p.append((i-1, i, 50)) + p.append((i, i, 50)) + return p + +def charolastra_palette(): + p = [] + for i in range(1024): + g = i / 1023.0 + c = colorsys.hsv_to_rgb(0.65-(g-0.08), 1, 0.2+g) + p.append((int(c[0]*256), int(c[1]*256), int(c[2]*256))) + return p + +def twente_palette(): + p = [] + for i in range(20, 100, 2): + p.append((0, 0, i)) + for i in range(256): + g = i / 255.0 + p.append((int(g*255), 0, int(g*155)+100)) + for i in range(256): + p.append((255, i, 255)) + # intentionally blow out the highs + for i in range(100): + p.append((255, 255, 255)) + return p + +def rgb_fn(palette, min_z, max_z): + "palette is a list of tuples, returns a function of z" + def rgb_inner(z): + tone = (z - min_z) / (max_z - min_z) + tone_scaled = int(tone * (len(palette)-1)) + return palette[tone_scaled] + return rgb_inner + +def collate_row(x_size): + # this is more fragile than the old code + # sensitive to timestamps that are out of order + old_t = None + row = [0.0] * x_size + for line in raw_data(): + line = [s.strip() for s in line.strip().split(',')] + #line = [line[0], line[1]] + [float(s) for s in line[2:] if s] + line = [s for s in line if s] + t = line[0] + ' ' + line[1] + if '-' not in line[0]: + t = line[0] + if t not in args.times: + continue # happens with live files and time cropping + if old_t is None: + old_t = t + low = int(line[2]) + args.offset_freq + high = int(line[3]) + args.offset_freq + step = float(line[4]) + columns = list(frange(low, high, step)) + start_col, stop_col = slice_columns(columns, args.low_freq, args.high_freq) + if args.low_freq and columns[stop_col] < args.low_freq: + continue + if args.high_freq and columns[start_col] > args.high_freq: + continue + start_freq = columns[start_col] + if args.low_freq: + start_freq = max(args.low_freq, start_freq) + # sometimes fails? skip or abort? + x_start = args.freqs.index(start_freq) + zs = floatify(line[6+start_col:6+stop_col+1]) + if t != old_t: + yield old_t, row + row = [0.0] * x_size + old_t = t + for i in range(len(zs)): + x = x_start + i + if x >= x_size: + continue + row[x] = zs[i] + yield old_t, row + +def push_pixels(args): + "returns PIL img" + width = len(args.freqs) + rgb = rgb_fn(args.palette(), args.db_limit[0], args.db_limit[1]) + img = Image.new("RGB", (width, tape_height + args.pix_height + 1)) + pix = img.load() + x_size = img.size[0] + average = [0.0] * width + tally = 0 + old_y = None + height = len(args.times) + for t, zs in collate_row(x_size): + y = args.times.index(t) + if not args.compress: + for x in range(len(zs)): + pix[x,y+tape_height+1] = rgb(zs[x]) + continue + # ugh + y = args.pix_height - time_compression(height - y, args.compress) + if old_y is None: + old_y = y + if old_y != y: + for x in range(len(average)): + pix[x,old_y+tape_height+1] = rgb(average[x]/tally) + tally = 0 + average = [0.0] * width + old_y = y + for x in range(len(zs)): + average[x] += zs[x] + tally += 1 + return img + +def closest_index(n, m_list, interpolate=False): + "assumes sorted m_list, returns two points for interpolate" + i = len(m_list) // 2 + jump = len(m_list) // 2 + while jump > 1: + i_down = i - jump + i_here = i + i_up = i + jump + if i_down < 0: + i_down = i + if i_up >= len(m_list): + i_up = i + e_down = abs(m_list[i_down] - n) + e_here = abs(m_list[i_here] - n) + e_up = abs(m_list[i_up] - n) + e_best = min([e_down, e_here, e_up]) + if e_down == e_best: + i = i_down + if e_up == e_best: + i = i_up + if e_here == e_best: + i = i_here + jump = jump // 2 + if not interpolate: + return i + if n < m_list[i] and i > 0: + return i-1, i + if n > m_list[i] and i < len(m_list)-1: + return i, i+1 + return i, i + +def word_aa(label, pt, fg_color, bg_color): + f = ImageFont.truetype(vera_path, pt*3) + #s = f.getsize(label) + s = f.getlength(label) + #s = (s[0], pt*3 + 3) # getsize lies, manually compute + s = (int(s + 0.5), pt*3 + 3) # getsize lies, manually compute + w_img = Image.new("RGB", s, bg_color) + w_draw = ImageDraw.Draw(w_img) + w_draw.text((0, 0), label, font=f, fill=fg_color) + #return w_img.resize((s[0]//3, s[1]//3), Image.ANTIALIAS) + return w_img.resize((s[0]//3, s[1]//3), Image.Resampling.LANCZOS) + +def blend(percent, c1, c2): + "c1 and c2 are RGB tuples" + # probably isn't gamma correct + r = c1[0] * percent + c2[0] * (1 - percent) + g = c1[1] * percent + c2[1] * (1 - percent) + b = c1[2] * percent + c2[2] * (1 - percent) + c3 = map(int, map(round, [r,g,b])) + return tuple(c3) + +def tape_lines(draw, freqs, interval, y1, y2, used=set()): + min_f = min(freqs) + max_f = max(freqs) + "returns the number of lines" + low_f = (min_f // interval) * interval + high_f = (1 + max_f // interval) * interval + hits = 0 + blur = lambda p: blend(p, (255, 255, 0), (0, 0, 0)) + for i in range(int(low_f), int(high_f), int(interval)): + if not (min_f < i < max_f): + continue + hits += 1 + if i in used: + continue + x1,x2 = closest_index(i, args.freqs, interpolate=True) + if x1 == x2: + draw.line([x1,y1,x1,y2], fill='black') + else: + percent = (i - args.freqs[x1]) / float(args.freqs[x2] - args.freqs[x1]) + draw.line([x1,y1,x1,y2], fill=blur(percent)) + draw.line([x2,y1,x2,y2], fill=blur(1-percent)) + used.add(i) + return hits + +def tape_text(img, freqs, interval, y, used=set()): + min_f = min(freqs) + max_f = max(freqs) + low_f = (min_f // interval) * interval + high_f = (1 + max_f // interval) * interval + for i in range(int(low_f), int(high_f), int(interval)): + if i in used: + continue + if not (min_f < i < max_f): + continue + x = closest_index(i, freqs) + s = str(i) + if interval >= 1e6: + s = '%iM' % (i/1e6) + elif interval > 1000: + s = '%ik' % ((i/1e3) % 1000) + if s.startswith('0'): + s = '%iM' % (i/1e6) + else: + s = '%i' % (i%1000) + if s.startswith('0'): + s = '%ik' % ((i/1e3) % 1000) + if s.startswith('0'): + s = '%iM' % (i/1e6) + w = word_aa(s, tape_pt, 'black', 'yellow') + img.paste(w, (x - w.size[0]//2, y)) + used.add(i) + +def shadow_text(draw, x, y, s, font, fg_color='white', bg_color='black'): + draw.text((x+1, y+1), s, font=font, fill=bg_color) + draw.text((x, y), s, font=font, fill=fg_color) + +def create_labels(args, img): + draw = ImageDraw.Draw(img) + font = ImageFont.load_default() + pixel_bandwidth = args.pixel_bandwidth + + draw.rectangle([0,0,img.size[0],tape_height], fill='yellow') + min_freq = min(args.freqs) + max_freq = max(args.freqs) + delta = max_freq - min_freq + width = len(args.freqs) + height = len(args.times) + label_base = 9 + + for i in range(label_base, 0, -1): + interval = int(10**i) + low_f = (min_freq // interval) * interval + high_f = (1 + max_freq // interval) * interval + hits = len(range(int(low_f), int(high_f), interval)) + if hits >= 4: + label_base = i + break + label_base = 10**label_base + + for scale,y in [(1,10), (5,15), (10,19), (50,22), (100,24), (500, 25)]: + hits = tape_lines(draw, args.freqs, label_base/scale, y, tape_height) + pixels_per_hit = width / hits + if pixels_per_hit > 50: + tape_text(img, args.freqs, label_base/scale, y-tape_pt) + if pixels_per_hit < 10: + break + + start, stop = args.start_stop + duration = stop - start + duration = duration.days * 24*60*60 + duration.seconds + 30 + pixel_height = duration / len(args.times) + hours = int(duration / 3600) + minutes = int((duration - 3600*hours) / 60) + + if args.time_tick: + label_format = "%H:%M:%S" + if args.time_tick % (60*60*24) == 0: + label_format = "%Y-%m-%d" + elif args.time_tick % 60 == 0: + label_format = "%H:%M" + label_next = datetime.datetime(start.year, start.month, start.day, start.hour) + tick_delta = datetime.timedelta(seconds = args.time_tick) + while label_next < start: + label_next += tick_delta + last_y = -100 + full_height = args.pix_height + for y,t in enumerate(args.times): + label_time = date_parse(t) + if label_time < label_next: + continue + if args.compress: + y = full_height - time_compression(height - y, args.compress) + if y - last_y > 15: + shadow_text(draw, 2, y+tape_height, label_next.strftime(label_format), font) + last_y = y + label_next += tick_delta + + margin = 2 + if args.time_tick: + margin = 60 + shadow_text(draw, margin, img.size[1] - 45, 'Duration: %i:%02i' % (hours, minutes), font) + shadow_text(draw, margin, img.size[1] - 35, 'Range: %.2fMHz - %.2fMHz' % (min_freq/1e6, (max_freq+pixel_bandwidth)/1e6), font) + shadow_text(draw, margin, img.size[1] - 25, 'Pixel: %.2fHz x %is' % (pixel_bandwidth, int(round(pixel_height))), font) + shadow_text(draw, margin, img.size[1] - 15, 'Started: {0}'.format(start), font) + # bin size + +print("loading") +args = prepare_args() +raw_data = open_raw_data(args.input_path) +summarize_pass(args) + +print("drawing") +img = push_pixels(args) + +print("labeling") +create_labels(args, img) + +print("saving") +img.save(args.output_path) + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..36e75de --- /dev/null +++ b/src/main.rs @@ -0,0 +1,135 @@ +use std::str::FromStr; + +use chrono::{Datelike, Timelike}; +use clap::Parser; + +#[derive(Debug, Clone)] +struct FreqRange { + lower: String, + upper: String, + bin_size: String, +} +impl std::fmt::Display for FreqRange { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}:{}", self.lower, self.upper, self.bin_size) + } +} + +impl FromStr for FreqRange { + type Err = std::io::Error; + + fn from_str(s: &str) -> Result { + use std::io::ErrorKind; + let mut ittr = s.split(':'); + let lower = ittr + .next() + .ok_or(Self::Err::from(ErrorKind::InvalidInput))? + .to_string(); + let upper = ittr + .next() + .ok_or(Self::Err::from(ErrorKind::InvalidInput))? + .to_string(); + let bin_size = ittr + .next() + .ok_or(Self::Err::from(ErrorKind::InvalidInput))? + .to_string(); + Ok(FreqRange { + lower, + upper, + bin_size, + }) + } +} + +/// rrtlpower, a simple front end to rtl_power +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct CliArgs { + #[arg(short, long)] + freq_range: FreqRange, + + #[arg(short, long)] + integration_interval: Option, + + #[arg(short, long)] + exit_timer: Option, + + #[arg(short, long)] + tuner_gain: Option, + + #[arg(short, long)] + crop_percent: Option, +} +fn main() { + let args = CliArgs::parse(); + let now = chrono::Utc::now(); + + let filename = format!( + "{:04}-{:02}-{:02}-{:02}:{:02}:{:02}z_{}", + now.year(), + now.month(), + now.day(), + now.hour(), + now.minute(), + now.second(), + args.freq_range, + ); + println!("Using filename {filename}.csv"); + + let mut rtl_cmd = std::process::Command::new("rtl_power"); + rtl_cmd.arg("-f"); + rtl_cmd.arg(args.freq_range.to_string()); + // Optional args + if let Some(interval) = args.integration_interval { + rtl_cmd.arg("-i"); + rtl_cmd.arg(interval.clone()); + } + if let Some(timer) = args.exit_timer { + rtl_cmd.arg("-e"); + rtl_cmd.arg(timer); + } + if let Some(gain) = args.tuner_gain { + rtl_cmd.arg("-g"); + rtl_cmd.arg(gain); + } + if let Some(crop) = args.crop_percent { + rtl_cmd.arg("-c"); + rtl_cmd.arg(crop); + } + rtl_cmd.arg(format!("{filename}.csv")); + + println!( + "rtl_power{}", + rtl_cmd + .get_args() + // TODO: Waiting for https://github.com/rust-lang/rust/issues/79524 + .map(|arg| format!(" {}", arg.to_str().unwrap())) + .collect::() + ); + + match rtl_cmd.status() { + Ok(status) if status.success() => {} + //TODO: More verbose error status message + _ => { + println!("\nError running rtl_power! Exiting early"); + return; + } + } + + let mut heatmap_cmd = std::process::Command::new("python3"); + heatmap_cmd.arg("heatmap.py"); + heatmap_cmd.arg(format!("{filename}.csv")); + heatmap_cmd.arg(format!("{filename}.png")); + + /*println!( + "\npython3{}", + heatmap_cmd + .get_args() + // TODO: Waiting for https://github.com/rust-lang/rust/issues/79524 + .map(|arg| format!(" {}", arg.to_str().unwrap())) + .collect::() + );*/ + println!("python3 heatmap.py {filename}.csv {filename}.png"); + + println!("{}", heatmap_cmd.status().unwrap()); +}