From bc34f02444e8dc2b351f96383fb797f55b27c248 Mon Sep 17 00:00:00 2001 From: Leon Jacobs Date: Sun, 9 Oct 2016 10:03:24 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=A6=20First=20release!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + MANIFEST.in | 1 + README.md | 93 +- images/banner.png | Bin 0 -> 23960 bytes ooktools/__init__.py | 33 + ooktools/commands/__init__.py | 0 ooktools/commands/converstions.py | 77 ++ ooktools/commands/graphing.py | 165 +++ ooktools/commands/grc.py | 56 + ooktools/commands/information.py | 177 +++ ooktools/commands/signalling.py | 363 +++++ ooktools/console.py | 259 ++++ ooktools/share/template.grc | 2084 +++++++++++++++++++++++++++++ ooktools/utilities.py | 222 +++ ooktools/validators.py | 163 +++ requirements.txt | 5 + setup.py | 29 + 17 files changed, 3729 insertions(+), 1 deletion(-) create mode 100644 MANIFEST.in create mode 100644 images/banner.png create mode 100644 ooktools/__init__.py create mode 100644 ooktools/commands/__init__.py create mode 100644 ooktools/commands/converstions.py create mode 100644 ooktools/commands/graphing.py create mode 100644 ooktools/commands/grc.py create mode 100644 ooktools/commands/information.py create mode 100644 ooktools/commands/signalling.py create mode 100644 ooktools/console.py create mode 100755 ooktools/share/template.grc create mode 100644 ooktools/utilities.py create mode 100644 ooktools/validators.py create mode 100644 requirements.txt create mode 100644 setup.py diff --git a/.gitignore b/.gitignore index 72364f9..8f647b7 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ ENV/ # Rope project settings .ropeproject + +# Ignore Jetbrains editors +.idea/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..e499e13 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include ooktools/share/template.grc diff --git a/README.md b/README.md index 2f7f956..5524653 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,93 @@ -# ooktools +

+ ooktools +
+ +

+ +**ooktools** aims to help with the reverse engineering of [on-off keying](https://en.wikipedia.org/wiki/On-off_keying) data sources such as wave files or raw frames captured using [RfCat](https://bitbucket.org/atlas0fd00m/rfcat). + +--- + +### why? +I recently [played around a little with static key remotes](https://virtualenv.pypa.io/en/stable/), and wrote some code to help with the reverse engineering thereof. + +### major features + +- Binary string extraction from wave file recordings. +- Wave file cleanups to remove noise in On-off keying recordings. +- Graphing capabilities for wave files. +- General information extraction of wave files. +- Signal recording and playback using `json` definition files that can be shared. +- Plotting of data from the previously mentioned `json` recordings. +- Signal searching for On-off keying type data. +- Sending signals in both binary, complete PWM formatted or hex strings using an RfCat dongle. +- Gnuradio `.grc` template file generation. + +### installation +You can install `ooktools` in two ways. Either from `pip` or from source. In case of a source installation, you may want to optionally consider installing it in a [virtualenv](https://virtualenv.pypa.io/en/stable/). + +#### rfcat +In both installation cases, you need to install [RfCat](https://bitbucket.org/atlas0fd00m/rfcat). This too can be done in two ways. On Kali Linux, you can install it with a simple `apt` command: + +``` +$ apt install rfcat +``` + +Or, if you need to manually install it, download the latest [RfCat sources](https://bitbucket.org/atlas0fd00m/rfcat/downloads) and run the `setup.py` script: + +``` +$ wget -c https://bitbucket.org/atlas0fd00m/rfcat/downloads/rfcat_150225.tgz +$ tar xjvf rfcat_150225.tgz +$ cd rfcat_150225 +$ python setup.py install +``` +#### ooktools +Pip Package: +``` +$ pip install ooktools +``` + +Using this method, you should have the `ooktools` command available globally. + +From source: +``` +$ git clone https://github.com/leonjza/ooktools.git +$ cd ooktools +$ pip install -r requirements.txt +``` + +If you installed from source then you can invoke `ooktools` with as a module using `python -m ooktools.console` from the directory you cloned to. + +### usage +There are a number of sub commands that are grouped by major category. At anytime, add the `--help` argument to get a full description of any other sub commands and or arguments available. + +``` +$ ooktools --help + _ _ _ + ___ ___| |_| |_ ___ ___| |___ +| . | . | '_| _| . | . | |_ -| +|___|___|_,_|_| |___|___|_|___| v0.1 On-off keying tools for your SD-arrrR +https://github.com/leonjza/ooktools + +Usage: ooktools [OPTIONS] COMMAND [ARGS]... + +Options: + --help Show this message and exit. + +Commands: + gnuradio GNU Radio Commands. + signal Signal Commands. + wave Wave File Commands. +``` + +For examples, please refer to the blogpost [here](https://leonjza.github.io/blog/2016/10/08/introducing-ooktools.-on-off-keying-tools-for-your-sdr/). + +### known issues +Nothing is perfect I guess. One of the biggest problems would be test cases and variations. So, here is the stuff that I know is not 100% perfect. Pull requests welcome! + +- Wave file operations such as `graph` and `clean` break when the wave file is too long. ~50M samples seem to start hitting the point of breakage. +- The `matplotlib` usage is silly from a performance perspective. Its the main reason I don't have live graphs in too as I just cant get it working great. + +## license +Please refer to the [LICENSE](https://github.com/leonjza/ooktools/blob/master/LICENSE) file. diff --git a/images/banner.png b/images/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..a34fcc91d59dff678dd43a2abc18d7f799676a58 GIT binary patch literal 23960 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 literal 0 HcmV?d00001 diff --git a/ooktools/__init__.py b/ooktools/__init__.py new file mode 100644 index 0000000..49e8ec8 --- /dev/null +++ b/ooktools/__init__.py @@ -0,0 +1,33 @@ +# The MIT License (MIT) +# +# Copyright (c) 2016 Leon Jacobs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import click +from pkg_resources import get_distribution + +banner = (""" _ _ _ + ___ ___| |_| |_ ___ ___| |___ +| . | . | '_| _| . | . | |_ -| +|___|___|_,_|_| |___|___|_|___| v{} +On-off keying tools for your SD-arrrR""".format(get_distribution('ooktools').version)) + +click.secho('{}'.format(banner), bold=True) +click.secho('https://github.com/leonjza/ooktools\n', dim=True) diff --git a/ooktools/commands/__init__.py b/ooktools/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ooktools/commands/converstions.py b/ooktools/commands/converstions.py new file mode 100644 index 0000000..1fa4e0d --- /dev/null +++ b/ooktools/commands/converstions.py @@ -0,0 +1,77 @@ +# The MIT License (MIT) +# +# Copyright (c) 2016 Leon Jacobs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import absolute_import + +import struct +import wave + +import click +import numpy + +from ..utilities import cleanup_wave_data + + +def clean_pwm_wave(source, destination): + """ + Clean up a source wave file with PWM encoded data. + + The basic idea here is to read the source data and calculate + the average height of the samples. Using this height, we iterate + over all of the samples and replace the actual value with + a 1 or a 0. + + Finally, in order to improve the graphing ability, we amplify + the 1's value by 10000 and write the results out to + a new wave file with the same attributes as the source. + + :param destination: + :param source: + :return: + """ + + # Read the frames into a numpy array + signal = numpy.fromstring(source.readframes(-1), dtype=numpy.int16) + signal = cleanup_wave_data(signal) + + # Prepare the output wave file. We will use exactly + # the same parameters as the source + output_wave = wave.open(destination, 'w') + output_wave.setparams(source.getparams()) + + click.secho('Normalizing values..') + + frames = [] + + for _, value in numpy.ndenumerate(signal): + + # If the value is one, amplify it so that it is + # *obviously* not 0. This makes graphing easier too. + if value == 1: + value *= 10000 + + frames.append(struct.pack('h', value)) + + click.secho('Writing output to file: {}'.format(destination)) + output_wave.writeframes(''.join(frames)) + + return diff --git a/ooktools/commands/graphing.py b/ooktools/commands/graphing.py new file mode 100644 index 0000000..634c94b --- /dev/null +++ b/ooktools/commands/graphing.py @@ -0,0 +1,165 @@ +# The MIT License (MIT) +# +# Copyright (c) 2016 Leon Jacobs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from datetime import datetime + +import click +import numpy +import peakutils + +try: + import matplotlib.pyplot as plt + import matplotlib.animation as animation + + plotting = True + +except RuntimeError: + plotting = False + + +def _can_plot(): + if not plotting: + click.secho('Plotting library was not sucesfully imported.\n' + 'Ensure your python installation can run `import ' + 'matplotlib.pyplot` without errors.', fg='red') + + return False + + return True + + +def generate_wave_graph(source, peaks): + """ + Generate a plot from a wave file source. + Optionally, include peak calculations. + + Source: + https://github.com/MonsieurV/py-findpeaks/blob/master/tests/vector.py + + :param source: + :param peaks: + :return: + """ + + if not _can_plot(): + return + + click.secho('Reading {} frames from source.'.format(source.getnframes()), fg='green') + click.secho('Preparing plot.', fg='green', dim=True) + + # Read the source data + signal = source.readframes(-1) + signal = numpy.fromstring(signal, dtype=numpy.int16) + + _, ax = plt.subplots(1, 1, figsize=(8, 4)) + ax.plot(signal, 'b', lw=1) + + # If we have to include peak information, calculate that + if peaks: + click.secho('Calculating peak information too.', dim=True) + indexes = peakutils.indexes(signal, thres=0.02 / max(signal), min_dist=100) + + if indexes.size: + label = 'peak' + label = label + 's' if indexes.size > 1 else label + ax.plot(indexes, signal[indexes], '+', mfc=None, mec='r', mew=2, ms=8, + label='%d %s' % (indexes.size, label)) + ax.legend(loc='best', framealpha=.5, numpoints=1) + + # Continue graphing the source information + ax.set_xlim(-.02 * signal.size, signal.size * 1.02 - 1) + ymin, ymax = signal[numpy.isfinite(signal)].min(), signal[numpy.isfinite(signal)].max() + yrange = ymax - ymin if ymax > ymin else 1 + ax.set_ylim(ymin - 0.1 * yrange, ymax + 0.1 * yrange) + ax.set_xlabel('Frame #', fontsize=14) + ax.set_ylabel('Amplitude', fontsize=14) + + # Finally, generate the graph + plt.show() + + return + + +def generage_saved_recording_graphs(source, count, series): + """ + Plot frames from a recording + + :param source: + :param count: + :param series: + :return: + """ + + if not _can_plot(): + return + + click.secho('Source Information:') + click.secho('Recording Date: {}'.format(datetime.fromtimestamp(source['date'])), bold=True, fg='green') + click.secho('Recording Frequency: {}'.format(source['frequency']), bold=True, fg='green') + click.secho('Recording Baud: {}'.format(source['baud']), bold=True, fg='green') + click.secho('Recording Framecount: {}'.format(source['framecount']), bold=True, fg='green') + + # If we dont have a series to plot, plot the number of frames + # from the start to count + if not series: + data = source['frames'][:count] + click.secho('Preparing Graph for {} plots...'.format(count)) + else: + start, end = series + data = source['frames'][start:end] + click.secho('Preparing Graph for {} plots from {} to {}...'.format(len(data), start, end)) + + # Place holder to check if we have set the first plot yet + fp = False + + # Start the plot. + fig = plt.figure(1) + fig.canvas.set_window_title('Frame Data Comparisons') + + # Loop over the frames, plotting them + for (index,), frame in numpy.ndenumerate(data): + + # If it is not the first plot, set it keep note of the + # axo variable. This is the original plot. + if not fp: + + axo = plt.subplot(len(data), 1, index + 1) + axo.grid(True) + axo.set_xlabel('Symbols') + axo.set_ylabel('Aplitude') + axo.xaxis.set_label_position('top') + + # Flip the first plot variable as this is done + fp = True + + # If we have plotted before, set the new subplot and share + # the X & Y axis with axo + else: + ax = plt.subplot(len(data), 1, index + 1, sharex=axo, sharey=axo) + ax.grid(True) + + # Plot the data + plt.plot(numpy.frombuffer(buffer=str(frame), dtype=numpy.int16)) + + # Show the plot! + click.secho('Launching the graphs!') + plt.show() diff --git a/ooktools/commands/grc.py b/ooktools/commands/grc.py new file mode 100644 index 0000000..dab8dad --- /dev/null +++ b/ooktools/commands/grc.py @@ -0,0 +1,56 @@ +# The MIT License (MIT) +# +# Copyright (c) 2016 Leon Jacobs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from os import path + +import click + + +def generate_template(destination): + """ + Write a copy of the GNU Radio template to + a new file. + + :param destination: + :return: + """ + + template_store = '/../share' + template_file = '/template.grc' + + # Resolve the path to the source template + source_template = path.realpath( + path.abspath(path.dirname(__file__) + template_store)) + template_file + + click.secho('Writing a GNU Radio template XML to: {}'.format(destination), fg='green') + + # Open the template + with open(source_template, 'r') as s: + + # And write it to the new destination. + with open(destination, 'w') as d: + + # Line by line. + for line in s.readlines(): + d.write(line) + + click.secho('Done.') diff --git a/ooktools/commands/information.py b/ooktools/commands/information.py new file mode 100644 index 0000000..334acae --- /dev/null +++ b/ooktools/commands/information.py @@ -0,0 +1,177 @@ +# The MIT License (MIT) +# +# Copyright (c) 2016 Leon Jacobs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import division +from __future__ import print_function +from __future__ import absolute_import + +import click +import numpy + +from ..utilities import cleanup_wave_data + +# Let numpy print full arrays +numpy.set_printoptions(threshold=numpy.nan) + + +def get_wave_info(wave_file): + """ + Get Wave File Information + + :param wave_file: + :return: + """ + click.secho('Wave File information:') + + click.secho('Audio Channels: {}'.format(wave_file.getnchannels()), fg='green') + click.secho('Sample Width (in bytes): {}'.format(wave_file.getsampwidth()), fg='green') + click.secho('Sampling Frequency: {}'.format(wave_file.getframerate()), fg='green') + click.secho('Number of Audio Frames: {}'.format(wave_file.getnframes()), fg='green') + click.secho('Compression Type: {}'.format(wave_file.getcomptype()), fg='green') + click.secho('') + click.secho('Params of get*: {}'.format(wave_file.getparams()), fg='green') + + +def get_wave_binary(source): + """ + Attempt to represent the binary of an OOK wave source. + + The basic idea is to determine the average of all of + the frame values. Then, determine if the actual value is + above/below the average and flip the value to a 1/0 + respectively. This is just to cleanup the signal, + making it easier to work with. + + Next, count how many 1's and 0's there are, average those + values again, and run a similar function to output 1's and 0's + + NOTE: Large wave files dont work with this as the averages + are not really useful with that amount of data. + + :param source: + :return: + """ + + # Read the frames into a numpy array + signal = numpy.fromstring(source.readframes(-1), dtype=numpy.int16) + signal = cleanup_wave_data(signal) + + peaks = [] + shortest_peak = float('inf') + longest_peak = 0 + current_sample_count = 0 + last_sample_value = 0 + + # Attempt to determine the length of the longest & shortest peak + # by iterating over the cleaned up signal, counting the number + # of samples before we hit a zero value. + for (index,), value in numpy.ndenumerate(signal): + + # Zero indicates *no* signal. + if value == 0: + + # If the last sample value was not zero, it may + # mean that the previous value had signal and we + # can go ahead and calculate distances and + # record the values in the peaks list + if last_sample_value != 0: + + # Check if we still know what the shortest + # peak was + if current_sample_count < shortest_peak: + shortest_peak = current_sample_count + + # Check if we still know what the longest + # peak was + if current_sample_count > longest_peak: + longest_peak = current_sample_count + + # Add this peaks data to the peaks list for later processing + peaks.append({'end_index': index, 'distance': current_sample_count}) + + # Null the current sample count as we obviously + # hit a zero indicating no signal + current_sample_count = 0 + + else: + + # We have a value, therefore signal, so increment + # the counter for the number of samples we have + # processed so far + current_sample_count += 1 + + # Update the last sample value so that the marker + # for a zero value can process it. + last_sample_value = value + + # Info + click.secho('Samples in (Shortest Peak: {}) (Longest Peak: {})'.format(shortest_peak, longest_peak), fg='green') + + # Calculate the Baud rate of PWM + click.secho('Math for baud rate will be {}/({}/float({}))'.format(1.0, shortest_peak, source.getframerate())) + sample_rate = int(1.0 / (shortest_peak / float(source.getframerate()))) + click.secho('Source wave file has baud rate of: {}'.format(sample_rate), fg='green') + + # If the shortest peak ends up being one, stop. There nothing + # of value that can be done here. + if shortest_peak in [1, float('inf')]: + click.secho('Can not determine key with a shortest peak of {}.' + ' Try re-recording the sample, or shortening it.'.format(shortest_peak), fg='red') + return + + key = [] + + for (peak_index,), peak_data in numpy.ndenumerate(peaks): + + # Calculate how many times the shortest peak fits in the + # current peak. + baud_fits_in_peak = peak_data['distance'] // shortest_peak + + # Update the peaks data with the count for this baud + # fitting + peaks[peak_index]['baud_fit'] = baud_fits_in_peak + + if baud_fits_in_peak == 1: + + # print('Appending 1 for {}'.format(peak_data['distance'])) + key.append(1) + + elif baud_fits_in_peak == 2: + + # print('Appending 0 for {}'.format(peak_data['distance'])) + key.append(0) + + else: + + # Getting here we assume there is some form of 'break' + # For now, just discard this. + click.secho('Skipping for {} as it fits {} times(s)'.format( + peak_data['distance'], baud_fits_in_peak), dim=True) + key.append('[ ]') + + # Print the keys we have found! + if len(key) > 0: + + # Indicate that [ ] mean large breaks in the signal + if '[ ]' in key: + click.secho('[ ] indicates breaks', fg='white') + click.secho('Key Data: {}'.format(''.join(str(x) for x in key)), bold=True) diff --git a/ooktools/commands/signalling.py b/ooktools/commands/signalling.py new file mode 100644 index 0000000..55f9aa2 --- /dev/null +++ b/ooktools/commands/signalling.py @@ -0,0 +1,363 @@ +# The MIT License (MIT) +# +# Copyright (c) 2016 Leon Jacobs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import absolute_import +from __future__ import print_function + +import json +import time +from datetime import datetime + +import bitstring +import click +import numpy +import rflib +from rflib.chipcon_usb import keystop + +from ..utilities import configure_dongle +from ..utilities import oneline_print +from ..utilities import valid_packet + + +def search(start_frequency, end_frequency, baud, increment, framecount): + """ + Search for a signal + + :param start_frequency: + :param end_frequency: + :param baud: + :param increment: + :param framecount: + :return: + """ + + click.secho('Starting on frequency: {}'.format(start_frequency), fg='green') + click.secho('Ending on frequency: {}'.format(end_frequency), fg='red') + + # Setup RFCat + click.secho('Configuring Radio', fg='yellow') + d = rflib.RfCat() + configure_dongle(d, frequency=start_frequency, pktflen=0, baud=baud, lowball=True) + + # The frequency variable is used to track the current + # frequency that we are scanning. + frequency = start_frequency + + # The signals variable will hold all of the frequencies + # and the valid data packets found for that frequency. + signals = dict() + + click.secho('\nScanning frequency range. Press [ENTER] to stop.', + fg='green', bold=True) + + # Lables for the values that will update during scanning + click.secho('Frequency | Framecount | Found', dim=True) + + # While ENTER has not yet been pressed, iterate over + # the frequencies as calculated for x abount of frame counts. + # Each time valid data is detected, the data frame is added + # to a dictionary using the frequency it was detected on as + # the key. + while not keystop(): + + # Read packets 'framecount' amount of times + for framecounter in xrange(0, framecount): + + # Status Update. Spacing is to match up with the previously + # echoed 'lables'. + oneline_print('{} | {}/{} | {}'.format(frequency, + framecounter, + framecount, + len(signals))) + + # This try/except is just to catch the UsbTimeout + # that gets thrown if data has not been received in + # the time specified in RFrecv. + try: + + # Get a packet from the RFcat radio + pkt, _ = d.RFrecv(timeout=1000) + packet = pkt.encode('hex') + + # If we have a 'valid' packet, append it as a frame + # to the frequency. A valid packet is defined as one + # that has 38 0's in its hex representation. + if valid_packet(packet=packet, constraint='0' * 38): + + # If this is the first time we are seeing valid + # data on this frequency, prepare a data dict + # with the first packet added. + if frequency not in signals: + signals[frequency] = {'data': [(pkt, packet)]} + + # Otherwise, just append the packet we just got + # to the existing dict for this frequency + else: + signals[frequency]['data'].append((pkt, packet)) + + # A timeout in RFrecv will raise an exception. + except rflib.ChipconUsbTimeoutException: + pass + + # Set the new frequency incremented by the + # increment count. If we have passed the end + # frequency, reset back to the start. + if frequency > end_frequency: + frequency = start_frequency + else: + frequency += increment + + # Update the radio to the new frequency + d.setFreq(freq=frequency) + + # Try and be nice to the radio by setting it to Idle. + click.secho('\nIdling Radio\n', fg='yellow') + d.setModeIDLE() + + # If we found nothing, just end. + if not signals: + click.secho('No signals were found.', fg='red') + return + + # Sort the output signals. + # signals.items() translates into a list of tuples with + # t[0] being the frequency and t[1] the data: + # eg: + # [(433800000, {'data': ['x', 'x', 'x']})] + f = sorted(signals.items(), key=lambda t: (len(t[1]['data']), t[0]), reverse=True) + + # Iterate the sorted list, printing how many data packets + # were found on what frequency. + click.secho('# of valid packets per frequency', fg='green') + click.secho('Frequency | # packets', bold=True) + for frequency, data in f: + click.secho('{} | {}'.format(frequency, len(data['data']))) + + return + + +def record(frequency, baud, framecount, destination): + """ + Record symbols from an RFCat dongle to a file. + + :param frequency: + :param baud: + :param framecount: + :param destination: + :return: + """ + + click.secho('Recording on frequency: {} to {}'.format(frequency, destination), fg='green') + + # Setup RFCat + click.secho('Configuring Radio', fg='yellow') + d = rflib.RfCat() + configure_dongle(d, frequency=frequency, pktflen=0, baud=baud, lowball=True) + + # The final payload that will be written + payload = { + 'date': time.mktime(datetime.now().timetuple()), + 'frequency': frequency, + 'baud': baud, + 'framecount': 0, + 'frames': [] + } + + # A help message to get maximum # of frames written to file. + click.secho('For maximum frames, press and release the remote multiple times.', fg='green') + + # Capture frames! + for c in xrange(0, framecount): + oneline_print('Progress [{}/{}] Frames: {}'.format(c, framecount, len(payload['frames']))) + + # This try/except is just to catch the UsbTimeout + # that gets thrown if data has not been received in + # the time specified in RFrecv. + try: + + # Get a packet from the RFcat radio + pkt, _ = d.RFrecv(timeout=1000) + packet = pkt.encode('hex') + + # If we have a 'valid' packet, append it as a frame + # to the frequency. A valid packet is defined as one + # that has 38 0's in its hex representation. + if valid_packet(packet=packet, constraint='0' * 38): + payload['framecount'] += 1 + payload['frames'].append(packet) + + # A timeout in RFrecv will raise an exception. + except rflib.ChipconUsbTimeoutException: + pass + + # Try and be nice to the radio by setting it to Idle. + click.secho('\nIdling Radio\n', fg='yellow') + d.setModeIDLE() + + click.secho('Writing saved payload to: {}'.format(destination), bold=True) + with open(destination, 'wb') as f: + f.write(json.dumps(payload)) + + return + + +def play(source, repeat): + """ + Replay frames from a previous recording + + :param source: + :param repeat: + :return: + """ + + click.secho('Source Information:') + click.secho('Recording Date: {}'.format(datetime.fromtimestamp(source['date'])), bold=True, fg='green') + click.secho('Recording Frequency: {}'.format(source['frequency']), bold=True, fg='green') + click.secho('Recording Baud: {}'.format(source['baud']), bold=True, fg='green') + click.secho('Recording Framecount: {}'.format(source['framecount']), bold=True, fg='green') + + # Setup RFCat + click.secho('Configuring Radio', fg='yellow') + d = rflib.RfCat() + configure_dongle(d, frequency=source['frequency'], pktflen=0, baud=source['baud']) + + # Transmit the frames from the recordin + click.secho('Processing {} frames from the source file'.format(source['framecount']), bold=True) + for (index,), frame in numpy.ndenumerate(source['frames']): + oneline_print('Progress [{}/{}]'.format(index + 1, source['framecount'])) + + # Transmit the frame! + d.RFxmit(data=frame.decode('hex'), repeat=repeat) + + # Try and be nice to the radio by setting it to Idle. + click.secho('\nIdling Radio\n', fg='yellow') + d.setModeIDLE() + + return + + +def send_binary(frequency, prefix, suffix, baud, repeat, data, full): + """ + Send a binary string as hex over an RFcat radio + + :param frequency: + :param prefix: + :param suffix: + :param baud: + :param repeat: + :param data: + :param full: + :return: + """ + + click.secho('Building RF Data from binary string: {}'.format(data), bold=True) + + if full: + # Warn that the data length is a little short for a 'full' key + if len(data) <= 12: + click.secho('WARNING: Data specified as full binary ' + 'but its only {} long'.format(len(data)), fg='yellow') + + # Convert the data to bytes for the radio to send + rf_data = bitstring.BitArray(bin=data).tobytes() + + else: + # Calculate the PWM version of the binary we got. + + # First, we instantiate the final data string with + # the prefix value + rf_data_string = prefix + + # Next, flip the 1's and 0's to their PWM versions + for bit in data: + + # rf_data_string += '100' if bit == '1' else '110' + x = None + + if bit == '1': + x = '100' + + if bit == '0': + x = '110' + + rf_data_string += x + + # Finally, add the suffix + rf_data_string += suffix + click.secho('Full PWM key: {}'.format(rf_data_string), fg='green') + + # Convert the data to bytes for the radio to send + rf_data = bitstring.BitArray(bin=rf_data_string).tobytes() + + # Print some information about what we have so far + click.secho('RF data packet length: {}'.format(len(rf_data)), fg='green') + click.secho('Packet as hex: {}'.format(rf_data.encode('hex')), fg='green') + click.secho('Preparing radio', fg='yellow') + + # Setup the Radio + d = rflib.RfCat() + configure_dongle(d, frequency=frequency, pktflen=len(rf_data), baud=baud) + + # Transmit the data + click.secho('Sending transmission \'{}\' time(s)...'.format(repeat), fg='red', bold=True) + d.RFxmit(data=rf_data, repeat=repeat) + + # Idle the radio + click.secho('Idling radio', fg='yellow') + d.setModeIDLE() + + return + + +def send_hex(frequency, baud, repeat, data): + """ + Send a hex string as hex over an RFcat radio + + :param frequency: + :param baud: + :param repeat: + :param data: + :return: + """ + + click.secho('Building RF Data from hex string: {}'.format(data), bold=True) + rf_data = bitstring.BitArray(hex=data) + + # Print some information about what we have so far + click.secho('Full PWM key: {}'.format(rf_data.bin), fg='green') + click.secho('RF data packet length: {}'.format(len(rf_data)), fg='green') + click.secho('Packet as hex: {}'.format(rf_data), fg='green') + click.secho('Preparing radio', fg='yellow') + + # Setup the Radio + d = rflib.RfCat() + configure_dongle(d, frequency=frequency, pktflen=len(rf_data), baud=baud) + + # Transmit the data + click.secho('Sending transmission \'{}\' time(s)...'.format(repeat), fg='red', bold=True) + d.RFxmit(data=rf_data.tobytes(), repeat=repeat) + + # Idle the radio + click.secho('Idling radio', fg='yellow') + d.setModeIDLE() + + return diff --git a/ooktools/console.py b/ooktools/console.py new file mode 100644 index 0000000..555a65c --- /dev/null +++ b/ooktools/console.py @@ -0,0 +1,259 @@ +# The MIT License (MIT) +# +# Copyright (c) 2016 Leon Jacobs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import absolute_import + +import click + +from .commands import converstions +from .commands import graphing +from .commands import grc +from .commands import information +from .commands import signalling +from .validators import validate_binary_string +from .validators import validate_hex_string +from .validators import validate_recording_json +from .validators import validate_series +from .validators import validate_wave + + +# Start the Click command group +@click.group() +def cli(): + pass + + +@cli.group() +def wave(): + """ + Wave File Commands. + """ + + pass + + +@cli.group() +def gnuradio(): + """ + GNU Radio Commands. + """ + + pass + + +@cli.group() +def signal(): + """ + Signal Commands. + """ + + pass + + +@signal.group() +def send(): + """ + Send signals using a RFCat dongle. + """ + + pass + + +@wave.command() +@click.option('--source', '-S', required=True, help='Wave file to use as input.', + type=click.Path(exists=True), callback=validate_wave) +def info(source): + """ + Print information about a wave file. + """ + + information.get_wave_info(source) + return + + +@wave.command() +@click.option('--source', '-S', required=True, help='Wave file to use as input.', + type=click.Path(exists=True), callback=validate_wave) +@click.option('--destination', '-D', required=True, help='Destination, cleaned up wave file.', + type=click.Path(writable=True, resolve_path=True)) +def clean(source, destination): + """ + Cleanup a PWM source Wave file. + """ + + converstions.clean_pwm_wave(**locals()) + return + + +@wave.command() +@click.option('--source', '-S', required=True, help='Wave file to use as input.', + type=click.Path(exists=True), callback=validate_wave) +@click.option('--peaks', '-p', is_flag=True, default=False, help='Include peaks in graph.') +def graph(source, peaks): + """ + Plot wave file values. + """ + + graphing.generate_wave_graph(**locals()) + return + + +@wave.command() +@click.option('--source', '-S', required=True, help='Wave file to use as input.', + type=click.Path(exists=True), callback=validate_wave) +def binary(source): + """ + Search for a binary sequence. + """ + + information.get_wave_binary(**locals()) + return + + +@signal.command() +@click.option('--start-frequency', '-S', default=430000000, show_default=True, + help='Start listening for packets on a specific frequency.') +@click.option('--end-frequency', '-E', default=440000000, show_default=True, + help='Start listening for packets on a specific frequency.') +@click.option('--baud', '-b', default=38400, show_default=True, + help='Baud rate to receive at.') +@click.option('--increment', '-i', default=50000, show_default=True, + help='Increment when searching frequencies.') +@click.option('--framecount', '-f', default=5, show_default=True, + help='Number of frames to receive per frequency.') +def search(start_frequency, end_frequency, baud, increment, framecount): + """ + Search for signals. + """ + + signalling.search(**locals()) + return + + +@signal.command() +@click.option('--frequency', '-F', default=433920000, show_default=True, + help='Frequency to record.') +@click.option('--baud', '-b', default=38400, show_default=True, + help='Baud rate to receive at.') +@click.option('--framecount', '-f', default=120, show_default=True, + help='Number of frames to record.') +@click.option('--destination', '-D', required=True, help='Destination filename for the recording.', + type=click.Path(writable=True, resolve_path=True)) +def record(frequency, baud, framecount, destination): + """ + Record frames to a file. + """ + + signalling.record(**locals()) + return + + +@signal.command() +@click.option('--source', '-S', required=True, help='Source recording file as input.', + type=click.Path(exists=True), callback=validate_recording_json) +@click.option('--repeat', '-r', default=10, show_default=True, + help='Number of times to repeat a frame.') +def play(source, repeat): + """ + Play frames from a source file. + """ + + signalling.play(**locals()) + return + + +@signal.command() +@click.option('--source', '-S', required=True, help='Source recording file as input.', + type=click.Path(exists=True), callback=validate_recording_json) +@click.option('--count', '-c', default=5, show_default=True, + help='Number of frames to plot. Starts at the first frame.') +@click.option('--series', '-s', default=None, metavar='start:end', + help='Graph a specific series of fields from a recorded file.', + callback=validate_series) +def plot(source, count, series): + """ + Plot frames from a recorded signal. + """ + + graphing.generage_saved_recording_graphs(**locals()) + return + + +@send.command() +@click.option('--data', '-D', required=True, help='Source bitstring.', + callback=validate_binary_string) +@click.option('--prefix', '-p', default='', help='Signal prefix.', + callback=validate_binary_string) +@click.option('--suffix', '-s', default='', help='Signal suffix.', + callback=validate_binary_string) +@click.option('--frequency', '-F', default=433920000, show_default=True, + help='Frequency to use.') +@click.option('--baud', '-b', default=4800, show_default=True, + help='Baud rate to use.') +@click.option('--full', '-f', is_flag=True, default=False, show_default=True, + help='Is data a full bitstring.') +@click.option('--repeat', '-r', default=25, show_default=True, + help='# of times to repeat transmission.') +def binary(frequency, prefix, suffix, baud, repeat, data, full): + """ + Send binary string. + """ + + signalling.send_binary(**locals()) + return + + +@send.command() +@click.option('--data', '-D', required=True, help='Source bitstring.', + callback=validate_hex_string) +@click.option('--frequency', '-F', default=433920000, show_default=True, + help='Frequency to use.') +@click.option('--baud', '-b', default=4800, show_default=True, + help='Baud rate to use.') +@click.option('--full', '-f', is_flag=True, default=False, show_default=True, + help='Is data a full bitstring.') +@click.option('--repeat', '-r', default=25, show_default=True, + help='# of times to repeat transmission.') +def hexstring(frequency, baud, repeat, data, full): + """ + Send hex string. + """ + + signalling.send_hex(**locals()) + return + + +@gnuradio.command() +@click.option('--destination', '-D', default='remote.grc', + help='Destination filename for the recording.', show_default=True, + type=click.Path(writable=True, resolve_path=True)) +def template(destination): + """ + Generate a GNU Radio Tempalte file + """ + + grc.generate_template(**locals()) + return + + +if __name__ == '__main__': + cli() diff --git a/ooktools/share/template.grc b/ooktools/share/template.grc new file mode 100755 index 0000000..779c2ed --- /dev/null +++ b/ooktools/share/template.grc @@ -0,0 +1,2084 @@ + + + + Sat Aug 20 16:51:51 2016 + + options + + author + + + + window_size + + + + category + [GRC Hier Blocks] + + + comment + + + + description + + + + _enabled + True + + + _coordinate + (8, 8) + + + _rotation + 0 + + + generate_options + wx_gui + + + hier_block_src_path + .: + + + id + top_block + + + max_nouts + 0 + + + qt_qss_theme + + + + realtime_scheduling + + + + run_command + {python} -u {filename} + + + run_options + prompt + + + run + True + + + thread_safe_setters + + + + title + + + + + variable_slider + + comment + + + + converver + float_converter + + + value + 433.9e6 + + + _enabled + True + + + _coordinate + (8, 168) + + + _rotation + 0 + + + grid_pos + + + + id + freq + + + label + + + + max + 500e6 + + + min + 300e6 + + + notebook + + + + num_steps + 100 + + + style + wx.SL_HORIZONTAL + + + + variable + + comment + + + + _enabled + True + + + _coordinate + (8, 92) + + + _rotation + 0 + + + id + samp_rate + + + value + 2e6 + + + + blocks_complex_to_mag + + alias + + + + comment + + + + affinity + + + + _enabled + 1 + + + _coordinate + (424, 336) + + + _rotation + 0 + + + id + blocks_complex_to_mag_1_0 + + + maxoutbuf + 0 + + + minoutbuf + 0 + + + vlen + 1 + + + + blocks_file_source + + alias + + + + comment + + + + affinity + + + + _enabled + 0 + + + file + /tmp/file.wave + + + _coordinate + (192, 188) + + + _rotation + 0 + + + id + blocks_file_source_0 + + + maxoutbuf + 0 + + + minoutbuf + 0 + + + type + complex + + + repeat + True + + + vlen + 1 + + + + blocks_wavfile_sink + + bits_per_sample + 8 + + + alias + + + + comment + + + + affinity + + + + _enabled + 0 + + + file + /tmp/file.wave + + + _coordinate + (696, 396) + + + _rotation + 0 + + + id + blocks_wavfile_sink_0_0_0 + + + nchan + 1 + + + samp_rate + 2000000 + + + + notebook + + alias + + + + comment + + + + _enabled + True + + + _coordinate + (8, 284) + + + _rotation + 0 + + + grid_pos + + + + id + notebook_0 + + + labels + ['Raw FFT', 'Raw Scope', 'Processed Scope'] + + + notebook + + + + style + wx.NB_TOP + + + + osmosdr_source + + alias + + + + ant0 + + + + bb_gain0 + 20 + + + bw0 + 0 + + + dc_offset_mode0 + 0 + + + corr0 + 0 + + + freq0 + freq + + + gain_mode0 + False + + + if_gain0 + 20 + + + iq_balance_mode0 + 0 + + + gain0 + 0 + + + ant10 + + + + bb_gain10 + 20 + + + bw10 + 0 + + + dc_offset_mode10 + 0 + + + corr10 + 0 + + + freq10 + 100e6 + + + gain_mode10 + False + + + if_gain10 + 20 + + + iq_balance_mode10 + 0 + + + gain10 + 10 + + + ant11 + + + + bb_gain11 + 20 + + + bw11 + 0 + + + dc_offset_mode11 + 0 + + + corr11 + 0 + + + freq11 + 100e6 + + + gain_mode11 + False + + + if_gain11 + 20 + + + iq_balance_mode11 + 0 + + + gain11 + 10 + + + ant12 + + + + bb_gain12 + 20 + + + bw12 + 0 + + + dc_offset_mode12 + 0 + + + corr12 + 0 + + + freq12 + 100e6 + + + gain_mode12 + False + + + if_gain12 + 20 + + + iq_balance_mode12 + 0 + + + gain12 + 10 + + + ant13 + + + + bb_gain13 + 20 + + + bw13 + 0 + + + dc_offset_mode13 + 0 + + + corr13 + 0 + + + freq13 + 100e6 + + + gain_mode13 + False + + + if_gain13 + 20 + + + iq_balance_mode13 + 0 + + + gain13 + 10 + + + ant14 + + + + bb_gain14 + 20 + + + bw14 + 0 + + + dc_offset_mode14 + 0 + + + corr14 + 0 + + + freq14 + 100e6 + + + gain_mode14 + False + + + if_gain14 + 20 + + + iq_balance_mode14 + 0 + + + gain14 + 10 + + + ant15 + + + + bb_gain15 + 20 + + + bw15 + 0 + + + dc_offset_mode15 + 0 + + + corr15 + 0 + + + freq15 + 100e6 + + + gain_mode15 + False + + + if_gain15 + 20 + + + iq_balance_mode15 + 0 + + + gain15 + 10 + + + ant16 + + + + bb_gain16 + 20 + + + bw16 + 0 + + + dc_offset_mode16 + 0 + + + corr16 + 0 + + + freq16 + 100e6 + + + gain_mode16 + False + + + if_gain16 + 20 + + + iq_balance_mode16 + 0 + + + gain16 + 10 + + + ant17 + + + + bb_gain17 + 20 + + + bw17 + 0 + + + dc_offset_mode17 + 0 + + + corr17 + 0 + + + freq17 + 100e6 + + + gain_mode17 + False + + + if_gain17 + 20 + + + iq_balance_mode17 + 0 + + + gain17 + 10 + + + ant18 + + + + bb_gain18 + 20 + + + bw18 + 0 + + + dc_offset_mode18 + 0 + + + corr18 + 0 + + + freq18 + 100e6 + + + gain_mode18 + False + + + if_gain18 + 20 + + + iq_balance_mode18 + 0 + + + gain18 + 10 + + + ant19 + + + + bb_gain19 + 20 + + + bw19 + 0 + + + dc_offset_mode19 + 0 + + + corr19 + 0 + + + freq19 + 100e6 + + + gain_mode19 + False + + + if_gain19 + 20 + + + iq_balance_mode19 + 0 + + + gain19 + 10 + + + ant1 + + + + bb_gain1 + 20 + + + bw1 + 0 + + + dc_offset_mode1 + 0 + + + corr1 + 0 + + + freq1 + 100e6 + + + gain_mode1 + False + + + if_gain1 + 20 + + + iq_balance_mode1 + 0 + + + gain1 + 10 + + + ant20 + + + + bb_gain20 + 20 + + + bw20 + 0 + + + dc_offset_mode20 + 0 + + + corr20 + 0 + + + freq20 + 100e6 + + + gain_mode20 + False + + + if_gain20 + 20 + + + iq_balance_mode20 + 0 + + + gain20 + 10 + + + ant21 + + + + bb_gain21 + 20 + + + bw21 + 0 + + + dc_offset_mode21 + 0 + + + corr21 + 0 + + + freq21 + 100e6 + + + gain_mode21 + False + + + if_gain21 + 20 + + + iq_balance_mode21 + 0 + + + gain21 + 10 + + + ant22 + + + + bb_gain22 + 20 + + + bw22 + 0 + + + dc_offset_mode22 + 0 + + + corr22 + 0 + + + freq22 + 100e6 + + + gain_mode22 + False + + + if_gain22 + 20 + + + iq_balance_mode22 + 0 + + + gain22 + 10 + + + ant23 + + + + bb_gain23 + 20 + + + bw23 + 0 + + + dc_offset_mode23 + 0 + + + corr23 + 0 + + + freq23 + 100e6 + + + gain_mode23 + False + + + if_gain23 + 20 + + + iq_balance_mode23 + 0 + + + gain23 + 10 + + + ant24 + + + + bb_gain24 + 20 + + + bw24 + 0 + + + dc_offset_mode24 + 0 + + + corr24 + 0 + + + freq24 + 100e6 + + + gain_mode24 + False + + + if_gain24 + 20 + + + iq_balance_mode24 + 0 + + + gain24 + 10 + + + ant25 + + + + bb_gain25 + 20 + + + bw25 + 0 + + + dc_offset_mode25 + 0 + + + corr25 + 0 + + + freq25 + 100e6 + + + gain_mode25 + False + + + if_gain25 + 20 + + + iq_balance_mode25 + 0 + + + gain25 + 10 + + + ant26 + + + + bb_gain26 + 20 + + + bw26 + 0 + + + dc_offset_mode26 + 0 + + + corr26 + 0 + + + freq26 + 100e6 + + + gain_mode26 + False + + + if_gain26 + 20 + + + iq_balance_mode26 + 0 + + + gain26 + 10 + + + ant27 + + + + bb_gain27 + 20 + + + bw27 + 0 + + + dc_offset_mode27 + 0 + + + corr27 + 0 + + + freq27 + 100e6 + + + gain_mode27 + False + + + if_gain27 + 20 + + + iq_balance_mode27 + 0 + + + gain27 + 10 + + + ant28 + + + + bb_gain28 + 20 + + + bw28 + 0 + + + dc_offset_mode28 + 0 + + + corr28 + 0 + + + freq28 + 100e6 + + + gain_mode28 + False + + + if_gain28 + 20 + + + iq_balance_mode28 + 0 + + + gain28 + 10 + + + ant29 + + + + bb_gain29 + 20 + + + bw29 + 0 + + + dc_offset_mode29 + 0 + + + corr29 + 0 + + + freq29 + 100e6 + + + gain_mode29 + False + + + if_gain29 + 20 + + + iq_balance_mode29 + 0 + + + gain29 + 10 + + + ant2 + + + + bb_gain2 + 20 + + + bw2 + 0 + + + dc_offset_mode2 + 0 + + + corr2 + 0 + + + freq2 + 100e6 + + + gain_mode2 + False + + + if_gain2 + 20 + + + iq_balance_mode2 + 0 + + + gain2 + 10 + + + ant30 + + + + bb_gain30 + 20 + + + bw30 + 0 + + + dc_offset_mode30 + 0 + + + corr30 + 0 + + + freq30 + 100e6 + + + gain_mode30 + False + + + if_gain30 + 20 + + + iq_balance_mode30 + 0 + + + gain30 + 10 + + + ant31 + + + + bb_gain31 + 20 + + + bw31 + 0 + + + dc_offset_mode31 + 0 + + + corr31 + 0 + + + freq31 + 100e6 + + + gain_mode31 + False + + + if_gain31 + 20 + + + iq_balance_mode31 + 0 + + + gain31 + 10 + + + ant3 + + + + bb_gain3 + 20 + + + bw3 + 0 + + + dc_offset_mode3 + 0 + + + corr3 + 0 + + + freq3 + 100e6 + + + gain_mode3 + False + + + if_gain3 + 20 + + + iq_balance_mode3 + 0 + + + gain3 + 10 + + + ant4 + + + + bb_gain4 + 20 + + + bw4 + 0 + + + dc_offset_mode4 + 0 + + + corr4 + 0 + + + freq4 + 100e6 + + + gain_mode4 + False + + + if_gain4 + 20 + + + iq_balance_mode4 + 0 + + + gain4 + 10 + + + ant5 + + + + bb_gain5 + 20 + + + bw5 + 0 + + + dc_offset_mode5 + 0 + + + corr5 + 0 + + + freq5 + 100e6 + + + gain_mode5 + False + + + if_gain5 + 20 + + + iq_balance_mode5 + 0 + + + gain5 + 10 + + + ant6 + + + + bb_gain6 + 20 + + + bw6 + 0 + + + dc_offset_mode6 + 0 + + + corr6 + 0 + + + freq6 + 100e6 + + + gain_mode6 + False + + + if_gain6 + 20 + + + iq_balance_mode6 + 0 + + + gain6 + 10 + + + ant7 + + + + bb_gain7 + 20 + + + bw7 + 0 + + + dc_offset_mode7 + 0 + + + corr7 + 0 + + + freq7 + 100e6 + + + gain_mode7 + False + + + if_gain7 + 20 + + + iq_balance_mode7 + 0 + + + gain7 + 10 + + + ant8 + + + + bb_gain8 + 20 + + + bw8 + 0 + + + dc_offset_mode8 + 0 + + + corr8 + 0 + + + freq8 + 100e6 + + + gain_mode8 + False + + + if_gain8 + 20 + + + iq_balance_mode8 + 0 + + + gain8 + 10 + + + ant9 + + + + bb_gain9 + 20 + + + bw9 + 0 + + + dc_offset_mode9 + 0 + + + corr9 + 0 + + + freq9 + 100e6 + + + gain_mode9 + False + + + if_gain9 + 20 + + + iq_balance_mode9 + 0 + + + gain9 + 10 + + + comment + + + + affinity + + + + args + + + + _enabled + 1 + + + _coordinate + (192, 12) + + + _rotation + 0 + + + id + osmosdr_source_0 + + + maxoutbuf + 0 + + + clock_source0 + + + + time_source0 + + + + clock_source1 + + + + time_source1 + + + + clock_source2 + + + + time_source2 + + + + clock_source3 + + + + time_source3 + + + + clock_source4 + + + + time_source4 + + + + clock_source5 + + + + time_source5 + + + + clock_source6 + + + + time_source6 + + + + clock_source7 + + + + time_source7 + + + + minoutbuf + 0 + + + nchan + 1 + + + num_mboards + 1 + + + type + fc32 + + + sample_rate + samp_rate + + + sync + + + + + wxgui_fftsink2 + + avg_alpha + 0 + + + average + False + + + baseband_freq + 0 + + + alias + + + + comment + + + + affinity + + + + _enabled + 1 + + + fft_size + 1024 + + + freqvar + None + + + _coordinate + (520, 124) + + + _rotation + 0 + + + grid_pos + + + + id + wxgui_fftsink2_0 + + + notebook + notebook_0,0 + + + peak_hold + False + + + ref_level + 0 + + + ref_scale + 2.0 + + + fft_rate + 15 + + + samp_rate + samp_rate + + + title + FFT Plot + + + type + complex + + + win_size + + + + win + None + + + y_divs + 10 + + + y_per_div + 10 + + + + wxgui_scopesink2 + + ac_couple + False + + + alias + + + + comment + + + + affinity + + + + _enabled + 1 + + + _coordinate + (520, 8) + + + _rotation + 0 + + + grid_pos + + + + id + wxgui_scopesink2_1 + + + notebook + notebook_0,1 + + + num_inputs + 1 + + + samp_rate + samp_rate + + + t_scale + 0 + + + title + Scope Plot + + + trig_mode + wxgui.TRIG_MODE_AUTO + + + type + complex + + + v_offset + 0 + + + v_scale + 0 + + + win_size + + + + xy_mode + False + + + y_axis_label + Counts + + + + wxgui_scopesink2 + + ac_couple + False + + + alias + + + + comment + + + + affinity + + + + _enabled + 1 + + + _coordinate + (696, 288) + + + _rotation + 0 + + + grid_pos + + + + id + wxgui_scopesink2_1_0 + + + notebook + notebook_0,2 + + + num_inputs + 1 + + + samp_rate + samp_rate + + + t_scale + 0 + + + title + Scope Plot + + + trig_mode + wxgui.TRIG_MODE_AUTO + + + type + float + + + v_offset + 0 + + + v_scale + 0 + + + win_size + + + + xy_mode + False + + + y_axis_label + Counts + + + + blocks_complex_to_mag_1_0 + blocks_wavfile_sink_0_0_0 + 0 + 0 + + + blocks_complex_to_mag_1_0 + wxgui_scopesink2_1_0 + 0 + 0 + + + blocks_file_source_0 + blocks_complex_to_mag_1_0 + 0 + 0 + + + blocks_file_source_0 + wxgui_scopesink2_1 + 0 + 0 + + + osmosdr_source_0 + blocks_complex_to_mag_1_0 + 0 + 0 + + + osmosdr_source_0 + wxgui_fftsink2_0 + 0 + 0 + + + osmosdr_source_0 + wxgui_scopesink2_1 + 0 + 0 + + diff --git a/ooktools/utilities.py b/ooktools/utilities.py new file mode 100644 index 0000000..b8003fc --- /dev/null +++ b/ooktools/utilities.py @@ -0,0 +1,222 @@ +# The MIT License (MIT) +# +# Copyright (c) 2016 Leon Jacobs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys +from collections import Counter + +import click +import numpy +import rflib + + +def valid_packet(packet, constraint): + """ + Check if a hex encoded packet received from + rflib.Rfcat.RFrecv has the constraint value in it. + + Idea From: + https://github.com/mossmann/stealthlock/blob/master/sl.py#L17 + + :param packet: + :param constraint: + :return: + """ + + if constraint not in packet: + return False + + return True + + +def pwm_decode(p): + """ + PWM-ify a received packet. + Source: + From https://github.com/mossmann/stealthlock/blob/master/sl.py#L37 + + :param p: + :return: + """ + + biginteger = 0 + + for byte in p: + biginteger <<= 8 + biginteger |= ord(byte) + + biginteger >>= 12 + out = 0 + + for i in range(28, (len(p) * 8 - 12) / 3, 1): + out <<= 1 + out |= ((biginteger & 1) ^ 1) + biginteger >>= 3 + + return out + + +def oneline_print(string): + """ + Print a string with a carriage return + + :param string: + :return: + """ + + sys.stdout.write('{}\r'.format(string)) + sys.stdout.flush() + + +def chunks(data, l): + """ + Yield l-sized chunks from data. + + :param data: + :param l: + :return: + """ + + for i in range(0, len(data), l): + yield data[i:i + l] + + +def cleanup_wave_data(data): + """ + + :param data: + :return: + """ + + # Determine the min, max and average values. + minval, maxval, valcount = numpy.amin(data), numpy.amax(data), len(data) + meanval = numpy.mean([minval, maxval]) + click.secho('Total Samples: {}, Min: {}, Max: {}, Mean: {}'.format( + valcount, minval, maxval, meanval), fg='green') + + # Give some information about what is going to happen now. + click.secho('Cleaning up {} data points...'.format(valcount), dim=True) + + # Some constant values that will determine hard values + # for legit data + sample_border = 40000 # Calculate averages every x samples + significant_max = minval + 3000 # Must have at least one data point with more than this + clean_data = [] + + # Ensure the source data is OK to work with by checking that we have + # data points with at least a certain max and not less than a certain min + if maxval < significant_max: + click.secho('Data source has values that are not more than {}. These ' + 'values may skew average calculations. Please try and re-record ' + 'your data source.'.format(significant_max), fg='red') + return clean_data + + for samples in chunks(data, sample_border): + + # Determine new min, max and means for this sample range + sample_minval, sample_maxval = numpy.amin(samples), numpy.amax(samples) + sample_mean = numpy.mean([sample_minval, sample_maxval]) + + # Debug + # print(sample_minval, sample_maxval, sample_mean) + + # Ensure we have data in this sample range that is workable + if significant_max > sample_maxval: + # click.secho('Dont have data in minimum range.', fg='yellow') + continue + + for value in samples: + + # print (value, sample_mean) + if (value > 500) and (value > sample_mean): + clean_data.append(1) + continue + + clean_data.append(0) + # Apply the clean_values function to the sample range + # average_func = numpy.vectorize(clean_values) + # clean_data.append(average_func(samples, numpy.mean([sample_minval, sample_maxval]))) + + # return list(itertools.chain(*clean_data)) + return clean_data + + +def find_common_string(data): + """ + Derived from: + http://stackoverflow.com/questions/25071766/find-most-common-sub-string-pattern-in-a-file?answertab=votes#tab-top + + :param data: + :return: + """ + + d = {} + + for n in range(1, len(data)): + + substr_counter = Counter(data[i: i + n] for i in range(len(data) - n)) + phrase, count = substr_counter.most_common(1)[0] + if count == 1: # early out for trivial cases + break + + # print 'Size: %3d: Occurrences: %3d Phrase: %r' % (n, count, phrase) + d[n] = {'occurrences': count, 'phrase': phrase} + + return d + + +def configure_dongle(d, frequency, pktflen, baud, modulation=rflib.MOD_ASK_OOK, + syncmode=0, lowball=False): + """ + Configure an instance of rflib.RFCat + + :param d: + :param frequency: + :param pktflen: + :param baud: + :param modulation: + :param syncmode: + :param lowball: + :return: + """ + + # Set the radio frequency + if frequency is not None: + d.setFreq(frequency) + click.secho('[radio] Frequency: {}'.format(frequency), dim=True) + + # Set the rest of the values + d.setMdmModulation(modulation) + d.makePktFLEN(pktflen) + d.setMdmDRate(baud) + d.setMdmSyncMode(syncmode) # Disable preamble + + click.secho('[radio] MdmModulation: {}'.format(modulation), dim=True) + click.secho('[radio] PktFLEN: {}'.format(pktflen), dim=True) + click.secho('[radio] MdmDRate: {}'.format(baud), dim=True) + click.secho('[radio] MdmSyncMode: {}'.format(syncmode), dim=True) + + # set the radio to lowest filtering mode + if lowball: + d.lowball() + click.secho('[radio] Lowball: {}'.format(lowball), dim=True, bold=True) + + return diff --git a/ooktools/validators.py b/ooktools/validators.py new file mode 100644 index 0000000..e820fd6 --- /dev/null +++ b/ooktools/validators.py @@ -0,0 +1,163 @@ +# The MIT License (MIT) +# +# Copyright (c) 2016 Leon Jacobs +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import json +import wave + +import click + + +def validate_wave(ctx, param, value): + """ + Validate the wave file by trying to open it + and checking that its got a single channel. + + :param ctx: + :param param: + :param value:str + :return: + """ + + try: + wave_read = wave.open(value) + + if wave_read.getnchannels() is not 1: + raise click.BadParameter('Only mono wave files are supported') + + return wave_read + + except wave.Error as e: + raise click.BadParameter('Not a valid wave file. {}'.format(e.__str__())) + + +def validate_binary_string(ctx, param, value): + """ + Ensure that a binary string only has 1's and 0's + + :param ctx: + :param param: + :param value:str + :return:str + """ + + valid_characters = '10' + + # If we string the value of valid characters, are there + # any other characters left over? + left_overs = value.strip(valid_characters) + + # Except if there are characters left + if left_overs: + raise click.BadParameter('Only the characters "{}" is considered valid bitsring input.' + ' The following were invalid: {}'.format(valid_characters, left_overs)) + + return value + + +def validate_hex_string(ctx, param, value): + """ + Ensure that a value is valid hex + + :param ctx: + :param param: + :param value:str + :return:str + """ + + try: + int(value, 16) + except ValueError: + raise click.BadParameter('\'{}\' is not a valid hex string.'.format(value)) + + return value + + +def validate_recording_json(ctx, param, value): + """ + Ensure that a source Json recording file is valid + + :param ctx: + :param param: + :param value: + :return:dict + """ + + # Load the JSON from the source file + try: + with open(value) as f: + data = json.load(f) + except ValueError: + raise click.BadParameter('The source file appears to be corrupt. ' + 'You can try to fix it manually by ensuring that ' + 'it contains valid JSON formatted data.') + + # Ensure that we got a dict back + if not isinstance(data, dict): + raise click.BadParameter('The source file \'{}\' appears to be improperly ' + 'formatted.'.format(value)) + + # Make sure all the keys are present + required_keys = ['date', 'frequency', 'baud', 'framecount', 'frames'] + if not all(k in data for k in required_keys): + raise click.BadParameter('The source file \'{}\' is missing values.'.format(value)) + + return data + + +def validate_series(ctx, param, value): + """ + Ensure that a series input is valid + + :param ctx: + :param param: + :param value: + :return:tuple + """ + + # Dont validate if the default value of None is received + if not value: + return None + + seperator = ':' + + if seperator not in value: + raise click.BadParameter('A series must be seperated by a \'{}\' ' + 'character.'.format(seperator)) + + # Check that a split by seperator only returns 2 + # values in the tuple + if len(value.split(seperator)) != 2: + raise click.BadParameter('Only one seperator characters is valid.') + + # Split the values and cast them to integers. + start, end = value.split(':') + start = int(start) + end = int(end) + + # Check that the values 'make sense' + if end < start: + raise click.BadParameter('The start value must be less than the end value.') + + if start == end: + raise click.BadParameter('The start value and the end value most not be the same.') + + return start, end diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c04134a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +peakutils +bitstring +click +matplotlib +numpy diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d7170df --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +from setuptools import setup + +setup( + name='ooktools', + description='On-off keying tools for your SDR', + author='Leon Jacobs', + author_email='leonja511@gmail.com', + url='https://github.com/leonjza/ooktools', + download_url='https://github.com/leonjza/ooktools/tarball/1.0', + keywords=['sdr', 'on-off', 'keying', 'rfcat', 'radio'], + version='1.0', + packages=[ + 'ooktools', + 'ooktools.commands', + ], + include_package_data=True, + install_requires=[ + 'bitstring', + 'click', + 'matplotlib', + 'numpy', + 'peakutils', + ], + entry_points={ + 'console_scripts': [ + 'ooktools=ooktools.console:cli', + ], + }, +)