├── m └── README ├── samplerate ├── .gitattributes ├── _samplerate_data │ ├── libsamplerate.dylib │ ├── libsamplerate-32bit.dll │ ├── libsamplerate-64bit.dll │ ├── build_samplerate_mxe.sh │ └── README.md ├── .gitignore ├── exceptions.py ├── __init__.py ├── LICENSE.rst ├── samplerate_build.py ├── _src.py ├── tests │ └── test_samplerate.py ├── lowlevel.py ├── converters.py └── _version.py ├── chunk ├── COPIED_FROM.txt ├── README.rst ├── LICENSE └── __init__.py ├── .octaverc ├── .gitignore ├── tools └── README ├── fast_agc.yaml ├── default_agc.yaml ├── scan_continuous.yaml ├── scan_squelch.yaml ├── kiwi ├── __init__.py ├── wavreader.py ├── worker.py ├── rigctld.py └── wsclient.py ├── kiwi_iq_wav_to_c2.py ├── CHANGE_LOG ├── mod_pywebsocket ├── stream.py ├── _stream_base.py ├── http_header_util.py ├── __init__.py ├── _stream_hixie75.py ├── common.py └── util.py ├── test └── kiwi_server.py ├── proc_kiwi_iq_wav.m ├── waterfall_data_analysis.ipynb ├── src └── read_kiwi_iq_wav.cc ├── microkiwi_waterfall.py ├── SCANNING ├── README.md └── kiwiwfrecorder.py /m/README: -------------------------------------------------------------------------------- 1 | A directory for Octave *.m files 2 | -------------------------------------------------------------------------------- /samplerate/.gitattributes: -------------------------------------------------------------------------------- 1 | samplerate/_version.py export-subst 2 | -------------------------------------------------------------------------------- /chunk/COPIED_FROM.txt: -------------------------------------------------------------------------------- 1 | Copied from: github.com/youknowone/python-deadlib/tree/main/chunk 2 | -------------------------------------------------------------------------------- /.octaverc: -------------------------------------------------------------------------------- 1 | addpath (['m' pathsep() 'oct']) 2 | pkg load signal 3 | ##graphics_toolkit('gnuplot') 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.png 3 | *.wav 4 | log* 5 | *~ 6 | gnss_pos/ 7 | *.o 8 | *.oct 9 | *.npy 10 | -------------------------------------------------------------------------------- /tools/README: -------------------------------------------------------------------------------- 1 | The latest version of wsprdaemon can be found at: https://github.com/rrobinett/wsprdaemon 2 | -------------------------------------------------------------------------------- /samplerate/_samplerate_data/libsamplerate.dylib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jks-prv/kiwiclient/HEAD/samplerate/_samplerate_data/libsamplerate.dylib -------------------------------------------------------------------------------- /fast_agc.yaml: -------------------------------------------------------------------------------- 1 | AGC : 2 | 'on': True ## beware 'on' vs on 3 | hang: False 4 | thresh: -130 5 | slope: 1 6 | decay: 1000 7 | gain: 50 8 | -------------------------------------------------------------------------------- /samplerate/_samplerate_data/libsamplerate-32bit.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jks-prv/kiwiclient/HEAD/samplerate/_samplerate_data/libsamplerate-32bit.dll -------------------------------------------------------------------------------- /samplerate/_samplerate_data/libsamplerate-64bit.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jks-prv/kiwiclient/HEAD/samplerate/_samplerate_data/libsamplerate-64bit.dll -------------------------------------------------------------------------------- /default_agc.yaml: -------------------------------------------------------------------------------- 1 | AGC : 2 | 'on': False ## beware 'on' vs on 3 | hang: False 4 | thresh: -100 5 | slope: 6 6 | decay: 1000 7 | gain: 50 8 | -------------------------------------------------------------------------------- /scan_continuous.yaml: -------------------------------------------------------------------------------- 1 | Scan: 2 | wait: 1 # seconds 3 | dwell: 5 # seconds 4 | frequencies: [2500, 3330, 5000, 7850, 10000, 14670, 15000, 20000] # kHz, time stations 5 | -------------------------------------------------------------------------------- /samplerate/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | 4 | .coverage 5 | .cache/ 6 | .eggs/ 7 | *.egg-info 8 | dist/ 9 | build/ 10 | 11 | docs/_build 12 | 13 | tags 14 | .vscode/ 15 | 16 | samplerate/_src.py -------------------------------------------------------------------------------- /scan_squelch.yaml: -------------------------------------------------------------------------------- 1 | Scan: 2 | threshold: 20 # dB 3 | wait: 1 # seconds 4 | dwell: 6 # seconds 5 | # frequencies: [6661, 6559, 6311, 8948, 8912] # kHz, HFDL 6 | frequencies: [4024, 4030, 4039] # kHz, STANAG-4539 bursts 7 | pbc: true 8 | -------------------------------------------------------------------------------- /kiwi/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | ## -*- python -*- 3 | 4 | #from .client import KiwiSDRStream 5 | #from .client import KiwiError,KiwiTooBusyError,KiwiDownErrorKiwiCampError,KiwiBadPasswordError,KiwiTimeLimitError,KiwiServerTerminatedConnection,KiwiUnknownModulation 6 | from .client import * 7 | from .worker import KiwiWorker 8 | from .wavreader import * 9 | -------------------------------------------------------------------------------- /chunk/README.rst: -------------------------------------------------------------------------------- 1 | Dead battery redistribution 2 | =========================== 3 | 4 | Python is moving forward! Python finally started to remove dead batteries. 5 | For more information, see `PEP 594 `_. 6 | 7 | If your project depends on a module that has been removed from the standard, 8 | here is the redistribution of the dead batteries. 9 | -------------------------------------------------------------------------------- /samplerate/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions 2 | 3 | """ 4 | 5 | 6 | class ResamplingError(RuntimeError): 7 | """Runtime exception of libsamplerate""" 8 | 9 | def __init__(self, error): 10 | from samplerate.lowlevel import src_strerror 11 | message = 'libsamplerate error #{}: {}'.format(error, 12 | src_strerror(error)) 13 | super(ResamplingError, self).__init__(message) 14 | self.error = error 15 | -------------------------------------------------------------------------------- /samplerate/__init__.py: -------------------------------------------------------------------------------- 1 | """Python bindings for libsamplerate based on CFFI and NumPy. 2 | 3 | """ 4 | # Versioning 5 | from samplerate._version import get_versions 6 | __version__ = get_versions()['version'] 7 | del get_versions 8 | 9 | from samplerate.lowlevel import __libsamplerate_version__ 10 | 11 | # Convenience imports 12 | from samplerate.exceptions import ResamplingError 13 | from samplerate.converters import resample 14 | from samplerate.converters import Resampler 15 | from samplerate.converters import CallbackResampler 16 | -------------------------------------------------------------------------------- /samplerate/_samplerate_data/build_samplerate_mxe.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | JOBS=8 3 | export PATH="$(pwd)/mxe/usr/bin:$PATH" 4 | for TARGET in x86_64-w64-mingw32.static i686-w64-mingw32.static 5 | do 6 | make -C mxe libsamplerate -j$JOBS JOBS=$JOBS MXE_TARGETS=$TARGET 7 | $TARGET-gcc -O2 -shared -o libsamplerate-$TARGET.dll -Wl,--whole-archive -lsamplerate -Wl,--no-whole-archive 8 | $TARGET-strip libsamplerate-$TARGET.dll 9 | chmod -x libsamplerate-$TARGET.dll 10 | done 11 | mv libsamplerate-x86_64-w64-mingw32.static.dll libsamplerate-64bit.dll 12 | mv libsamplerate-i686-w64-mingw32.static.dll libsamplerate-32bit.dll 13 | -------------------------------------------------------------------------------- /samplerate/_samplerate_data/README.md: -------------------------------------------------------------------------------- 1 | # libsamplerate binaries 2 | 3 | These are statically compiled dynamic libraries of 4 | [libsamplerate](http://www.mega-nerd.com/libsamplerate/). 5 | 6 | ## DLLs for Windows (32-bit and 64-bit) 7 | 8 | The instructions follow the README of 9 | [libsndfile-binaries](https://github.com/bastibe/libsndfile-binaries). The DLLs 10 | were created on macOS using [MXE](http://mxe.cc) with the 11 | `build_samplerate_mxe.sh` script: 12 | 13 | git clone https://github.com/mxe/mxe.git 14 | ./build-samplerate.sh 15 | 16 | ## Dylib for macOS (64-bit) 17 | 18 | Build using [Homebrew](http://brew.sh/): 19 | 20 | brew install libsamplerate 21 | cp /usr/local/lib/libsamplerate.dylib . 22 | # There should be no further dependencies: 23 | otool -L libsamplerate.dylib 24 | -------------------------------------------------------------------------------- /samplerate/LICENSE.rst: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © 2017 Tino Wagner 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the "Software"), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /kiwi_iq_wav_to_c2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import soundfile,struct,sys 4 | import numpy as np 5 | 6 | ## convert kiwirecorder IQ wav files to .c2 file for wsprd 7 | 8 | def decode_kiwi_wav_filename(fn): 9 | """ input: kiwirecorder wav file name 10 | output: .c2 filename (length 14) and frequency in kHz""" 11 | fields = fn.split('/')[-1].split('_') 12 | date_and_time = fields[0] 13 | c2_filename = date_and_time[2:8] + '_' + date_and_time[9:13] + '.c2' 14 | freq_kHz = int(fields[1])/1e3 15 | return c2_filename,freq_kHz 16 | 17 | def convert_kiwi_wav_to_c2(fn): 18 | """ input: kiwirecorder IQ wav file name with sample rate 375 Hz 19 | output: .c2 file for wsprd""" 20 | c2_filename,freq_kHz = decode_kiwi_wav_filename(fn) 21 | iq,fs = soundfile.read(fn) 22 | assert(fs == 375) ## sample rate has to be 375 Hz 23 | assert(iq.shape[1] == 2) ## works only for IQ mode recordings 24 | iq[:,1] *= -1 25 | iq.resize((45000,2), refcheck=False) 26 | f = open(c2_filename, 'wb') 27 | f.write(struct.pack('<14sid', c2_filename.encode(), 2, freq_kHz)) 28 | f.write(iq.astype(np.float32).flatten().tobytes()) 29 | f.close() 30 | print('in=' + fn + ' out='+c2_filename) 31 | 32 | if __name__ == '__main__': 33 | if len(sys.argv) == 1: 34 | print('convert kiwirecorder IQ wav files to .c2 file for wsprd') 35 | print('USAGE: ' + sys.argv[0]+ ' .wav filenames') 36 | for fn in sys.argv[1:]: 37 | convert_kiwi_wav_to_c2(fn) 38 | 39 | -------------------------------------------------------------------------------- /samplerate/samplerate_build.py: -------------------------------------------------------------------------------- 1 | from cffi import FFI 2 | 3 | ffibuilder = FFI() 4 | ffibuilder.set_source('samplerate._src', None) 5 | 6 | ffibuilder.cdef(""" 7 | typedef struct SRC_STATE_tag SRC_STATE ; 8 | 9 | typedef struct 10 | { const float *data_in ; 11 | float *data_out ; 12 | 13 | long input_frames, output_frames ; 14 | long input_frames_used, output_frames_gen ; 15 | 16 | int end_of_input ; 17 | 18 | double src_ratio ; 19 | } SRC_DATA ; 20 | 21 | // Simple API 22 | int src_simple (SRC_DATA *data, int converter_type, int channels) ; 23 | 24 | // Full API 25 | SRC_STATE* src_new (int converter_type, int channels, int *error) ; 26 | SRC_STATE* src_delete (SRC_STATE *state) ; 27 | int src_process (SRC_STATE *state, SRC_DATA *data) ; 28 | int src_reset (SRC_STATE *state) ; 29 | int src_set_ratio (SRC_STATE *state, double new_ratio) ; 30 | int src_is_valid_ratio (double ratio) ; 31 | 32 | // Callback API 33 | typedef long (*src_callback_t) (void *cb_data, float **data) ; 34 | SRC_STATE* src_callback_new (src_callback_t func, 35 | int converter_type, int channels, 36 | int *error, void* cb_data) ; 37 | long src_callback_read (SRC_STATE *state, double src_ratio, 38 | long frames, float *data) ; 39 | 40 | // Extern "Python"-style callback dropped in favor of compiler-less 41 | // ABI mode ... 42 | //extern "Python" long src_input_callback(void*, float**); 43 | 44 | // Misc 45 | int src_error (SRC_STATE *state) ; 46 | const char* src_strerror (int error) ; 47 | const char *src_get_name (int converter_type) ; 48 | const char *src_get_description (int converter_type) ; 49 | const char *src_get_version (void) ; 50 | void src_short_to_float_array (const short *in, float *out, int len) ; 51 | void src_float_to_short_array (const float *in, short *out, int len) ; 52 | void src_int_to_float_array (const int *in, float *out, int len) ; 53 | void src_float_to_int_array (const float *in, int *out, int len) ; 54 | """) 55 | 56 | if __name__ == '__main__': 57 | ffibuilder.compile(verbose=True) 58 | -------------------------------------------------------------------------------- /samplerate/_src.py: -------------------------------------------------------------------------------- 1 | # auto-generated file 2 | import _cffi_backend 3 | 4 | ffi = _cffi_backend.FFI('samplerate._src', 5 | _version = 0x2601, 6 | _types = b'\x00\x00\x01\x0D\x00\x00\x46\x03\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x07\x01\x00\x00\x07\x01\x00\x00\x04\x03\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x2D\x03\x00\x00\x07\x01\x00\x00\x07\x01\x00\x00\x06\x11\x00\x00\x4B\x03\x00\x00\x00\x0F\x00\x00\x47\x0D\x00\x00\x07\x01\x00\x00\x00\x0F\x00\x00\x47\x0D\x00\x00\x00\x0F\x00\x00\x04\x0D\x00\x00\x45\x03\x00\x00\x07\x01\x00\x00\x07\x01\x00\x00\x00\x0F\x00\x00\x04\x0D\x00\x00\x01\x11\x00\x00\x00\x0F\x00\x00\x04\x0D\x00\x00\x01\x11\x00\x00\x15\x11\x00\x00\x00\x0F\x00\x00\x04\x0D\x00\x00\x01\x11\x00\x00\x0E\x01\x00\x00\x00\x0F\x00\x00\x04\x0D\x00\x00\x0E\x01\x00\x00\x00\x0F\x00\x00\x2A\x0D\x00\x00\x01\x11\x00\x00\x0E\x01\x00\x00\x09\x01\x00\x00\x49\x03\x00\x00\x00\x0F\x00\x00\x2A\x0D\x00\x00\x0D\x11\x00\x00\x2B\x03\x00\x00\x00\x0F\x00\x00\x4B\x0D\x00\x00\x49\x03\x00\x00\x06\x11\x00\x00\x07\x01\x00\x00\x00\x0F\x00\x00\x4B\x0D\x00\x00\x32\x11\x00\x00\x4A\x03\x00\x00\x07\x01\x00\x00\x00\x0F\x00\x00\x4B\x0D\x00\x00\x04\x03\x00\x00\x2B\x11\x00\x00\x07\x01\x00\x00\x00\x0F\x00\x00\x4B\x0D\x00\x00\x4A\x03\x00\x00\x2B\x11\x00\x00\x07\x01\x00\x00\x00\x0F\x00\x00\x00\x09\x00\x00\x01\x09\x00\x00\x48\x03\x00\x00\x02\x01\x00\x00\x0D\x01\x00\x00\x05\x01\x00\x00\x00\x01', 7 | _globals = (b'\x00\x00\x08\x23src_callback_new',0,b'\x00\x00\x27\x23src_callback_read',0,b'\x00\x00\x00\x23src_delete',0,b'\x00\x00\x19\x23src_error',0,b'\x00\x00\x31\x23src_float_to_int_array',0,b'\x00\x00\x36\x23src_float_to_short_array',0,b'\x00\x00\x0F\x23src_get_description',0,b'\x00\x00\x0F\x23src_get_name',0,b'\x00\x00\x12\x23src_get_version',0,b'\x00\x00\x3B\x23src_int_to_float_array',0,b'\x00\x00\x24\x23src_is_valid_ratio',0,b'\x00\x00\x03\x23src_new',0,b'\x00\x00\x1C\x23src_process',0,b'\x00\x00\x19\x23src_reset',0,b'\x00\x00\x20\x23src_set_ratio',0,b'\x00\x00\x40\x23src_short_to_float_array',0,b'\x00\x00\x14\x23src_simple',0,b'\x00\x00\x0F\x23src_strerror',0), 8 | _struct_unions = ((b'\x00\x00\x00\x45\x00\x00\x00\x02$SRC_DATA',b'\x00\x00\x32\x11data_in',b'\x00\x00\x2B\x11data_out',b'\x00\x00\x2A\x11input_frames',b'\x00\x00\x2A\x11output_frames',b'\x00\x00\x2A\x11input_frames_used',b'\x00\x00\x2A\x11output_frames_gen',b'\x00\x00\x04\x11end_of_input',b'\x00\x00\x22\x11src_ratio'),(b'\x00\x00\x00\x46\x00\x00\x00\x10SRC_STATE_tag',)), 9 | _typenames = (b'\x00\x00\x00\x45SRC_DATA',b'\x00\x00\x00\x46SRC_STATE',b'\x00\x00\x00\x09src_callback_t'), 10 | ) 11 | -------------------------------------------------------------------------------- /CHANGE_LOG: -------------------------------------------------------------------------------- 1 | CHANGE_LOG 2 | 3 | For the Github commit log see here: github.com/jks-prv/kiwiclient/commits/master 4 | 5 | v1.7 August 20, 2025 6 | Incorporated standard-chunk source code to eliminate all the problems with Python 13 and 7 | Debian 13 not being able to find and include the correct package. 8 | 9 | Most .py files: fix/add "#!/usr/bin/env python3" (thanks Rob) 10 | 11 | v1.6 July 11, 2025 12 | By request: The resampling option now works with netcat and camping modes. 13 | 14 | v1.5 June 15, 2025 15 | Added continuous frequency scanning capability. 16 | Kiwirecorder previously had a squelch-based scanning function using parameters specified 17 | with a YAML file and the --scan-yaml option. A set of frequencies would be scanned and the 18 | scanning stopped when the squelch opened. Continuous scanning will now occur if the 19 | "threshold" parameter is not given in the YAML file. See the file SCANNING for complete info. 20 | 21 | Note that the former example file scan.yaml has been renamed to scan_squelch.yaml to 22 | distinguish it from the new example file scan_continuous.yaml used in frequency scanning. 23 | 24 | Added Kiwi channel camping feature. 25 | Instead of creating a new connection to the Kiwi when kiwirecorder is run it can now "camp" 26 | onto an existing connection and record whatever audio the camped user is listening to. 27 | Very similar to the camp capability that is presented via the camp/queue panel that appears 28 | when you try and connect to a Kiwi when all its channels are full. See the option 29 | --camp=CAMP_CHAN where CAMP_CHAN is the channel number to camp on (e.g. 0..4 for rx0..rx4). 30 | 31 | Note that a 2-channel (stereo) file or netcat stream is always produced because you can't 32 | predict what the camped user will do. They may switch from a mono mode (e.g. USB) to a 33 | stereo mode (e.g. IQ) at any time. For mono modes the single channel audio is duplicated in 34 | both channels. Things also work when the camped user switches compression mode on and off. 35 | 36 | Camp mode also works with the --netcat option. For an example see the Makefile "camp" target. 37 | Note that camping does not currently support squelch or GPS timestamps. 38 | 39 | Netcat function: 40 | Made all the audio modes work correctly: mono/stereo, compression/no-compression. 41 | Note that netcat does not currently support squelch or GPS timestamps. 42 | 43 | Squelch function: 44 | Made tail timing correct for the various audio modes: mono/stereo, compression/no-compression. 45 | -------------------------------------------------------------------------------- /chunk/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2001-2023 Python Software Foundation; All Rights Reserved 2 | 3 | This code originally taken from the Python 3.11.3 distribution 4 | and it is therefore now released under the following Python-style 5 | license: 6 | 7 | 1. This LICENSE AGREEMENT is between the Python Software Foundation ("PSF"), and 8 | the Individual or Organization ("Licensee") accessing and 9 | otherwise using nntplib software in source or binary form and 10 | its associated documentation. 11 | 12 | 2. Subject to the terms and conditions of this License Agreement, PSF hereby 13 | grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, 14 | analyze, test, perform and/or display publicly, prepare derivative works, 15 | distribute, and otherwise use nntplib alone or in any derivative 16 | version, provided, however, that PSF's License Agreement and PSF's notice of 17 | copyright, i.e., "Copyright © 2001-2023 Python Software Foundation; All Rights 18 | Reserved" are retained in nntplib alone or in any derivative version 19 | prepared by Licensee. 20 | 21 | 3. In the event Licensee prepares a derivative work that is based on or 22 | incorporates nntplib or any part thereof, and wants to make the 23 | derivative work available to others as provided herein, then Licensee hereby 24 | agrees to include in any such work a brief summary of the 25 | changes made to nntplib. 26 | 27 | 4. PSF is making nntplib available to Licensee on an "AS IS" basis. 28 | PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF 29 | EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY REPRESENTATION OR 30 | WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE 31 | USE OF NNTPLIB WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 32 | 33 | 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF NNTPLIB 34 | FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF 35 | MODIFYING, DISTRIBUTING, OR OTHERWISE USING NNTPLIB, OR ANY DERIVATIVE 36 | THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 37 | 38 | 6. This License Agreement will automatically terminate upon a material breach of 39 | its terms and conditions. 40 | 41 | 7. Nothing in this License Agreement shall be deemed to create any relationship 42 | of agency, partnership, or joint venture between PSF and Licensee. This License 43 | Agreement does not grant permission to use PSF trademarks or trade name in a 44 | trademark sense to endorse or promote products or services of Licensee, or any 45 | third party. 46 | 47 | 8. By copying, installing or otherwise using nntplib, Licensee agrees 48 | to be bound by the terms and conditions of this License Agreement. 49 | -------------------------------------------------------------------------------- /samplerate/tests/test_samplerate.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | # so samplerate module is found when doing "pytest samplerate/tests/test_samplerate.py" 5 | # in kiwiclient top-level directory 6 | import sys, os 7 | sys.path.insert(0, os.path.abspath('.')) 8 | import samplerate 9 | 10 | 11 | @pytest.fixture(scope="module", params=[1, 2]) 12 | def data(request): 13 | num_channels = request.param 14 | periods = np.linspace(0, 10, 1000) 15 | input_data = [ 16 | np.sin(2 * np.pi * periods + i * np.pi / 2) 17 | for i in range(num_channels) 18 | ] 19 | return ((num_channels, input_data[0]) 20 | if num_channels == 1 else (num_channels, np.transpose(input_data))) 21 | 22 | 23 | @pytest.fixture(params=list(samplerate.converters.ConverterType)) 24 | def converter_type(request): 25 | return request.param 26 | 27 | 28 | def test_simple(data, converter_type, ratio=2.0): 29 | _, input_data = data 30 | samplerate.resample(input_data, ratio, converter_type) 31 | 32 | 33 | def test_process(data, converter_type, ratio=2.0): 34 | num_channels, input_data = data 35 | src = samplerate.Resampler(converter_type, num_channels) 36 | src.process(input_data, ratio) 37 | 38 | 39 | def test_match(data, converter_type, ratio=2.123): 40 | num_channels, input_data = data 41 | output_simple = samplerate.resample(input_data, ratio, converter_type) 42 | resampler = samplerate.Resampler(converter_type, channels=num_channels) 43 | if num_channels == 2: 44 | output_full = np.empty((0,num_channels), dtype=np.float32) 45 | for i in range(10): 46 | output_full = np.append(output_full, resampler.process(input_data[100*i:100*(i+1),:], ratio), axis=0) 47 | m = output_simple.shape[0] 48 | output_simple = np.resize(output_simple, output_full.shape) 49 | assert np.allclose(output_simple, output_full) and output_full.shape[0] > 0.8*m 50 | else: 51 | output_full = np.empty(0, dtype=np.float32) 52 | for i in range(10): 53 | output_full = np.append(output_full, resampler.process(input_data[100*i:100*(i+1)], ratio)) 54 | m = len(output_simple) 55 | output_simple = np.resize(output_simple, output_full.shape) 56 | assert np.allclose(output_simple, output_full) and len(output_full) > 0.8*m 57 | 58 | 59 | def test_callback(data, converter_type, ratio=2.0): 60 | from samplerate import CallbackResampler 61 | num_channels, input_data = data 62 | 63 | def producer(): 64 | yield input_data 65 | while True: 66 | yield None 67 | 68 | callback = lambda p=producer(): next(p) 69 | 70 | with CallbackResampler(callback, ratio, converter_type, channels=num_channels) as resampler: 71 | resampler.read(int(ratio) * input_data.shape[0]) 72 | -------------------------------------------------------------------------------- /mod_pywebsocket/stream.py: -------------------------------------------------------------------------------- 1 | # Copyright 2011, Google Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # * Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following disclaimer 12 | # in the documentation and/or other materials provided with the 13 | # distribution. 14 | # * Neither the name of Google Inc. nor the names of its 15 | # contributors may be used to endorse or promote products derived from 16 | # this software without specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | 31 | """This file exports public symbols.""" 32 | 33 | 34 | from mod_pywebsocket._stream_base import BadOperationException 35 | from mod_pywebsocket._stream_base import ConnectionTerminatedException 36 | from mod_pywebsocket._stream_base import InvalidFrameException 37 | from mod_pywebsocket._stream_base import InvalidUTF8Exception 38 | from mod_pywebsocket._stream_base import UnsupportedFrameException 39 | from mod_pywebsocket._stream_hixie75 import StreamHixie75 40 | from mod_pywebsocket._stream_hybi import Frame 41 | from mod_pywebsocket._stream_hybi import Stream 42 | from mod_pywebsocket._stream_hybi import StreamOptions 43 | 44 | # These methods are intended to be used by WebSocket client developers to have 45 | # their implementations receive broken data in tests. 46 | from mod_pywebsocket._stream_hybi import create_close_frame 47 | from mod_pywebsocket._stream_hybi import create_header 48 | from mod_pywebsocket._stream_hybi import create_length_header 49 | from mod_pywebsocket._stream_hybi import create_ping_frame 50 | from mod_pywebsocket._stream_hybi import create_pong_frame 51 | from mod_pywebsocket._stream_hybi import create_binary_frame 52 | from mod_pywebsocket._stream_hybi import create_text_frame 53 | from mod_pywebsocket._stream_hybi import create_closing_handshake_body 54 | 55 | 56 | # vi:sts=4 sw=4 et 57 | -------------------------------------------------------------------------------- /test/kiwi_server.py: -------------------------------------------------------------------------------- 1 | ## -*- python -*- 2 | 3 | ## kiwisdr websocket simulator 4 | ## * needs python>=3.7 5 | ## * only one client is supported 6 | ## * IQ mode and GNSS timestamps are not supported 7 | 8 | import asyncio 9 | import websockets 10 | import struct 11 | import random 12 | import sys 13 | 14 | @asyncio.coroutine 15 | def consumer_handler(websocket, path): 16 | try: 17 | while True: 18 | message = yield from websocket.recv() 19 | if message.find('SET keepalive') < 0: 20 | print('got', path, message) 21 | sys.stdout.flush() 22 | except Exception as e: 23 | print(e) 24 | 25 | @asyncio.coroutine 26 | def producer_handler(websocket, path): 27 | try: 28 | i=0 29 | while True: 30 | if (i%10000) == 0: 31 | print('i=%12d %10.2f h' % (i, i/12000./3600*512)) 32 | sys.stdout.flush() 33 | ##data = b''.join([b'SND', struct.pack('= 0: 95 | if self._frame_counter < 3: 96 | self._samplerate = n/(self.gpssec - self._last_gpssec) 97 | else: 98 | self._samplerate = 0.9*self._samplerate + 0.1*n/(self.gpssec - self._last_gpssec) 99 | 100 | if self._frame_counter >= 2: 101 | t = np.arange(start = self.gpssec, 102 | stop = self.gpssec + (n-0.5)/self._samplerate, 103 | step = 1/self._samplerate, 104 | dtype = np.float64) 105 | ##t = self.gpssec + np.array(range(n))/self._samplerate 106 | self.process_iq_samples(t,z) 107 | 108 | self._last_gpssec = self.gpssec 109 | self._frame_counter += (self._frame_counter < 3) 110 | return t,z 111 | 112 | def read_kiwi_iq_wav(filename): 113 | t = [] 114 | z = [] 115 | for _t,_z in KiwiIQWavReader(filename): 116 | if _t is None: 117 | continue 118 | t.append(_t) 119 | z.append(_z) 120 | return np.concatenate(t), np.concatenate(z) 121 | 122 | if __name__ == '__main__': 123 | import sys 124 | if len(sys.argv) != 2: 125 | print('Usage: ...') 126 | 127 | [t,z]=read_kiwi_iq_wav(sys.argv[1]) 128 | print (len(t),len(z), t[-1], z[-1], (t[-1]-t[-2])*1e6) 129 | -------------------------------------------------------------------------------- /waterfall_data_analysis.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "%pylab inline\n", 10 | "import struct" 11 | ] 12 | }, 13 | { 14 | "cell_type": "markdown", 15 | "metadata": {}, 16 | "source": [ 17 | "# Read in data from binary waterfall file" 18 | ] 19 | }, 20 | { 21 | "cell_type": "code", 22 | "execution_count": null, 23 | "metadata": {}, 24 | "outputs": [], 25 | "source": [ 26 | "header_len = 8+26 # 2 unsigned int for center_freq and span: 8 bytes PLUS 26 bytes for datetime\n", 27 | "nbin = 1024\n", 28 | "\n", 29 | "with open(\"wf.bin\", \"rb\") as fd:\n", 30 | " buff = fd.read()\n", 31 | "\n", 32 | "length = len(buff[header_len:])\n", 33 | "n_t = length / nbin\n", 34 | "\n", 35 | "header = struct.unpack('2I26s', buff[:header_len])\n", 36 | "data = struct.unpack('%dB'%length, buff[header_len:])\n", 37 | "\n", 38 | "waterfall_array = np.reshape(np.array(data[:]), (n_t, nbin))\n", 39 | "waterfall_array -= 255\n", 40 | "\n", 41 | "avg_wf = np.mean(waterfall_array[:,:], axis=0)\n", 42 | "\n", 43 | "center_frequency, span, rec_time = header\n", 44 | "start_freq = center_frequency - span/2\n", 45 | "stop_freq = center_frequency + span/2\n", 46 | "print \"Recording date:\", rec_time" 47 | ] 48 | }, 49 | { 50 | "cell_type": "markdown", 51 | "metadata": {}, 52 | "source": [ 53 | "# Plot waterfall data" 54 | ] 55 | }, 56 | { 57 | "cell_type": "code", 58 | "execution_count": null, 59 | "metadata": {}, 60 | "outputs": [], 61 | "source": [ 62 | "figure(figsize=(40,8))\n", 63 | "nticks = 11\n", 64 | "xticks(np.linspace(0,nbin,nticks), np.linspace(start_freq, stop_freq, nticks))\n", 65 | "pcolormesh(waterfall_array[:,1:], cmap=cm.jet , vmin=-100, vmax=-46)\n", 66 | "title(\"Recording time: %s\"%rec_time)\n", 67 | "colorbar()" 68 | ] 69 | }, 70 | { 71 | "cell_type": "markdown", 72 | "metadata": {}, 73 | "source": [ 74 | "# Plot a few spectra" 75 | ] 76 | }, 77 | { 78 | "cell_type": "code", 79 | "execution_count": null, 80 | "metadata": {}, 81 | "outputs": [], 82 | "source": [ 83 | "figure(figsize=(20,8))\n", 84 | "nticks = 11\n", 85 | "xticks(np.linspace(0,nbin,nticks), np.linspace(start_freq, stop_freq, nticks))\n", 86 | "for spectrum in waterfall_array[1:10]:\n", 87 | " plot(spectrum[1:])" 88 | ] 89 | }, 90 | { 91 | "cell_type": "markdown", 92 | "metadata": {}, 93 | "source": [ 94 | "# Plot signal levels distribution (instantaneous and average)" 95 | ] 96 | }, 97 | { 98 | "cell_type": "code", 99 | "execution_count": null, 100 | "metadata": {}, 101 | "outputs": [], 102 | "source": [ 103 | "figure(figsize=(20,8))\n", 104 | "_=hist(waterfall_array[0,:], bins=40, normed=True)\n", 105 | "xlim(-110,0)\n", 106 | "xlabel(\"Signal level (dB)\")\n", 107 | "ylabel(\"Occurrences\")\n", 108 | "title(\"Instantaneous power level distribution\")\n", 109 | "\n", 110 | "figure(figsize=(20,8))\n", 111 | "_=hist(avg_wf, bins=40, normed=True)\n", 112 | "xlim(-110,0)\n", 113 | "xlabel(\"Signal level (dB)\")\n", 114 | "ylabel(\"Occurrences\")\n", 115 | "title(\"Average power level distribution\")\n" 116 | ] 117 | }, 118 | { 119 | "cell_type": "markdown", 120 | "metadata": {}, 121 | "source": [ 122 | "# Compute SNR for the whole band (average data)" 123 | ] 124 | }, 125 | { 126 | "cell_type": "code", 127 | "execution_count": null, 128 | "metadata": {}, 129 | "outputs": [], 130 | "source": [ 131 | "median = np.median(avg_wf)\n", 132 | "perc95 = np.percentile(avg_wf, 95)\n", 133 | "\n", 134 | "print \"SNR estimation: median: %f dB, 95th perc.: %f dB, SNR: %f dB\" % (median, perc95, perc95-median)" 135 | ] 136 | }, 137 | { 138 | "cell_type": "code", 139 | "execution_count": null, 140 | "metadata": {}, 141 | "outputs": [], 142 | "source": [] 143 | } 144 | ], 145 | "metadata": { 146 | "kernelspec": { 147 | "display_name": "Python 2", 148 | "language": "python", 149 | "name": "python2" 150 | }, 151 | "language_info": { 152 | "codemirror_mode": { 153 | "name": "ipython", 154 | "version": 2 155 | }, 156 | "file_extension": ".py", 157 | "mimetype": "text/x-python", 158 | "name": "python", 159 | "nbconvert_exporter": "python", 160 | "pygments_lexer": "ipython2", 161 | "version": "2.7.15" 162 | } 163 | }, 164 | "nbformat": 4, 165 | "nbformat_minor": 2 166 | } 167 | -------------------------------------------------------------------------------- /kiwi/worker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | ## -*- python -*- 3 | 4 | import logging, time 5 | import threading 6 | from traceback import print_exc 7 | 8 | from .client import KiwiTooBusyError, KiwiRedirectError, KiwiTimeLimitError, KiwiServerTerminatedConnection 9 | from .rigctld import Rigctld 10 | 11 | class KiwiWorker(threading.Thread): 12 | def __init__(self, group=None, target=None, name=None, args=(), kwargs=None): 13 | super(KiwiWorker, self).__init__(group=group, target=target, name=name) 14 | self._recorder, self._options, self._delay_run, self._run_event = args 15 | self._recorder._reader = True 16 | self._event = threading.Event() 17 | self._rigctld = None 18 | if self._options.rigctl_enabled: 19 | self._rigctld = Rigctld(self._recorder, self._options.rigctl_port, self._options.rigctl_address) 20 | 21 | def _do_run(self): 22 | return self._run_event.is_set() 23 | 24 | def run(self): 25 | self.connect_count = self._options.connect_retries 26 | self.busy_count = self._options.busy_retries 27 | if self._delay_run: # let snd/wf get established first 28 | time.sleep(3) 29 | 30 | while self._do_run(): 31 | try: 32 | self._recorder.connect(self._options.server_host, self._options.server_port) 33 | 34 | except Exception as e: 35 | logging.warn("Failed to connect, sleeping and reconnecting error='%s'" %e) 36 | if self._options.is_kiwi_tdoa: 37 | self._options.status = 1 38 | break 39 | self.connect_count -= 1 40 | if self._options.connect_retries > 0 and self.connect_count == 0: 41 | break 42 | if self._options.connect_timeout > 0: 43 | self._event.wait(timeout = self._options.connect_timeout) 44 | continue 45 | 46 | try: 47 | self._recorder.open() 48 | while self._do_run(): 49 | self._recorder.run() 50 | # do things like freq changes while not receiving sound 51 | if self._rigctld: 52 | self._rigctld.run() 53 | 54 | except KiwiServerTerminatedConnection as e: 55 | if self._options.no_api: 56 | msg = '' 57 | else: 58 | msg = ' Reconnecting after 5 seconds' 59 | logging.info("%s:%s %s.%s" % (self._options.server_host, self._options.server_port, e, msg)) 60 | self._recorder.close() 61 | if self._options.no_api: ## don't retry 62 | break 63 | self._recorder._start_ts = None ## this makes the recorder open a new file on restart 64 | self._event.wait(timeout=5) 65 | continue 66 | 67 | except KiwiTooBusyError: 68 | if self._options.is_kiwi_tdoa: 69 | self._options.status = 2 70 | break 71 | self.busy_count -= 1 72 | if self._options.busy_retries > 0 and self.busy_count == 0: 73 | break 74 | logging.warn("%s:%d Too busy now. Reconnecting after %d seconds" 75 | % (self._options.server_host, self._options.server_port, self._options.busy_timeout)) 76 | if self._options.busy_timeout > 0: 77 | self._event.wait(timeout = self._options.busy_timeout) 78 | continue 79 | 80 | except KiwiRedirectError as e: 81 | prev = self._options.server_host +':'+ str(self._options.server_port) 82 | # http://host:port 83 | # ^^^^ ^^^^ 84 | uri = str(e).split(':') 85 | self._options.server_host = uri[1][2:] 86 | self._options.server_port = uri[2] 87 | logging.warn("%s Too busy now. Redirecting to %s:%s" % (prev, self._options.server_host, self._options.server_port)) 88 | if self._options.is_kiwi_tdoa: 89 | self._options.status = 2 90 | break 91 | self._event.wait(timeout=2) 92 | continue 93 | 94 | except KiwiTimeLimitError: 95 | break 96 | 97 | except Exception as e: 98 | if self._options.is_kiwi_tdoa: 99 | self._options.status = 1 100 | print_exc() 101 | break 102 | 103 | self._run_event.clear() # tell all other threads to stop 104 | self._recorder.close() 105 | self._recorder._close_func() 106 | if self._rigctld: 107 | self._rigctld.close() 108 | -------------------------------------------------------------------------------- /src/read_kiwi_iq_wav.cc: -------------------------------------------------------------------------------- 1 | // -*- C++ -*- 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #if defined(__GNUC__) && not defined(__MINGW32__) 8 | # define _PACKED __attribute__((__packed__)) 9 | #else 10 | # define _PACKED 11 | # define _USE_PRAGMA_PACK 12 | #endif 13 | 14 | #ifdef _USE_PRAGMA_PACK 15 | # pragma pack(push, 1) 16 | #endif 17 | 18 | #if __cplusplus != 201103L 19 | # include 20 | # define static_assert(A,B) assert(A) 21 | #endif 22 | 23 | class chunk_base { 24 | public: 25 | chunk_base() 26 | : _id() 27 | , _size() { 28 | static_assert(sizeof(chunk_base) == 8, "chunk_base has wrong packed size"); 29 | } 30 | std::string id() const { return std::string((char*)(_id), 4); } 31 | std::streampos size() const { return _size; } 32 | private: 33 | int8_t _id[4]; 34 | uint32_t _size; 35 | } _PACKED; 36 | 37 | class chunk_riff : public chunk_base { 38 | public: 39 | chunk_riff() 40 | : _format() { 41 | static_assert(sizeof(chunk_riff) == 8+4, "chunk_riff has wrong packed size"); 42 | } 43 | std::string format() const { return std::string((char*)(_format), 4); } 44 | 45 | private: 46 | int8_t _format[4]; 47 | } _PACKED; 48 | 49 | class chunk_fmt : public chunk_base { 50 | public: 51 | chunk_fmt() 52 | : _format() 53 | , _num_channels() 54 | , _sample_rate() 55 | , _byte_rate() 56 | , _block_align() 57 | , _dummy() { 58 | static_assert(sizeof(chunk_fmt) == 8+16, "chunk_fmt has wrong packed size"); 59 | } 60 | uint16_t format() const { return _format; } 61 | uint16_t num_channels() const { return _num_channels; } 62 | uint32_t sample_rate() const { return _sample_rate; } 63 | uint32_t byte_rate() const { return _byte_rate; } 64 | uint16_t block_align() const { return _block_align; } 65 | 66 | protected: 67 | uint16_t _format; 68 | uint16_t _num_channels; 69 | uint32_t _sample_rate; 70 | uint32_t _byte_rate; 71 | uint16_t _block_align; 72 | uint16_t _dummy; 73 | } _PACKED; 74 | 75 | class chunk_kiwi : public chunk_base { 76 | public: 77 | chunk_kiwi() 78 | : _last() 79 | , _dummy() 80 | , _gpssec() 81 | , _gpsnsec() { 82 | static_assert(sizeof(chunk_kiwi) == 8+10, "chunk_kiwi has wrong packed size"); 83 | } 84 | uint8_t last() const { return _last; } 85 | uint32_t gpssec() const { return _gpssec; } 86 | uint32_t gpsnsec() const { return _gpsnsec; } 87 | private: 88 | uint8_t _last, _dummy; 89 | uint32_t _gpssec, _gpsnsec; 90 | } _PACKED; 91 | 92 | #ifdef _USE_PRAGMA_PACK 93 | # pragma pack(pop) 94 | # undef _USE_PRAGMA_PACK 95 | #endif 96 | 97 | #undef _PACKED 98 | 99 | DEFUN_DLD (read_kiwi_iq_wav, args, nargout, "[d,sample_rate]=read_kiwi_iq_wav(\"(i/32768.0f, q/32768.0f); 153 | } 154 | if (j != n) 155 | error("incomplete 'data' chunk"); 156 | cell_z(data_counter++) = a; 157 | } else if (c.id() == "kiwi") { 158 | file.seekg(pos); 159 | chunk_kiwi kiwi; 160 | file.read((char*)(&kiwi), sizeof(kiwi)); 161 | if (!file) 162 | error("incomplete 'kiwi' chunk"); 163 | cell_last(data_counter) = kiwi.last(); 164 | cell_gpssec(data_counter) = kiwi.gpssec(); 165 | cell_gpsnsec(data_counter) = kiwi.gpsnsec(); 166 | } else { 167 | octave_stdout << "skipping unknown chunk '" << c.id() << "'" << std::endl; 168 | file.seekg(file.tellg() + c.size()); 169 | } 170 | } 171 | octave_map map; 172 | map.setfield("z", cell_z); 173 | if (cell_last.length() == cell_z.length()) { 174 | map.setfield("gpslast", cell_last); 175 | map.setfield("gpssec", cell_gpssec); 176 | map.setfield("gpsnsec", cell_gpsnsec); 177 | } else { 178 | error("number of GNSS timestamps does not match number of IQ samples"); 179 | } 180 | retval(0) = map; 181 | 182 | return retval; 183 | } 184 | -------------------------------------------------------------------------------- /microkiwi_waterfall.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import numpy as np 4 | import struct 5 | 6 | import array 7 | import logging 8 | import socket 9 | import struct 10 | import time 11 | from datetime import datetime 12 | 13 | from kiwi import wsclient 14 | 15 | import mod_pywebsocket.common 16 | from mod_pywebsocket.stream import Stream 17 | from mod_pywebsocket.stream import StreamOptions 18 | 19 | import sys 20 | if sys.version_info > (3,): 21 | buffer = memoryview 22 | def bytearray2str(b): 23 | return b.decode('ascii') 24 | else: 25 | def bytearray2str(b): 26 | return str(b) 27 | 28 | from optparse import OptionParser 29 | 30 | parser = OptionParser() 31 | parser.add_option("-f", "--file", dest="filename", type=str, 32 | help="write waterfall data to binary FILE", metavar="FILE") 33 | parser.add_option("-s", "--server", type=str, 34 | help="server name", dest="server", default='192.168.1.82') 35 | parser.add_option("-p", "--port", type=int, 36 | help="port number", dest="port", default=8073) 37 | parser.add_option("-l", "--length", type=int, 38 | help="how many samples to draw from the server", dest="length", default=100) 39 | parser.add_option("-z", "--zoom", type=int, 40 | help="zoom factor", dest="zoom", default=0) 41 | parser.add_option("-o", "--offset", type=int, 42 | help="start frequency in kHz", dest="offset_khz", default=0) 43 | parser.add_option("-v", "--verbose", type=int, 44 | help="whether to print progress and debug info", dest="verbosity", default=0) 45 | 46 | 47 | options = vars(parser.parse_args()[0]) 48 | 49 | if 'filename' in options: 50 | filename = options['filename'] 51 | else: 52 | filename = None 53 | 54 | host = options['server'] 55 | port = options['port'] 56 | print ("KiwiSDR Server: %s:%d" % (host,port)) 57 | # the default number of bins is 1024 58 | bins = 1024 59 | print ("Number of waterfall bins: %d" % bins) 60 | 61 | zoom = options['zoom'] 62 | print ("Zoom factor:", zoom) 63 | 64 | full_span = 30000.0 # for a 30MHz kiwiSDR 65 | if zoom>0: 66 | span = full_span / 2.**zoom 67 | else: 68 | span = full_span 69 | 70 | start = options['offset_khz'] 71 | stop = start + span 72 | rbw = span/bins 73 | center_freq = span/2+start 74 | print ("Start %.3f, Stop %.3f, Center %.3f, Span %.3f (MHz)" % (start/1000, stop/1000, center_freq/1000, span/1000)) 75 | 76 | if start < 0 or stop > full_span: 77 | s = "Frequency and zoom values result in span outside 0 - %d kHz range" % full_span 78 | raise Exception(s) 79 | 80 | now = str(datetime.now()) 81 | header_bin = struct.pack("II26s", int(center_freq), int(span), bytes(now, 'utf-8')) 82 | 83 | print ("Trying to contact server...") 84 | try: 85 | mysocket = socket.socket() 86 | mysocket.connect((host, port)) 87 | except: 88 | print ("Failed to connect") 89 | exit() 90 | print ("Socket open...") 91 | 92 | uri = '/%d/%s' % (int(time.time()), 'W/F') 93 | handshake = wsclient.ClientHandshakeProcessor(mysocket, host, port) 94 | handshake.handshake(uri) 95 | 96 | request = wsclient.ClientRequest(mysocket) 97 | request.ws_version = mod_pywebsocket.common.VERSION_HYBI13 98 | 99 | stream_option = StreamOptions() 100 | stream_option.mask_send = True 101 | stream_option.unmask_receive = False 102 | 103 | mystream = Stream(request, stream_option) 104 | print ("Data stream active...") 105 | 106 | 107 | # send a sequence of messages to the server, hardcoded for now 108 | # max wf speed, no compression 109 | msg_list = ['SET auth t=kiwi p=', 'SET zoom=%d cf=%d'%(zoom,center_freq),\ 110 | 'SET maxdb=0 mindb=-100', 'SET wf_speed=4', 'SET wf_comp=0'] 111 | for msg in msg_list: 112 | mystream.send_message(msg) 113 | print ("Starting to retrieve waterfall data...") 114 | # number of samples to draw from server 115 | length = options['length'] 116 | # create a numpy array to contain the waterfall data 117 | wf_data = np.zeros((length, bins)) 118 | binary_wf_list = [] 119 | time = 0 120 | while time self.chunksize: 114 | raise RuntimeError 115 | self.file.seek(self.offset + pos, 0) 116 | self.size_read = pos 117 | 118 | def tell(self): 119 | if self.closed: 120 | raise ValueError("I/O operation on closed file") 121 | return self.size_read 122 | 123 | def read(self, size=-1): 124 | """Read at most size bytes from the chunk. 125 | If size is omitted or negative, read until the end 126 | of the chunk. 127 | """ 128 | 129 | if self.closed: 130 | raise ValueError("I/O operation on closed file") 131 | if self.size_read >= self.chunksize: 132 | return b'' 133 | if size < 0: 134 | size = self.chunksize - self.size_read 135 | if size > self.chunksize - self.size_read: 136 | size = self.chunksize - self.size_read 137 | data = self.file.read(size) 138 | self.size_read = self.size_read + len(data) 139 | if self.size_read == self.chunksize and \ 140 | self.align and \ 141 | (self.chunksize & 1): 142 | dummy = self.file.read(1) 143 | self.size_read = self.size_read + len(dummy) 144 | return data 145 | 146 | def skip(self): 147 | """Skip the rest of the chunk. 148 | If you are not interested in the contents of the chunk, 149 | this method should be called so that the file points to 150 | the start of the next chunk. 151 | """ 152 | 153 | if self.closed: 154 | raise ValueError("I/O operation on closed file") 155 | if self.seekable: 156 | try: 157 | n = self.chunksize - self.size_read 158 | # maybe fix alignment 159 | if self.align and (self.chunksize & 1): 160 | n = n + 1 161 | self.file.seek(n, 1) 162 | self.size_read = self.size_read + n 163 | return 164 | except OSError: 165 | pass 166 | while self.size_read < self.chunksize: 167 | n = min(8192, self.chunksize - self.size_read) 168 | dummy = self.read(n) 169 | if not dummy: 170 | raise EOFError 171 | -------------------------------------------------------------------------------- /SCANNING: -------------------------------------------------------------------------------- 1 | [updated 17 June 2025] 2 | 3 | A discussion and examples of the kiwirecorder scanning and squelch capabilities. 4 | 5 | Please refer to example invocations in the Makefile. 6 | Search for the "# squelch" and "# scanning" sections of the Makefile. 7 | See also the README.md file for general information about kiwiclient / kiwirecorder. 8 | 9 | Because a list of frequencies associated with scanning might be extensive, and would otherwise 10 | cause the command line invocation of kiwirecorder to become unwieldy, a separate YAML-formatted file 11 | is used to store scanning parameters. In the Makefile this can be seen as: 12 | 13 | --scan-yaml scan_squelch.yaml 14 | --scan-yaml scan_continuous.yaml 15 | 16 | The example scan_squelch.yaml file contains: 17 | Scan: 18 | threshold: 20 # dB 19 | wait: 1 # seconds 20 | dwell: 6 # seconds 21 | # frequencies: [6661, 6559, 6311, 8948, 8912] # kHz, HFDL 22 | frequencies: [4024, 4030, 4039] # kHz, STANAG-4539 bursts 23 | pbc: true 24 | 25 | Whereas the example scan_continuous.yaml contains: 26 | Scan: 27 | wait: 1 # seconds 28 | dwell: 5 # seconds 29 | frequencies: [2500, 3330, 5000, 7850, 10000, 14670, 15000, 20000] # kHz, time stations 30 | 31 | The presence of the "threshold" parameter distinguishes a squelching scan from a continuous scan. 32 | The "#" character precedes a comment. 33 | 34 | A squelching scan works as follows. Kiwirecorder maintains a continuous measurement of the signal 35 | level over time by averaging readings from the S-meter transmitted along with the received audio. 36 | When the signal level on a frequency channel exceeds the threshold the scan stops and a few file 37 | begins recording. When the level drops below the threshold the file is closed and scanning resumes. 38 | 39 | The "wait" time is applied after tuning to the next entry in the frequency list and delays detection 40 | of the signal and possible start of the file recording. "dwell" is the amount of time the scan will 41 | remain on the frequency before moving on to the next one if no signal is detected. The parameter 42 | "--squelch-tail=(secs, default=1)" on the command line (not in the YAML file) determines how long 43 | the recording continues after the signal falls below the threshold. 44 | 45 | Without a threshold parameter the scan becomes continuous. Recording a file for the dwell time on 46 | each frequency in turn, independent of any signal level. 47 | 48 | When discussing receive frequencies there is always the question of carrier/dial frequency versus 49 | passband center frequency. This is only a consideration for passbands that are not symmetrical 50 | around 0 Hz. That is, sideband/CW modes compared to the AM/SAM modes. Although note that you can 51 | always adjust the passband in any mode to be anything you want. So it could change from symmetrical 52 | to asymmetrical. IQ mode is sometimes setup both ways. 53 | 54 | Sometimes a signal is centered on a round number, and if you want to record it in USB (an 55 | assymetrical passband) then it would be nice to specify these round numbered values in the frequency 56 | lists. Instead of mentally subtracting half the passband offset to get the carrier/dial frequency to 57 | enter into the list. That's when you can use the "pbc: true" entry in the YAML file (or the "--pbc" 58 | parameter on the command line). This means the frequency specified is to be interpreted as a 59 | passband center frequency. And the actual carrier/dial frequency is equal to freq - pbc. 60 | 61 | In the scan_squelch.yaml file above the HFDL and STANAG-4539 frequencies are the center of the 62 | signal as seen on the waterfall display (e.g. 4030 kHz). So the "pbc" parameter is used to allow 63 | 4030 to be specified in the frequency list instead of 4028.5 = 4030 - 1.5 kHz, since 1.5 kHz is the 64 | center of the default USB passband of 300 - 2700 Hz (300 + (2700-300)/2 = 1500). 65 | 66 | In the scan_continuous.yaml file all the time station freqnecies are carrier/dial frequencies 67 | since AM mode is used. So the "pbc: true" and "--pbc" parameters are not used. 68 | 69 | Hint: If you hold down the keyboard "shift" key while mousing around the waterfall display the 70 | frequency at the cursor center will be displayed. This is helpful to understand the exact 71 | frequencies of the passband edges. 72 | 73 | Sample output of kiwirecorder using the example scan_continuous.yaml file via running "make scan": 74 | 75 | >>> make clean; make scan 76 | rm -f *.log *.wav *.png *.txt *.npy *.nc 77 | python3 -u kiwirecorder.py -s wessex.zapto.org -p 8074 --log_level=info -m amn --scan-yaml=scan_continuous.yaml 78 | 2025-06-17 09:26:20,576 pid 21197 started sound recorder 0 79 | 2025-06-17 09:26:22,159 pid 21197 Kiwi server version: 1.813 80 | 2025-06-17 09:26:22,519 pid 21197 GNSS position: lat,lon=[+50.74, -2.64] 81 | 2025-06-17 09:26:23,702 pid 21197 GNSS position: lat,lon=[+50.74, -2.64] 82 | Median: -103.2 Thr: -109.2 S Scan: 21:26:34 S DWELL 2500 kHz 2025-06-17 09:26:34,954 pid 21197 Started a new file: 20250616T212634Z_2500000_amn.wav 83 | Block: 00000065, RSSI: -76.2 Scan: 21:26:41 S DWELL 3330 kHz 2025-06-17 09:26:41,125 pid 21197 Started a new file: 20250616T212641Z_3330000_amn.wav 84 | Block: 00000089, RSSI: -88.7 Scan: 21:26:47 S DWELL 5000 kHz 2025-06-17 09:26:47,239 pid 21197 Started a new file: 20250616T212647Z_5000000_amn.wav 85 | Block: 000000ae, RSSI: -98.5 Scan: 21:26:53 S DWELL 7850 kHz 2025-06-17 09:26:53,583 pid 21197 Started a new file: 20250616T212653Z_7850000_amn.wav 86 | Block: 000000d2, RSSI: -84.8 Scan: 21:26:59 S DWELL 10000 kHz 2025-06-17 09:26:59,707 pid 21197 Started a new file: 20250616T212659Z_10000000_amn.wav 87 | Block: 000000f6, RSSI: -107.7 Scan: 21:27:05 S DWELL 14670 kHz 2025-06-17 09:27:05,816 pid 21197 Started a new file: 20250616T212705Z_14670000_amn.wav 88 | Block: 0000011a, RSSI: -106.0 Scan: 21:27:11 S DWELL 15000 kHz 2025-06-17 09:27:11,882 pid 21197 Started a new file: 20250616T212711Z_15000000_amn.wav 89 | Block: 0000013d, RSSI: -114.0 Scan: 21:27:17 S DWELL 20000 kHz 2025-06-17 09:27:17,935 pid 21197 Started a new file: 20250616T212717Z_20000000_amn.wav 90 | Block: 00000162, RSSI: -103.3 Scan: 21:27:24 S DWELL 2500 kHz 2025-06-17 09:27:24,256 pid 21197 Started a new file: 20250616T212724Z_2500000_amn.wav 91 | ^C 92 | Block: 00000177, RSSI: -103.0 Scan: 21:27:27 S DWELL 2500 kHz KeyboardInterrupt: threads successfully closed 93 | >>> 94 | 95 | Try "make scan-sq" to scan with squelching via --scan-yaml=scan_squelch.yaml 96 | To see how squelch alone works without frequency scanning try "make sq". 97 | 98 | Less logging info will be shown if the "--log_level=info" parameter is removed. 99 | 100 | [end-of-document] 101 | -------------------------------------------------------------------------------- /mod_pywebsocket/_stream_base.py: -------------------------------------------------------------------------------- 1 | # Copyright 2011, Google Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # * Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following disclaimer 12 | # in the documentation and/or other materials provided with the 13 | # distribution. 14 | # * Neither the name of Google Inc. nor the names of its 15 | # contributors may be used to endorse or promote products derived from 16 | # this software without specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | 31 | """Base stream class. 32 | """ 33 | 34 | 35 | # Note: request.connection.write/read are used in this module, even though 36 | # mod_python document says that they should be used only in connection 37 | # handlers. Unfortunately, we have no other options. For example, 38 | # request.write/read are not suitable because they don't allow direct raw bytes 39 | # writing/reading. 40 | 41 | 42 | import socket 43 | 44 | from mod_pywebsocket import util 45 | 46 | 47 | # Exceptions 48 | 49 | 50 | class ConnectionTerminatedException(Exception): 51 | """This exception will be raised when a connection is terminated 52 | unexpectedly. 53 | """ 54 | 55 | pass 56 | 57 | 58 | class InvalidFrameException(ConnectionTerminatedException): 59 | """This exception will be raised when we received an invalid frame we 60 | cannot parse. 61 | """ 62 | 63 | pass 64 | 65 | 66 | class BadOperationException(Exception): 67 | """This exception will be raised when send_message() is called on 68 | server-terminated connection or receive_message() is called on 69 | client-terminated connection. 70 | """ 71 | 72 | pass 73 | 74 | 75 | class UnsupportedFrameException(Exception): 76 | """This exception will be raised when we receive a frame with flag, opcode 77 | we cannot handle. Handlers can just catch and ignore this exception and 78 | call receive_message() again to continue processing the next frame. 79 | """ 80 | 81 | pass 82 | 83 | 84 | class InvalidUTF8Exception(Exception): 85 | """This exception will be raised when we receive a text frame which 86 | contains invalid UTF-8 strings. 87 | """ 88 | 89 | pass 90 | 91 | 92 | class StreamBase(object): 93 | """Base stream class.""" 94 | 95 | def __init__(self, request): 96 | """Construct an instance. 97 | 98 | Args: 99 | request: mod_python request. 100 | """ 101 | 102 | self._logger = util.get_class_logger(self) 103 | 104 | self._request = request 105 | 106 | def _read(self, length): 107 | """Reads length bytes from connection. In case we catch any exception, 108 | prepends remote address to the exception message and raise again. 109 | 110 | Raises: 111 | ConnectionTerminatedException: when read returns empty string. 112 | """ 113 | 114 | try: 115 | read_bytes = self._request.connection.read(length) 116 | if not read_bytes: 117 | raise ConnectionTerminatedException( 118 | 'Receiving %d byte failed. Peer (%r) closed connection' % 119 | (length, (self._request.connection.remote_addr,))) 120 | return read_bytes 121 | except socket.error as e: 122 | # Catch a socket.error. Because it's not a child class of the 123 | # IOError prior to Python 2.6, we cannot omit this except clause. 124 | # Use %s rather than %r for the exception to use human friendly 125 | # format. 126 | raise ConnectionTerminatedException( 127 | 'Receiving %d byte failed. socket.error (%s) occurred' % 128 | (length, e)) 129 | except IOError as e: 130 | # Also catch an IOError because mod_python throws it. 131 | raise ConnectionTerminatedException( 132 | 'Receiving %d byte failed. IOError (%s) occurred' % 133 | (length, e)) 134 | 135 | def _write(self, bytes_to_write): 136 | """Writes given bytes to connection. In case we catch any exception, 137 | prepends remote address to the exception message and raise again. 138 | """ 139 | 140 | try: 141 | self._request.connection.write(bytes_to_write) 142 | except Exception as e: 143 | util.prepend_message_to_exception( 144 | 'Failed to send message to %r: ' % 145 | (self._request.connection.remote_addr,), 146 | e) 147 | raise 148 | 149 | def receive_bytes(self, length): 150 | """Receives multiple bytes. Retries read when we couldn't receive the 151 | specified amount. 152 | 153 | Raises: 154 | ConnectionTerminatedException: when read returns empty string. 155 | """ 156 | 157 | read_bytes = [] 158 | while length > 0: 159 | new_read_bytes = self._read(length) 160 | read_bytes.append(new_read_bytes) 161 | length -= len(new_read_bytes) 162 | if length!=0 and type(read_bytes[0]) == str: 163 | return ''.join(read_bytes) 164 | else: 165 | return bytearray().join(read_bytes) 166 | 167 | def _read_until(self, delim_char): 168 | """Reads bytes until we encounter delim_char. The result will not 169 | contain delim_char. 170 | 171 | Raises: 172 | ConnectionTerminatedException: when read returns empty string. 173 | """ 174 | 175 | read_bytes = [] 176 | while True: 177 | ch = self._read(1) 178 | if ch == delim_char: 179 | break 180 | read_bytes.append(ch) 181 | return ''.join(read_bytes) 182 | 183 | 184 | # vi:sts=4 sw=4 et 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [updated 20 August 2025] 2 | 3 | # KiwiClient 4 | 5 | This is the version v1.7 Python client for KiwiSDR. It allows you to: 6 | 7 | * Receive data streams with audio samples, IQ samples, S-meter and waterfall data 8 | * Issue commands to the KiwiSDR 9 | 10 | ## Install 11 | 12 | Visit [github.com/jks-prv/kiwiclient](https://github.com/jks-prv/kiwiclient) 13 | Click the green `'Code'` button and select `'Download ZIP'` 14 | You should find the file `'kiwiclient-master.zip'` in your download directory. 15 | Copy it to the appropriate destination and unzip it if necessary. 16 | Perhaps rename the resulting directory `'kiwiclient-master'` to just `'kiwiclient'`. 17 | Change to that directory. 18 | Here you will find a `'Makefile'` containing various usage examples. 19 | Assuming your system has `'Make'` and `'Python3'` installed type `'make help'` to get started. 20 | Or without `'Make'` type `'python3 kiwirecorder.py --help'` 21 | It is also possible to use the `'git'` tools to checkout a kiwiclient clone that is easier to keep updated. 22 | For example `'git clone https://github.com/jks-prv/kiwiclient.git'` 23 | 24 | ## Dependencies 25 | 26 | Python3 is required. 27 | 28 | Make sure the Python package `'numpy'` is installed. 29 | On many Linux distributions the command would be similar to `'apt install python3-numpy'` 30 | On macOS try `'pip3 install numpy'` or perhaps `'python3 -m pip install numpy'` 31 | 32 | ## Resampling 33 | 34 | If you want high-quality resampling based on libsamplerate (SRC) you should build the version 35 | included with KiwiClient that has fixes rather than using the standard python-samplerate package. 36 | Follow these steps. Ask on the Kiwi forum if you have problems: [forum.kiwisdr.com](https://forum.kiwisdr.com) 37 | * Install the Python package `'cffi'` 38 | * Install the `'libsamplerate'` library using your system's package manager. 39 | Note: this is not the Python package `'samplerate'` but the native code library `'libsamplerate'` 40 | (e.g. x86\_64 or arm64). 41 | * Windows: download from `'github.com/libsndfile/libsamplerate/releases'` 42 | * Linux: use a package manager, e.g. `'apt install libsamplerate'` or `'libsamplerate0'` 43 | * macOS: use a package manager like brew: `'brew install libsamplerate'` 44 | * Run the samplerate module builder `'make samplerate_build'`. 45 | This generates a Python wrapper around `'libsamplerate'` in the file `'samplerate/_src.py'` 46 | * Install `'pytest'` using the Python package manager or perhaps `'pip3 install pytest'` 47 | * Test by running `'make samplerate_test'` 48 | * If your system says `'pytest'` isn't found try `'make samplerate_test2'` 49 | 50 | If you can't build the Kiwi version then install the regular Python package: `'pip3 install samplerate'` 51 | If either samplerate module is not found then low-quality resampling based on linear interpolation is used. 52 | A message indicating which resampler is being used will be shown. 53 | Set the environment variable `'USE_LIBSAMPLERATE'` to '`False'` to force the linear interpolator to be used. 54 | 55 | ## Demo code 56 | 57 | The following demo programs are provided. Use the `--help` argument to see all program options. 58 | 59 | * `kiwirecorder`: Record audio to WAV files, with squelch. Option `--wf` prints various waterfall statistics.
Adding option `--wf-png` records the waterfall as a PNG file. `--help` for more info. 60 | * `kiwiwfrecorder`: Specialty program. Saves waterfall data and GPS timestamps to .npy format file. 61 | * `kiwifax`: Decode radiofax and save as PNGs, with auto start, stop, and phasing. 62 | * `kiwiclientd`: Plays Kiwi audio on sound cards (real & virtual) for use by programs like fldigi and wsjtx. 63 | Implements hamlib rigctl network interface so the Kiwi freq & mode can be controlled by these programs. 64 | * `kiwi_nc`: Deprecated. Use the `--nc` option with `kiwirecorder`. Command line pipeline tool in the style of `netcat`. 65 | Example: streaming IQ samples to `dumphfdl` (see the `Makefile` target `dumphfdl`). 66 | 67 | The `Makefile` contains numerous examples of how to use these programs. 68 | 69 | ## IS0KYB micro tools 70 | 71 | Two utilities have been added to simplify the waterfall data acquisition/storage and data analysis. 72 | The SNR ratio (a la Pierre Ynard) is computed each time. 73 | There is now the possibility to change zoom level and offset frequency. 74 | 75 | * `microkiwi_waterfall.py`: launch this program with no filename and just the SNR will be computed, with a filename, the raw waterfall data is saved. Launch with `--help` to list all options. 76 | * `waterfall_data_analysis.ipynb`: this is a demo jupyter notebook to interactively analyze waterfall data. Easily transformable into a standalone python program. 77 | 78 | The data is, at the moment, transferred in uncompressed format. 79 | 80 | ## Guide to the code 81 | 82 | ### kiwirecorder.py 83 | * Can record audio data, IQ samples, and waterfall data. 84 | * The complete list of options can be obtained by `python3 kiwirecorder.py --help` or `make help`. 85 | * It is possible to record from more than one KiwiSDR simultaneously, see again `--help`. 86 | * For recording IQ samples there is the `-w` or `--kiwi-wav` option: this writes a .wav file 87 | which includes GNSS timestamps (see below). 88 | * The `--netcat` option can stream raw or .wav-formatted samples to standard output. 89 | * Kiwirecorder can "camp" onto an existing KiwiSDR audio channel. See the `--camp-chan` option. 90 | * AGC options can be specified in a YAML-formatted file, `--agc-yaml` option, see `default_agc.yaml`. 91 | * Scanning options (with optional squelch) can be specified in a YAML-formatted file, `--scan-yaml` option. 92 | See the file `SCANNING` for detailed info. 93 | * Note the above YAML options need PyYAML to be installed. 94 | * See the `Makefile` for many usage examples. 95 | * Ask on the KiwiSDR Forum for help: [forum.kiwisdr.com](https://forum.kiwisdr.com) 96 | 97 | ### kiwiclient.py 98 | 99 | Base class for receiving websocket data from a KiwiSDR. 100 | It provides the following methods which can be used in derived classes: 101 | 102 | * `_process_audio_samples(self, seq, samples, rssi)`: audio samples 103 | * `_process_iq_samples(self, seq, samples, rssi, gps)`: IQ samples 104 | * `_process_waterfall_samples(self, seq, samples)`: waterfall data 105 | 106 | ## IQ .wav files with GNSS timestamps 107 | ### kiwirecorder.py configuration 108 | * Use the option `-m iq --kiwi-wav --station=[name]` for recording IQ samples with GNSS time stamps. 109 | * The resulting .wav files contains non-standard WAV chunks with GNSS timestamps. 110 | * If a directory with name `gnss_pos/` exists, a text file `gnss_pos/[name].txt` will be created which contains latitude and longitude as provided by the KiwiSDR; existing files are overwritten. 111 | 112 | ### Working with the recorded .wav files 113 | * There is an Octave extension for reading such WAV files, see `read_kiwi_iq_wav.cc` where the details of the non-standard WAV chunk can be found; it needs to be compiled in this way: `make install`. 114 | * For using read_kiwi_iq_wav an Octave function `proc_kiwi_iq_wav.m` is provided; type `help proc_kiwi_iq_wav` in Octave for documentation. 115 | * There is a Makefile rule to invoke the above: `make proc f=filename.wav` 116 | * For checking the integrity of a .wav file using `client/wavreader.py` run: `make wav f=filename.wav` 117 | 118 | [end-of-document] 119 | -------------------------------------------------------------------------------- /mod_pywebsocket/http_header_util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2011, Google Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # * Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following disclaimer 12 | # in the documentation and/or other materials provided with the 13 | # distribution. 14 | # * Neither the name of Google Inc. nor the names of its 15 | # contributors may be used to endorse or promote products derived from 16 | # this software without specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | 31 | """Utilities for parsing and formatting headers that follow the grammar defined 32 | in HTTP RFC http://www.ietf.org/rfc/rfc2616.txt. 33 | """ 34 | 35 | try: 36 | from urllib.parse import urlparse 37 | except ImportError: 38 | import urlparse 39 | 40 | _SEPARATORS = '()<>@,;:\\"/[]?={} \t' 41 | 42 | 43 | def _is_char(c): 44 | """Returns true iff c is in CHAR as specified in HTTP RFC.""" 45 | 46 | return ord(c) <= 127 47 | 48 | 49 | def _is_ctl(c): 50 | """Returns true iff c is in CTL as specified in HTTP RFC.""" 51 | 52 | return ord(c) <= 31 or ord(c) == 127 53 | 54 | 55 | class ParsingState(object): 56 | 57 | def __init__(self, data): 58 | self.data = data 59 | self.head = 0 60 | 61 | 62 | def peek(state, pos=0): 63 | """Peeks the character at pos from the head of data.""" 64 | 65 | if state.head + pos >= len(state.data): 66 | return None 67 | 68 | return state.data[state.head + pos] 69 | 70 | 71 | def consume(state, amount=1): 72 | """Consumes specified amount of bytes from the head and returns the 73 | consumed bytes. If there's not enough bytes to consume, returns None. 74 | """ 75 | 76 | if state.head + amount > len(state.data): 77 | return None 78 | 79 | result = state.data[state.head:state.head + amount] 80 | state.head = state.head + amount 81 | return result 82 | 83 | 84 | def consume_string(state, expected): 85 | """Given a parsing state and a expected string, consumes the string from 86 | the head. Returns True if consumed successfully. Otherwise, returns 87 | False. 88 | """ 89 | 90 | pos = 0 91 | 92 | for c in expected: 93 | if c != peek(state, pos): 94 | return False 95 | pos += 1 96 | 97 | consume(state, pos) 98 | return True 99 | 100 | 101 | def consume_lws(state): 102 | """Consumes a LWS from the head. Returns True if any LWS is consumed. 103 | Otherwise, returns False. 104 | 105 | LWS = [CRLF] 1*( SP | HT ) 106 | """ 107 | 108 | original_head = state.head 109 | 110 | consume_string(state, '\r\n') 111 | 112 | pos = 0 113 | 114 | while True: 115 | c = peek(state, pos) 116 | if c == ' ' or c == '\t': 117 | pos += 1 118 | else: 119 | if pos == 0: 120 | state.head = original_head 121 | return False 122 | else: 123 | consume(state, pos) 124 | return True 125 | 126 | 127 | def consume_lwses(state): 128 | """Consumes *LWS from the head.""" 129 | 130 | while consume_lws(state): 131 | pass 132 | 133 | 134 | def consume_token(state): 135 | """Consumes a token from the head. Returns the token or None if no token 136 | was found. 137 | """ 138 | 139 | pos = 0 140 | 141 | while True: 142 | c = peek(state, pos) 143 | if c is None or c in _SEPARATORS or _is_ctl(c) or not _is_char(c): 144 | if pos == 0: 145 | return None 146 | 147 | return consume(state, pos) 148 | else: 149 | pos += 1 150 | 151 | 152 | def consume_token_or_quoted_string(state): 153 | """Consumes a token or a quoted-string, and returns the token or unquoted 154 | string. If no token or quoted-string was found, returns None. 155 | """ 156 | 157 | original_head = state.head 158 | 159 | if not consume_string(state, '"'): 160 | return consume_token(state) 161 | 162 | result = [] 163 | 164 | expect_quoted_pair = False 165 | 166 | while True: 167 | if not expect_quoted_pair and consume_lws(state): 168 | result.append(' ') 169 | continue 170 | 171 | c = consume(state) 172 | if c is None: 173 | # quoted-string is not enclosed with double quotation 174 | state.head = original_head 175 | return None 176 | elif expect_quoted_pair: 177 | expect_quoted_pair = False 178 | if _is_char(c): 179 | result.append(c) 180 | else: 181 | # Non CHAR character found in quoted-pair 182 | state.head = original_head 183 | return None 184 | elif c == '\\': 185 | expect_quoted_pair = True 186 | elif c == '"': 187 | return ''.join(result) 188 | elif _is_ctl(c): 189 | # Invalid character %r found in qdtext 190 | state.head = original_head 191 | return None 192 | else: 193 | result.append(c) 194 | 195 | 196 | def quote_if_necessary(s): 197 | """Quotes arbitrary string into quoted-string.""" 198 | 199 | quote = False 200 | if s == '': 201 | return '""' 202 | 203 | result = [] 204 | for c in s: 205 | if c == '"' or c in _SEPARATORS or _is_ctl(c) or not _is_char(c): 206 | quote = True 207 | 208 | if c == '"' or _is_ctl(c): 209 | result.append('\\' + c) 210 | else: 211 | result.append(c) 212 | 213 | if quote: 214 | return '"' + ''.join(result) + '"' 215 | else: 216 | return ''.join(result) 217 | 218 | 219 | def parse_uri(uri): 220 | """Parse absolute URI then return host, port and resource.""" 221 | 222 | parsed = urlparse.urlsplit(uri) 223 | if parsed.scheme != 'wss' and parsed.scheme != 'ws': 224 | # |uri| must be a relative URI. 225 | # TODO(toyoshim): Should validate |uri|. 226 | return None, None, uri 227 | 228 | if parsed.hostname is None: 229 | return None, None, None 230 | 231 | port = None 232 | try: 233 | port = parsed.port 234 | except ValueError as e: 235 | # port property cause ValueError on invalid null port description like 236 | # 'ws://host:/path'. 237 | return None, None, None 238 | 239 | if port is None: 240 | if parsed.scheme == 'ws': 241 | port = 80 242 | else: 243 | port = 443 244 | 245 | path = parsed.path 246 | if not path: 247 | path += '/' 248 | if parsed.query: 249 | path += '?' + parsed.query 250 | if parsed.fragment: 251 | path += '#' + parsed.fragment 252 | 253 | return parsed.hostname, port, path 254 | 255 | 256 | #try: 257 | # urlparse.uses_netloc.index('ws') 258 | # urlparse.uses_netloc.index('ws') 259 | #except ValueError as e: 260 | # # urlparse in Python2.5.1 doesn't have 'ws' and 'wss' entries. 261 | # urlparse.uses_netloc.append('ws') 262 | # urlparse.uses_netloc.append('wss') 263 | 264 | 265 | # vi:sts=4 sw=4 et 266 | -------------------------------------------------------------------------------- /mod_pywebsocket/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2011, Google Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # * Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following disclaimer 12 | # in the documentation and/or other materials provided with the 13 | # distribution. 14 | # * Neither the name of Google Inc. nor the names of its 15 | # contributors may be used to endorse or promote products derived from 16 | # this software without specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | 31 | """WebSocket extension for Apache HTTP Server. 32 | 33 | mod_pywebsocket is a WebSocket extension for Apache HTTP Server 34 | intended for testing or experimental purposes. mod_python is required. 35 | 36 | 37 | Installation 38 | ============ 39 | 40 | 0. Prepare an Apache HTTP Server for which mod_python is enabled. 41 | 42 | 1. Specify the following Apache HTTP Server directives to suit your 43 | configuration. 44 | 45 | If mod_pywebsocket is not in the Python path, specify the following. 46 | is the directory where mod_pywebsocket is installed. 47 | 48 | PythonPath "sys.path+['']" 49 | 50 | Always specify the following. is the directory where 51 | user-written WebSocket handlers are placed. 52 | 53 | PythonOption mod_pywebsocket.handler_root 54 | PythonHeaderParserHandler mod_pywebsocket.headerparserhandler 55 | 56 | To limit the search for WebSocket handlers to a directory 57 | under , configure as follows: 58 | 59 | PythonOption mod_pywebsocket.handler_scan 60 | 61 | is useful in saving scan time when 62 | contains many non-WebSocket handler files. 63 | 64 | If you want to allow handlers whose canonical path is not under the root 65 | directory (i.e. symbolic link is in root directory but its target is not), 66 | configure as follows: 67 | 68 | PythonOption mod_pywebsocket.allow_handlers_outside_root_dir On 69 | 70 | Example snippet of httpd.conf: 71 | (mod_pywebsocket is in /websock_lib, WebSocket handlers are in 72 | /websock_handlers, port is 80 for ws, 443 for wss.) 73 | 74 | 75 | PythonPath "sys.path+['/websock_lib']" 76 | PythonOption mod_pywebsocket.handler_root /websock_handlers 77 | PythonHeaderParserHandler mod_pywebsocket.headerparserhandler 78 | 79 | 80 | 2. Tune Apache parameters for serving WebSocket. We'd like to note that at 81 | least TimeOut directive from core features and RequestReadTimeout 82 | directive from mod_reqtimeout should be modified not to kill connections 83 | in only a few seconds of idle time. 84 | 85 | 3. Verify installation. You can use example/console.html to poke the server. 86 | 87 | 88 | Writing WebSocket handlers 89 | ========================== 90 | 91 | When a WebSocket request comes in, the resource name 92 | specified in the handshake is considered as if it is a file path under 93 | and the handler defined in 94 | /_wsh.py is invoked. 95 | 96 | For example, if the resource name is /example/chat, the handler defined in 97 | /example/chat_wsh.py is invoked. 98 | 99 | A WebSocket handler is composed of the following three functions: 100 | 101 | web_socket_do_extra_handshake(request) 102 | web_socket_transfer_data(request) 103 | web_socket_passive_closing_handshake(request) 104 | 105 | where: 106 | request: mod_python request. 107 | 108 | web_socket_do_extra_handshake is called during the handshake after the 109 | headers are successfully parsed and WebSocket properties (ws_location, 110 | ws_origin, and ws_resource) are added to request. A handler 111 | can reject the request by raising an exception. 112 | 113 | A request object has the following properties that you can use during the 114 | extra handshake (web_socket_do_extra_handshake): 115 | - ws_resource 116 | - ws_origin 117 | - ws_version 118 | - ws_location (HyBi 00 only) 119 | - ws_extensions (HyBi 06 and later) 120 | - ws_deflate (HyBi 06 and later) 121 | - ws_protocol 122 | - ws_requested_protocols (HyBi 06 and later) 123 | 124 | The last two are a bit tricky. See the next subsection. 125 | 126 | 127 | Subprotocol Negotiation 128 | ----------------------- 129 | 130 | For HyBi 06 and later, ws_protocol is always set to None when 131 | web_socket_do_extra_handshake is called. If ws_requested_protocols is not 132 | None, you must choose one subprotocol from this list and set it to 133 | ws_protocol. 134 | 135 | For HyBi 00, when web_socket_do_extra_handshake is called, 136 | ws_protocol is set to the value given by the client in 137 | Sec-WebSocket-Protocol header or None if 138 | such header was not found in the opening handshake request. Finish extra 139 | handshake with ws_protocol untouched to accept the request subprotocol. 140 | Then, Sec-WebSocket-Protocol header will be sent to 141 | the client in response with the same value as requested. Raise an exception 142 | in web_socket_do_extra_handshake to reject the requested subprotocol. 143 | 144 | 145 | Data Transfer 146 | ------------- 147 | 148 | web_socket_transfer_data is called after the handshake completed 149 | successfully. A handler can receive/send messages from/to the client 150 | using request. mod_pywebsocket.msgutil module provides utilities 151 | for data transfer. 152 | 153 | You can receive a message by the following statement. 154 | 155 | message = request.ws_stream.receive_message() 156 | 157 | This call blocks until any complete text frame arrives, and the payload data 158 | of the incoming frame will be stored into message. When you're using IETF 159 | HyBi 00 or later protocol, receive_message() will return None on receiving 160 | client-initiated closing handshake. When any error occurs, receive_message() 161 | will raise some exception. 162 | 163 | You can send a message by the following statement. 164 | 165 | request.ws_stream.send_message(message) 166 | 167 | 168 | Closing Connection 169 | ------------------ 170 | 171 | Executing the following statement or just return-ing from 172 | web_socket_transfer_data cause connection close. 173 | 174 | request.ws_stream.close_connection() 175 | 176 | close_connection will wait 177 | for closing handshake acknowledgement coming from the client. When it 178 | couldn't receive a valid acknowledgement, raises an exception. 179 | 180 | web_socket_passive_closing_handshake is called after the server receives 181 | incoming closing frame from the client peer immediately. You can specify 182 | code and reason by return values. They are sent as a outgoing closing frame 183 | from the server. A request object has the following properties that you can 184 | use in web_socket_passive_closing_handshake. 185 | - ws_close_code 186 | - ws_close_reason 187 | 188 | 189 | Threading 190 | --------- 191 | 192 | A WebSocket handler must be thread-safe if the server (Apache or 193 | standalone.py) is configured to use threads. 194 | 195 | 196 | Configuring WebSocket Extension Processors 197 | ------------------------------------------ 198 | 199 | See extensions.py for supported WebSocket extensions. Note that they are 200 | unstable and their APIs are subject to change substantially. 201 | 202 | A request object has these extension processing related attributes. 203 | 204 | - ws_requested_extensions: 205 | 206 | A list of common.ExtensionParameter instances representing extension 207 | parameters received from the client in the client's opening handshake. 208 | You shouldn't modify it manually. 209 | 210 | - ws_extensions: 211 | 212 | A list of common.ExtensionParameter instances representing extension 213 | parameters to send back to the client in the server's opening handshake. 214 | You shouldn't touch it directly. Instead, call methods on extension 215 | processors. 216 | 217 | - ws_extension_processors: 218 | 219 | A list of loaded extension processors. Find the processor for the 220 | extension you want to configure from it, and call its methods. 221 | """ 222 | 223 | 224 | # vi:sts=4 sw=4 et tw=72 225 | -------------------------------------------------------------------------------- /samplerate/lowlevel.py: -------------------------------------------------------------------------------- 1 | """Lowlevel wrappers around libsamplerate. 2 | 3 | The docstrings of the `src_*` functions are adapted from the libsamplerate 4 | header file. 5 | """ 6 | import os as _os 7 | import sys as _sys 8 | from ctypes.util import find_library as _find_library 9 | 10 | import numpy as _np 11 | 12 | # Locate and load libsamplerate 13 | from samplerate._src import ffi 14 | lib_basename = 'libsamplerate' 15 | lib_filename = _find_library('samplerate') 16 | 17 | if _os.environ.get('READTHEDOCS') == 'True': 18 | # Mock minimum C API for Read the Docs 19 | class MockLib(object): 20 | @classmethod 21 | def src_get_version(cls): 22 | return ffi.new('char[]', 'libsamplerate-0.1.9 (c) ...') 23 | lib_filename = 'mock' 24 | _lib = MockLib() 25 | elif lib_filename is None: 26 | if _sys.platform == 'darwin': 27 | lib_filename = '{}.dylib'.format(lib_basename) 28 | elif _sys.platform == 'win32': 29 | from platform import architecture 30 | lib_filename = '{}-{}.dll'.format(lib_basename, architecture()[0]) 31 | else: 32 | raise OSError('{} not found'.format(lib_basename)) 33 | lib_filename = _os.path.join( 34 | _os.path.dirname(_os.path.abspath(__file__)), '_samplerate_data', 35 | lib_filename) 36 | _lib = ffi.dlopen(lib_filename) 37 | else: 38 | _lib = ffi.dlopen(lib_filename) 39 | 40 | 41 | def _check_data(data): 42 | """Check whether `data` is a valid input/output for libsamplerate. 43 | 44 | Returns 45 | ------- 46 | num_frames 47 | Number of frames in `data`. 48 | channels 49 | Number of channels in `data`. 50 | 51 | Raises 52 | ------ 53 | ValueError: If invalid data is supplied. 54 | """ 55 | if not (data.dtype == _np.float32 and data.flags.c_contiguous): 56 | raise ValueError('supplied data must be float32 and C contiguous') 57 | if data.ndim == 2: 58 | num_frames, channels = data.shape 59 | elif data.ndim == 1: 60 | num_frames, channels = data.size, 1 61 | else: 62 | raise ValueError('rank > 2 not supported') 63 | return num_frames, channels 64 | 65 | 66 | def src_strerror(error): 67 | """Convert the error number into a string.""" 68 | return ffi.string(_lib.src_strerror(error)).decode() 69 | 70 | 71 | def src_get_name(converter_type): 72 | """Return the name of the converter given by `converter_type`.""" 73 | return ffi.string(_lib.src_get_name(converter_type)).decode() 74 | 75 | 76 | def src_get_description(converter_type): 77 | """Return the description of the converter given by `converter_type`.""" 78 | return ffi.string(_lib.src_get_description(converter_type)).decode() 79 | 80 | 81 | def src_get_version(): 82 | """Return the version string of libsamplerate.""" 83 | return ffi.string(_lib.src_get_version()).decode() 84 | 85 | 86 | def src_simple(input_data, output_data, ratio, converter_type, channels): 87 | """Perform a single conversion from an input buffer to an output buffer. 88 | 89 | Simple interface for performing a single conversion from input buffer to 90 | output buffer at a fixed conversion ratio. Simple interface does not require 91 | initialisation as it can only operate on a single buffer worth of audio. 92 | """ 93 | input_frames, _ = _check_data(input_data) 94 | output_frames, _ = _check_data(output_data) 95 | data = ffi.new('SRC_DATA*') 96 | data.input_frames = input_frames 97 | data.output_frames = output_frames 98 | data.src_ratio = ratio 99 | data.data_in = ffi.cast('float*', ffi.from_buffer(input_data)) 100 | data.data_out = ffi.cast('float*', ffi.from_buffer(output_data)) 101 | error = _lib.src_simple(data, converter_type, channels) 102 | return error, data.input_frames_used, data.output_frames_gen 103 | 104 | 105 | def src_new(converter_type, channels): 106 | """Initialise a new sample rate converter. 107 | 108 | Parameters 109 | ---------- 110 | converter_type : int 111 | Converter to be used. 112 | channels : int 113 | Number of channels. 114 | 115 | Returns 116 | ------- 117 | state 118 | An anonymous pointer to the internal state of the converter. 119 | error : int 120 | Error code. 121 | """ 122 | error = ffi.new('int*') 123 | state = _lib.src_new(converter_type, channels, error) 124 | return state, error[0] 125 | 126 | 127 | def src_delete(state): 128 | """Release `state`. 129 | 130 | Cleanup all internal allocations. 131 | """ 132 | _lib.src_delete(state) 133 | 134 | 135 | def src_process(state, input_data, output_data, ratio, end_of_input=0): 136 | """Standard processing function. 137 | 138 | Returns non zero on error. 139 | """ 140 | input_frames, _ = _check_data(input_data) 141 | output_frames, _ = _check_data(output_data) 142 | data = ffi.new('SRC_DATA*') 143 | data.input_frames = input_frames 144 | data.output_frames = output_frames 145 | data.src_ratio = ratio 146 | data.data_in = ffi.cast('float*', ffi.from_buffer(input_data)) 147 | data.data_out = ffi.cast('float*', ffi.from_buffer(output_data)) 148 | data.end_of_input = end_of_input 149 | error = _lib.src_process(state, data) 150 | return error, data.input_frames_used, data.output_frames_gen 151 | 152 | 153 | def src_error(state): 154 | """Return an error number.""" 155 | return _lib.src_error(state) if state else None 156 | 157 | 158 | def src_reset(state): 159 | """Reset the internal SRC state. 160 | 161 | Does not modify the quality settings. 162 | Does not free any memory allocations. 163 | Returns non zero on error. 164 | """ 165 | return _lib.src_reset(state) if state else None 166 | 167 | 168 | def src_set_ratio(state, new_ratio): 169 | """Set a new SRC ratio. 170 | 171 | This allows step responses in the conversion ratio. 172 | Returns non zero on error. 173 | """ 174 | return _lib.src_set_ratio(state, new_ratio) if state else None 175 | 176 | 177 | def src_is_valid_ratio(ratio): 178 | """Return `True` if ratio is a valid conversion ratio, `False` otherwise. 179 | """ 180 | return bool(_lib.src_is_valid_ratio(ratio)) 181 | 182 | 183 | @ffi.callback('src_callback_t') 184 | def _src_input_callback(cb_data, data): 185 | """Internal callback function to be used with the callback API. 186 | 187 | Pulls the Python callback function from the handle contained in `cb_data` 188 | and calls it to fetch frames. Frames are converted to the format required by 189 | the API (float, interleaved channels). A reference to these data is kept 190 | internally. 191 | 192 | Returns 193 | ------- 194 | frames : int 195 | The number of frames supplied. 196 | """ 197 | cb_data = ffi.from_handle(cb_data) 198 | ret = cb_data['callback']() 199 | if ret is None: 200 | cb_data['last_input'] = None 201 | return 0 # No frames supplied 202 | input_data = _np.require(ret, requirements='C', dtype=_np.float32) 203 | input_frames, channels = _check_data(input_data) 204 | 205 | # Check whether the correct number of channels is supplied by user. 206 | if cb_data['channels'] != channels: 207 | raise ValueError('Invalid number of channels in callback.') 208 | 209 | # Store a reference of the input data to ensure it is still alive when 210 | # accessed by libsamplerate. 211 | cb_data['last_input'] = input_data 212 | 213 | data[0] = ffi.cast('float*', ffi.from_buffer(input_data)) 214 | return input_frames 215 | 216 | 217 | def src_callback_new(callback, converter_type, channels): 218 | """Initialisation for the callback based API. 219 | 220 | Parameters 221 | ---------- 222 | callback : function 223 | Called whenever new frames are to be read. Must return a NumPy array 224 | of shape (num_frames, channels). 225 | converter_type : int 226 | Converter to be used. 227 | channels : int 228 | Number of channels. 229 | 230 | Returns 231 | ------- 232 | state 233 | An anonymous pointer to the internal state of the converter. 234 | handle 235 | A CFFI handle to the callback data. 236 | error : int 237 | Error code. 238 | 239 | """ 240 | cb_data = {'callback': callback, 'channels': channels} 241 | handle = ffi.new_handle(cb_data) 242 | error = ffi.new('int*') 243 | state = _lib.src_callback_new(_src_input_callback, converter_type, 244 | channels, error, handle) 245 | if state == ffi.NULL: 246 | return None, handle, error[0] 247 | return state, handle, error[0] 248 | 249 | 250 | def src_callback_read(state, ratio, frames, data): 251 | """Read up to `frames` worth of data using the callback API. 252 | 253 | Returns 254 | ------- 255 | frames : int 256 | Number of frames read or -1 on error. 257 | """ 258 | data_ptr = ffi.cast('float*f', ffi.from_buffer(data)) 259 | return _lib.src_callback_read(state, ratio, frames, data_ptr) 260 | 261 | 262 | __libsamplerate_version__ = src_get_version() 263 | if __libsamplerate_version__.startswith(lib_basename): 264 | __libsamplerate_version__ = __libsamplerate_version__[len( 265 | lib_basename) + 1:__libsamplerate_version__.find(' ')] 266 | -------------------------------------------------------------------------------- /mod_pywebsocket/_stream_hixie75.py: -------------------------------------------------------------------------------- 1 | # Copyright 2011, Google Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # * Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following disclaimer 12 | # in the documentation and/or other materials provided with the 13 | # distribution. 14 | # * Neither the name of Google Inc. nor the names of its 15 | # contributors may be used to endorse or promote products derived from 16 | # this software without specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | 31 | """This file provides a class for parsing/building frames of the WebSocket 32 | protocol version HyBi 00 and Hixie 75. 33 | 34 | Specification: 35 | - HyBi 00 http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-00 36 | - Hixie 75 http://tools.ietf.org/html/draft-hixie-thewebsocketprotocol-75 37 | """ 38 | 39 | 40 | from mod_pywebsocket import common 41 | from mod_pywebsocket._stream_base import BadOperationException 42 | from mod_pywebsocket._stream_base import ConnectionTerminatedException 43 | from mod_pywebsocket._stream_base import InvalidFrameException 44 | from mod_pywebsocket._stream_base import StreamBase 45 | from mod_pywebsocket._stream_base import UnsupportedFrameException 46 | from mod_pywebsocket import util 47 | 48 | 49 | class StreamHixie75(StreamBase): 50 | """A class for parsing/building frames of the WebSocket protocol version 51 | HyBi 00 and Hixie 75. 52 | """ 53 | 54 | def __init__(self, request, enable_closing_handshake=False): 55 | """Construct an instance. 56 | 57 | Args: 58 | request: mod_python request. 59 | enable_closing_handshake: to let StreamHixie75 perform closing 60 | handshake as specified in HyBi 00, set 61 | this option to True. 62 | """ 63 | 64 | StreamBase.__init__(self, request) 65 | 66 | self._logger = util.get_class_logger(self) 67 | 68 | self._enable_closing_handshake = enable_closing_handshake 69 | 70 | self._request.client_terminated = False 71 | self._request.server_terminated = False 72 | 73 | def send_message(self, message, end=True, binary=False): 74 | """Send message. 75 | 76 | Args: 77 | message: unicode string to send. 78 | binary: not used in hixie75. 79 | 80 | Raises: 81 | BadOperationException: when called on a server-terminated 82 | connection. 83 | """ 84 | 85 | if not end: 86 | raise BadOperationException( 87 | 'StreamHixie75 doesn\'t support send_message with end=False') 88 | 89 | if binary: 90 | raise BadOperationException( 91 | 'StreamHixie75 doesn\'t support send_message with binary=True') 92 | 93 | if self._request.server_terminated: 94 | raise BadOperationException( 95 | 'Requested send_message after sending out a closing handshake') 96 | 97 | self._write(''.join(['\x00', message.encode('utf-8'), '\xff'])) 98 | 99 | def _read_payload_length_hixie75(self): 100 | """Reads a length header in a Hixie75 version frame with length. 101 | 102 | Raises: 103 | ConnectionTerminatedException: when read returns empty string. 104 | """ 105 | 106 | length = 0 107 | while True: 108 | b_str = self._read(1) 109 | b = ord(b_str) 110 | length = length * 128 + (b & 0x7f) 111 | if (b & 0x80) == 0: 112 | break 113 | return length 114 | 115 | def receive_message(self): 116 | """Receive a WebSocket frame and return its payload an unicode string. 117 | 118 | Returns: 119 | payload unicode string in a WebSocket frame. 120 | 121 | Raises: 122 | ConnectionTerminatedException: when read returns empty 123 | string. 124 | BadOperationException: when called on a client-terminated 125 | connection. 126 | """ 127 | 128 | if self._request.client_terminated: 129 | raise BadOperationException( 130 | 'Requested receive_message after receiving a closing ' 131 | 'handshake') 132 | 133 | while True: 134 | # Read 1 byte. 135 | # mp_conn.read will block if no bytes are available. 136 | # Timeout is controlled by TimeOut directive of Apache. 137 | frame_type_str = self.receive_bytes(1) 138 | frame_type = ord(frame_type_str) 139 | if (frame_type & 0x80) == 0x80: 140 | # The payload length is specified in the frame. 141 | # Read and discard. 142 | length = self._read_payload_length_hixie75() 143 | if length > 0: 144 | _ = self.receive_bytes(length) 145 | # 5.3 3. 12. if /type/ is 0xFF and /length/ is 0, then set the 146 | # /client terminated/ flag and abort these steps. 147 | if not self._enable_closing_handshake: 148 | continue 149 | 150 | if frame_type == 0xFF and length == 0: 151 | self._request.client_terminated = True 152 | 153 | if self._request.server_terminated: 154 | self._logger.debug( 155 | 'Received ack for server-initiated closing ' 156 | 'handshake') 157 | return None 158 | 159 | self._logger.debug( 160 | 'Received client-initiated closing handshake') 161 | 162 | self._send_closing_handshake() 163 | self._logger.debug( 164 | 'Sent ack for client-initiated closing handshake') 165 | return None 166 | else: 167 | # The payload is delimited with \xff. 168 | bytes = self._read_until('\xff') 169 | # The WebSocket protocol section 4.4 specifies that invalid 170 | # characters must be replaced with U+fffd REPLACEMENT 171 | # CHARACTER. 172 | message = bytes.decode('utf-8', 'replace') 173 | if frame_type == 0x00: 174 | return message 175 | # Discard data of other types. 176 | 177 | def _send_closing_handshake(self): 178 | if not self._enable_closing_handshake: 179 | raise BadOperationException( 180 | 'Closing handshake is not supported in Hixie 75 protocol') 181 | 182 | self._request.server_terminated = True 183 | 184 | # 5.3 the server may decide to terminate the WebSocket connection by 185 | # running through the following steps: 186 | # 1. send a 0xFF byte and a 0x00 byte to the client to indicate the 187 | # start of the closing handshake. 188 | self._write('\xff\x00') 189 | 190 | def close_connection(self, unused_code='', unused_reason=''): 191 | """Closes a WebSocket connection. 192 | 193 | Raises: 194 | ConnectionTerminatedException: when closing handshake was 195 | not successfull. 196 | """ 197 | 198 | if self._request.server_terminated: 199 | self._logger.debug( 200 | 'Requested close_connection but server is already terminated') 201 | return 202 | 203 | if not self._enable_closing_handshake: 204 | self._request.server_terminated = True 205 | self._logger.debug('Connection closed') 206 | return 207 | 208 | self._send_closing_handshake() 209 | self._logger.debug('Sent server-initiated closing handshake') 210 | 211 | # TODO(ukai): 2. wait until the /client terminated/ flag has been set, 212 | # or until a server-defined timeout expires. 213 | # 214 | # For now, we expect receiving closing handshake right after sending 215 | # out closing handshake, and if we couldn't receive non-handshake 216 | # frame, we take it as ConnectionTerminatedException. 217 | message = self.receive_message() 218 | if message is not None: 219 | raise ConnectionTerminatedException( 220 | 'Didn\'t receive valid ack for closing handshake') 221 | # TODO: 3. close the WebSocket connection. 222 | # note: mod_python Connection (mp_conn) doesn't have close method. 223 | 224 | def send_ping(self, body): 225 | raise BadOperationException( 226 | 'StreamHixie75 doesn\'t support send_ping') 227 | 228 | 229 | # vi:sts=4 sw=4 et 230 | -------------------------------------------------------------------------------- /kiwi/rigctld.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Emulates a subset of the hamlib rigctld interface, allowing programs 4 | # like fldigi and wsjtx to query and change the frequency and mode of 5 | # a kiwisdr channel. 6 | # 7 | # The subset of commands corresponds to the commands actually used by 8 | # fldigi and wsjtx. 9 | # 10 | # (C) 2020 Rik van Riel 11 | # Released under the GNU General Public License (GPL) version 2 or newer 12 | 13 | import array 14 | import logging 15 | import socket 16 | import struct 17 | import time 18 | import select 19 | 20 | class rigsocket(socket.socket): 21 | def __init__(self, family=-1, type=-1, proto=-1, fileno=None): 22 | super().__init__(family, type, proto, fileno) 23 | self.buffer="" 24 | 25 | def recv_command(self): 26 | buf = self.recv(4096) 27 | try: 28 | self.buffer += buf.decode('ASCII') 29 | except socket.error: 30 | # just ignore non-ASCII 31 | self.buffer = "" 32 | return "" 33 | 34 | if len(self.buffer) == 0: 35 | return "" 36 | 37 | # the buffer contains one or more complete commands 38 | if self.buffer[-1] == "\n": 39 | result = self.buffer 40 | self.buffer = "" 41 | return result 42 | 43 | # nabbed from socket.accept, but returns a rigsock instead 44 | def accept(self): 45 | fd, addr = self._accept() 46 | rigsock = rigsocket(self.family, self.type, self.proto, fileno=fd) 47 | if socket.getdefaulttimeout() is None and self.gettimeout(): 48 | sock.setblocking(True) 49 | return rigsock, addr 50 | 51 | 52 | class Rigctld(object): 53 | def __init__(self, kiwisdrstream=None, port=None, ipaddr=None): 54 | self._kiwisdrstream = kiwisdrstream 55 | self._listenport = port 56 | self._clientsockets = [] 57 | # default localhost on port 6400 58 | if port == None: 59 | port = 6400 60 | if ipaddr == None: 61 | ipaddr = "127.0.0.1" 62 | 63 | try: 64 | socket.inet_aton(ipaddr) 65 | addr = (ipaddr, port) 66 | s = rigsocket(socket.AF_INET, socket.SOCK_STREAM) 67 | except socket.error: 68 | addr = (ipaddr, port, 0, 0) 69 | s = rigsocket(socket.AF_INET6, socket.SOCK_STREAM) 70 | 71 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 72 | s.setblocking(0) 73 | 74 | try: 75 | s.bind(addr) 76 | except socket.error: 77 | logging.error("could not bind to port ", port) 78 | s.close() 79 | raise 80 | 81 | s.listen() 82 | self._serversocket = s 83 | 84 | def close(self): 85 | for s in self._clientsockets: 86 | s.close() 87 | self._clientsockets.remove(s) 88 | self._serversocket.close() 89 | 90 | def _set_modulation(self, command): 91 | # The M (set modulation) command has two parameters: 92 | # M 93 | # mode is mandatory, passband is optional 94 | try: 95 | splitcmd = command.split() 96 | mod = splitcmd[1] 97 | try: 98 | hc = int(splitcmd[2]) 99 | except: 100 | hc = None 101 | freq = self._kiwisdrstream.get_frequency() 102 | # print("calling set_mod", mod, lc, hc, freq) 103 | self._kiwisdrstream.set_mod(mod, None, hc, freq) 104 | return "RPRT 0\n" 105 | except: 106 | return "RPRT -1\n" 107 | 108 | def _set_frequency(self, command): 109 | try: 110 | # hamlib freq is in Hz, kiwisdr in kHz 111 | # format: F 1234.00000 112 | newfreq = command[2:] 113 | freq = float(newfreq) / 1000 114 | except: 115 | try: 116 | # format: F VFOA 1234.00000 117 | newfreq = command[7:] 118 | freq = float(newfreq) / 1000 119 | except: 120 | print("could not decode frequency from %s" % command) 121 | return "RPRT -1\n" 122 | 123 | try: 124 | mod = self._kiwisdrstream.get_mod() 125 | lc = self._kiwisdrstream.get_lowcut() 126 | hc = self._kiwisdrstream.get_highcut() 127 | # print("calling set_mod ", mod, lc, hc, freq) 128 | self._kiwisdrstream.set_mod(mod, lc, hc, freq) 129 | return "RPRT 0\n" 130 | except: 131 | return "RPRT -1\n" 132 | 133 | def _dump_state(self): 134 | # hamlib expects this large table of rig info when connecting 135 | rigctlver = "0\n" 136 | rig_model = "2\n" 137 | itu_region = "0\n" 138 | freqs = "0.000000 30000000.000000" 139 | modes = "0x2f" # AM LSB USB CW (NB)FM see hamlib/rig.h 140 | # no tx power, one VFO per channel, one antenna 141 | rx_range = "{} {} -1 -1 0x1 0x1\n".format(freqs, modes) 142 | rx_end = "0 0 0 0 0 0 0\n" 143 | tx_range = "" 144 | tx_end = "0 0 0 0 0 0 0\n" 145 | tuningsteps = "" 146 | for step in ["1", "100", "1000", "5000", "9000", "10000"]: 147 | tuningsteps += "{} {}\n".format(modes, step) 148 | steps_end = "0 0\n" 149 | ssbfilt = "0xc 2200\n" 150 | cwfilt = "0x2 500\n" 151 | amfilt = "0x1 6000\n" 152 | fmfilt = "0x20 12000\n" 153 | filt_end = "0 0\n" 154 | max_rit = "0\n" 155 | max_xit = "0\n" 156 | max_ifshift = "0\n" 157 | announces = "0\n" 158 | preamp = "\n" 159 | attenuator = "\n" 160 | get_func = "0x0\n" 161 | set_func = "0x0\n" 162 | get_level = "0x0\n" 163 | set_level = "0x0\n" 164 | get_parm = "0x0\n" 165 | set_parm = "0x0\n" 166 | vfo_ops = "vfo_ops=0x0\n" 167 | ptt_type = "ptt_type=0x0\n" 168 | done = "done\n" 169 | 170 | message = rigctlver + rig_model + itu_region 171 | message += rx_range + rx_end + tx_range + tx_end 172 | message += tuningsteps + steps_end 173 | message += ssbfilt + cwfilt + amfilt + fmfilt + filt_end 174 | message += max_rit + max_xit + max_ifshift + announces 175 | message += preamp + attenuator 176 | message += get_func + set_func + get_level + set_level 177 | message += get_parm + set_parm + vfo_ops + ptt_type + done 178 | 179 | return message 180 | 181 | def _handle_command(self, sock, command): 182 | # Remove leading '\' from command 183 | if command.startswith("\\"): 184 | command = command[1:] 185 | 186 | if command.startswith('q'): 187 | # quit 188 | try: 189 | sock.send("RPRT 0\n".encode('ASCII')) 190 | sock.close() 191 | self._clientsockets.remove(sock) 192 | except: 193 | pass 194 | return "RPRT 0\n" 195 | elif command.startswith('chk_vfo'): 196 | return "CHKVFO 0\n" 197 | elif command.startswith('get_lock_mode'): 198 | # unlocked 199 | return "2\n" 200 | elif command.startswith('get_powerstat'): 201 | # always report that power is on 202 | return "1\n" 203 | elif command.startswith('dump_state'): 204 | return self._dump_state() 205 | elif command.startswith('f'): 206 | # get frequency 207 | freqinhz = int(self._kiwisdrstream.get_frequency() * 1000) 208 | return "{}\n".format(freqinhz) 209 | elif command.startswith('F'): 210 | return self._set_frequency(command) 211 | elif command.startswith('m'): 212 | # get modulation 213 | highcut = int(self._kiwisdrstream._highcut) 214 | mod = self._kiwisdrstream.get_mod() 215 | return "{}\n{}\n".format(mod.upper(), highcut) 216 | elif command.startswith('M'): 217 | return self._set_modulation(command) 218 | elif command.startswith('s'): 219 | # get split mode 220 | return "0\nVFOA\n" 221 | elif command.startswith('v'): 222 | return "VFOA\n" 223 | elif command.startswith('V'): 224 | # We cannot switch the VFO on a single kiwisdr channel 225 | return "RPRT 0\n" 226 | 227 | print("Received unknown command: ", command) 228 | return "RPRT 0\n" 229 | 230 | def run(self): 231 | # first accept a new connection, if there is any 232 | try: 233 | sock, addr = self._serversocket.accept() 234 | self._clientsockets.append(sock) 235 | except socket.error: 236 | # no new connections this time 237 | pass 238 | 239 | # check for incoming traffic on existing connections 240 | read_list = self._clientsockets 241 | readable, writable, errored = select.select(read_list, [], [], 0) if len(read_list) > 0 else ([],[],[]) 242 | 243 | for s in errored: 244 | s.close() 245 | self._clientsockets.remove(s) 246 | 247 | for s in readable: 248 | try: 249 | command = s.recv_command() 250 | except socket.error: 251 | continue 252 | 253 | if command != None and len(command) > 0: 254 | reply = "" 255 | # sometimes hamlib programs send multiple commands at once 256 | for line in command.splitlines(): 257 | reply += self._handle_command(s, command) 258 | else: 259 | continue 260 | 261 | try: 262 | reply = reply.encode('ASCII') 263 | s.send(reply) 264 | except socket.error: 265 | continue 266 | # EOF 267 | -------------------------------------------------------------------------------- /mod_pywebsocket/common.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012, Google Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # * Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following disclaimer 12 | # in the documentation and/or other materials provided with the 13 | # distribution. 14 | # * Neither the name of Google Inc. nor the names of its 15 | # contributors may be used to endorse or promote products derived from 16 | # this software without specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | 31 | """This file must not depend on any module specific to the WebSocket protocol. 32 | """ 33 | 34 | 35 | from mod_pywebsocket import http_header_util 36 | 37 | 38 | # Additional log level definitions. 39 | LOGLEVEL_FINE = 9 40 | 41 | # Constants indicating WebSocket protocol version. 42 | VERSION_HIXIE75 = -1 43 | VERSION_HYBI00 = 0 44 | VERSION_HYBI01 = 1 45 | VERSION_HYBI02 = 2 46 | VERSION_HYBI03 = 2 47 | VERSION_HYBI04 = 4 48 | VERSION_HYBI05 = 5 49 | VERSION_HYBI06 = 6 50 | VERSION_HYBI07 = 7 51 | VERSION_HYBI08 = 8 52 | VERSION_HYBI09 = 8 53 | VERSION_HYBI10 = 8 54 | VERSION_HYBI11 = 8 55 | VERSION_HYBI12 = 8 56 | VERSION_HYBI13 = 13 57 | VERSION_HYBI14 = 13 58 | VERSION_HYBI15 = 13 59 | VERSION_HYBI16 = 13 60 | VERSION_HYBI17 = 13 61 | 62 | # Constants indicating WebSocket protocol latest version. 63 | VERSION_HYBI_LATEST = VERSION_HYBI13 64 | 65 | # Port numbers 66 | DEFAULT_WEB_SOCKET_PORT = 80 67 | DEFAULT_WEB_SOCKET_SECURE_PORT = 443 68 | 69 | # Schemes 70 | WEB_SOCKET_SCHEME = 'ws' 71 | WEB_SOCKET_SECURE_SCHEME = 'wss' 72 | 73 | # Frame opcodes defined in the spec. 74 | OPCODE_CONTINUATION = 0x0 75 | OPCODE_TEXT = 0x1 76 | OPCODE_BINARY = 0x2 77 | OPCODE_CLOSE = 0x8 78 | OPCODE_PING = 0x9 79 | OPCODE_PONG = 0xa 80 | 81 | # UUIDs used by HyBi 04 and later opening handshake and frame masking. 82 | WEBSOCKET_ACCEPT_UUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' 83 | 84 | # Opening handshake header names and expected values. 85 | UPGRADE_HEADER = 'Upgrade' 86 | WEBSOCKET_UPGRADE_TYPE = 'websocket' 87 | WEBSOCKET_UPGRADE_TYPE_HIXIE75 = 'WebSocket' 88 | CONNECTION_HEADER = 'Connection' 89 | UPGRADE_CONNECTION_TYPE = 'Upgrade' 90 | HOST_HEADER = 'Host' 91 | ORIGIN_HEADER = 'Origin' 92 | SEC_WEBSOCKET_ORIGIN_HEADER = 'Sec-WebSocket-Origin' 93 | SEC_WEBSOCKET_KEY_HEADER = 'Sec-WebSocket-Key' 94 | SEC_WEBSOCKET_ACCEPT_HEADER = 'Sec-WebSocket-Accept' 95 | SEC_WEBSOCKET_VERSION_HEADER = 'Sec-WebSocket-Version' 96 | SEC_WEBSOCKET_PROTOCOL_HEADER = 'Sec-WebSocket-Protocol' 97 | SEC_WEBSOCKET_EXTENSIONS_HEADER = 'Sec-WebSocket-Extensions' 98 | SEC_WEBSOCKET_DRAFT_HEADER = 'Sec-WebSocket-Draft' 99 | SEC_WEBSOCKET_KEY1_HEADER = 'Sec-WebSocket-Key1' 100 | SEC_WEBSOCKET_KEY2_HEADER = 'Sec-WebSocket-Key2' 101 | SEC_WEBSOCKET_LOCATION_HEADER = 'Sec-WebSocket-Location' 102 | 103 | # Extensions 104 | DEFLATE_FRAME_EXTENSION = 'deflate-frame' 105 | PERMESSAGE_DEFLATE_EXTENSION = 'permessage-deflate' 106 | X_WEBKIT_DEFLATE_FRAME_EXTENSION = 'x-webkit-deflate-frame' 107 | MUX_EXTENSION = 'mux_DO_NOT_USE' 108 | 109 | # Status codes 110 | # Code STATUS_NO_STATUS_RECEIVED, STATUS_ABNORMAL_CLOSURE, and 111 | # STATUS_TLS_HANDSHAKE are pseudo codes to indicate specific error cases. 112 | # Could not be used for codes in actual closing frames. 113 | # Application level errors must use codes in the range 114 | # STATUS_USER_REGISTERED_BASE to STATUS_USER_PRIVATE_MAX. The codes in the 115 | # range STATUS_USER_REGISTERED_BASE to STATUS_USER_REGISTERED_MAX are managed 116 | # by IANA. Usually application must define user protocol level errors in the 117 | # range STATUS_USER_PRIVATE_BASE to STATUS_USER_PRIVATE_MAX. 118 | STATUS_NORMAL_CLOSURE = 1000 119 | STATUS_GOING_AWAY = 1001 120 | STATUS_PROTOCOL_ERROR = 1002 121 | STATUS_UNSUPPORTED_DATA = 1003 122 | STATUS_NO_STATUS_RECEIVED = 1005 123 | STATUS_ABNORMAL_CLOSURE = 1006 124 | STATUS_INVALID_FRAME_PAYLOAD_DATA = 1007 125 | STATUS_POLICY_VIOLATION = 1008 126 | STATUS_MESSAGE_TOO_BIG = 1009 127 | STATUS_MANDATORY_EXTENSION = 1010 128 | STATUS_INTERNAL_ENDPOINT_ERROR = 1011 129 | STATUS_TLS_HANDSHAKE = 1015 130 | STATUS_USER_REGISTERED_BASE = 3000 131 | STATUS_USER_REGISTERED_MAX = 3999 132 | STATUS_USER_PRIVATE_BASE = 4000 133 | STATUS_USER_PRIVATE_MAX = 4999 134 | # Following definitions are aliases to keep compatibility. Applications must 135 | # not use these obsoleted definitions anymore. 136 | STATUS_NORMAL = STATUS_NORMAL_CLOSURE 137 | STATUS_UNSUPPORTED = STATUS_UNSUPPORTED_DATA 138 | STATUS_CODE_NOT_AVAILABLE = STATUS_NO_STATUS_RECEIVED 139 | STATUS_ABNORMAL_CLOSE = STATUS_ABNORMAL_CLOSURE 140 | STATUS_INVALID_FRAME_PAYLOAD = STATUS_INVALID_FRAME_PAYLOAD_DATA 141 | STATUS_MANDATORY_EXT = STATUS_MANDATORY_EXTENSION 142 | 143 | # HTTP status codes 144 | HTTP_STATUS_BAD_REQUEST = 400 145 | HTTP_STATUS_FORBIDDEN = 403 146 | HTTP_STATUS_NOT_FOUND = 404 147 | 148 | 149 | def is_control_opcode(opcode): 150 | return (opcode >> 3) == 1 151 | 152 | 153 | class ExtensionParameter(object): 154 | 155 | """This is exchanged on extension negotiation in opening handshake.""" 156 | 157 | def __init__(self, name): 158 | self._name = name 159 | # TODO(tyoshino): Change the data structure to more efficient one such 160 | # as dict when the spec changes to say like 161 | # - Parameter names must be unique 162 | # - The order of parameters is not significant 163 | self._parameters = [] 164 | 165 | def name(self): 166 | """Return the extension name.""" 167 | return self._name 168 | 169 | def add_parameter(self, name, value): 170 | """Add a parameter.""" 171 | self._parameters.append((name, value)) 172 | 173 | def get_parameters(self): 174 | """Return the parameters.""" 175 | return self._parameters 176 | 177 | def get_parameter_names(self): 178 | """Return the names of the parameters.""" 179 | return [name for name, unused_value in self._parameters] 180 | 181 | def has_parameter(self, name): 182 | """Test if a parameter exists.""" 183 | for param_name, param_value in self._parameters: 184 | if param_name == name: 185 | return True 186 | return False 187 | 188 | def get_parameter_value(self, name): 189 | """Get the value of a specific parameter.""" 190 | for param_name, param_value in self._parameters: 191 | if param_name == name: 192 | return param_value 193 | 194 | 195 | class ExtensionParsingException(Exception): 196 | 197 | """Exception to handle errors in extension parsing.""" 198 | 199 | def __init__(self, name): 200 | super(ExtensionParsingException, self).__init__(name) 201 | 202 | 203 | def _parse_extension_param(state, definition): 204 | param_name = http_header_util.consume_token(state) 205 | 206 | if param_name is None: 207 | raise ExtensionParsingException('No valid parameter name found') 208 | 209 | http_header_util.consume_lwses(state) 210 | 211 | if not http_header_util.consume_string(state, '='): 212 | definition.add_parameter(param_name, None) 213 | return 214 | 215 | http_header_util.consume_lwses(state) 216 | 217 | # TODO(tyoshino): Add code to validate that parsed param_value is token 218 | param_value = http_header_util.consume_token_or_quoted_string(state) 219 | if param_value is None: 220 | raise ExtensionParsingException( 221 | 'No valid parameter value found on the right-hand side of ' 222 | 'parameter %r' % param_name) 223 | 224 | definition.add_parameter(param_name, param_value) 225 | 226 | 227 | def _parse_extension(state): 228 | extension_token = http_header_util.consume_token(state) 229 | if extension_token is None: 230 | return None 231 | 232 | extension = ExtensionParameter(extension_token) 233 | 234 | while True: 235 | http_header_util.consume_lwses(state) 236 | 237 | if not http_header_util.consume_string(state, ';'): 238 | break 239 | 240 | http_header_util.consume_lwses(state) 241 | 242 | try: 243 | _parse_extension_param(state, extension) 244 | except ExtensionParsingException as e: 245 | raise ExtensionParsingException( 246 | 'Failed to parse parameter for %r (%r)' % 247 | (extension_token, e)) 248 | 249 | return extension 250 | 251 | 252 | def parse_extensions(data): 253 | """Parse Sec-WebSocket-Extensions header value. 254 | 255 | Returns a list of ExtensionParameter objects. 256 | Leading LWSes must be trimmed. 257 | """ 258 | state = http_header_util.ParsingState(data) 259 | 260 | extension_list = [] 261 | while True: 262 | extension = _parse_extension(state) 263 | if extension is not None: 264 | extension_list.append(extension) 265 | 266 | http_header_util.consume_lwses(state) 267 | 268 | if http_header_util.peek(state) is None: 269 | break 270 | 271 | if not http_header_util.consume_string(state, ','): 272 | raise ExtensionParsingException( 273 | 'Failed to parse Sec-WebSocket-Extensions header: ' 274 | 'Expected a comma but found %r' % 275 | http_header_util.peek(state)) 276 | 277 | http_header_util.consume_lwses(state) 278 | 279 | if len(extension_list) == 0: 280 | raise ExtensionParsingException( 281 | 'No valid extension entry found') 282 | 283 | return extension_list 284 | 285 | 286 | def format_extension(extension): 287 | """Format an ExtensionParameter object.""" 288 | formatted_params = [extension.name()] 289 | for param_name, param_value in extension.get_parameters(): 290 | if param_value is None: 291 | formatted_params.append(param_name) 292 | else: 293 | quoted_value = http_header_util.quote_if_necessary(param_value) 294 | formatted_params.append('%s=%s' % (param_name, quoted_value)) 295 | return '; '.join(formatted_params) 296 | 297 | 298 | def format_extensions(extension_list): 299 | """Format a list of ExtensionParameter objects.""" 300 | formatted_extension_list = [] 301 | for extension in extension_list: 302 | formatted_extension_list.append(format_extension(extension)) 303 | return ', '.join(formatted_extension_list) 304 | 305 | 306 | # vi:sts=4 sw=4 et 307 | -------------------------------------------------------------------------------- /kiwiwfrecorder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | ## -*- python -*- 3 | 4 | ## to be merged into kiwirecorder.py 5 | 6 | import gc, logging, os, time, threading, os 7 | import numpy as np 8 | from traceback import print_exc 9 | from kiwi import KiwiSDRStream, KiwiWorker 10 | from optparse import OptionParser 11 | try: 12 | from Queue import Queue,Empty ## python2 13 | except ImportError: 14 | from queue import Queue,Empty ## python3 15 | 16 | 17 | class KiwiSoundRecorder(KiwiSDRStream): 18 | def __init__(self, options, q): 19 | super(KiwiSoundRecorder, self).__init__() 20 | self._options = options 21 | self._queue = q 22 | self._type = 'SND' 23 | self._freq = options.frequency 24 | self._num_skip = 2 ## skip data at the start of the WS stream with seq < 2 25 | 26 | def _setup_rx_params(self): 27 | self.set_name(self._options.user) 28 | mod = 'iq' 29 | lp_cut = -1000 30 | hp_cut = +1000 31 | self.set_mod(mod, lp_cut, hp_cut, self._freq) 32 | self.set_agc(on=True) 33 | 34 | def _process_iq_samples(self, seq, samples, rssi, gps, fmt): 35 | if self._num_skip != 0: 36 | if seq < 2: 37 | print('IQ: skipping seq=', seq) 38 | self._num_skip -= 1 39 | return 40 | else: 41 | self._num_skip = 0 42 | gps_time = gps['gpssec'] + 1e-9*gps['gpsnsec'] 43 | self._queue.put([seq, gps_time]) 44 | 45 | class KiwiWaterfallRecorder(KiwiSDRStream): 46 | def __init__(self, options, q): 47 | super(KiwiWaterfallRecorder, self).__init__() 48 | self._options = options 49 | self._queue = q 50 | self._type = 'W/F' 51 | self._freq = options.frequency 52 | self._zoom = options.zoom 53 | self._freq_bins = None 54 | self._num_channels = 2 55 | self._num_skip = 2 ## skip data at the start of the WS stream with seq < 2 56 | 57 | def _setup_rx_params(self): 58 | self._set_zoom_cf(self._zoom, self._freq) 59 | self._set_maxdb_mindb(-10, -110) # needed, but values don't matter 60 | self._freq_bins = self._freq + (0.5+np.arange(self.WF_BINS))/self.WF_BINS * self.zoom_to_span(self._options.zoom) 61 | #self._set_wf_comp(True) 62 | self._set_wf_comp(False) 63 | self._set_wf_speed(1) # 1 Hz update 64 | self.set_name(self._options.user) 65 | 66 | def _process_waterfall_samples(self, seq, samples): 67 | if self._num_skip != 0: 68 | if seq < 2: 69 | self._num_skip -= 1 70 | return 71 | else: 72 | self._num_skip = 0 73 | logging.info('process_wf_samples: seq= %5d %s' % (seq, samples)) 74 | self._queue.put({'seq': seq, 75 | 'freq_bins': self._freq_bins, 76 | 'wf_samples': samples}) 77 | 78 | class Consumer(threading.Thread): 79 | """ Combines WF data with precise GNSS timestamps from the SND stream """ 80 | def __init__(self, group=None, target=None, name=None, args=(), kwargs=None): 81 | super(Consumer, self).__init__(group=group, target=target, name=name) 82 | self._options, self._snd_queue, self._wf_queue, self._run_event = args 83 | self._event = threading.Event() 84 | self._store = dict() 85 | self._wf_data = None 86 | self._start_ts = None 87 | 88 | def run(self): 89 | while self._run_event.is_set(): 90 | try: 91 | self.proc() 92 | except Exception: 93 | print_exc() 94 | break 95 | self._run_event.clear() # tell all other threads to stop 96 | 97 | def proc(self): 98 | if self._wf_data is None: 99 | try: 100 | self._wf_data = self._wf_queue.get(timeout=1) 101 | except Empty: 102 | return 103 | 104 | self.update_store() 105 | if self._wf_data['seq'] in self._store: 106 | now = time.gmtime() 107 | if self._start_ts is None: 108 | self._start_ts = now 109 | with open(self._get_output_filename(), 'wb') as f: 110 | np.save(f, self._wf_data['freq_bins']) 111 | 112 | ## GNSS timestamp for seq obtained from the SND WS stream 113 | ts = self._store.pop(self._wf_data['seq']) 114 | logging.info('found seq %d %f %d (%d|%d,%d)' 115 | % (self._wf_data['seq'], ts, len(self._wf_data['wf_samples']), 116 | len(self._store), self._wf_queue.qsize(), self._snd_queue.qsize())) 117 | with open(self._get_output_filename(), 'ab') as f: 118 | np.save(f, np.array((ts, self._wf_data['wf_samples']), 119 | dtype=[('ts', np.float64), ('wf', ('B', 1024))])) 120 | 121 | self.prune_store(ts) 122 | self._wf_data = None 123 | else: 124 | time.sleep(0.1) 125 | 126 | def _get_output_filename(self): 127 | station = '' if self._options.station is None else '_'+ self._options.station 128 | return '%s_%d-%d%s.npy' % (time.strftime('%Y%m%dT%H%M%SZ', self._start_ts), 129 | round(self._wf_data['freq_bins'][ 0] * 1000), 130 | round(self._wf_data['freq_bins'][-1] * 1000), 131 | station) 132 | 133 | def update_store(self): 134 | """ put all available timestamps from the SND stream into the store """ 135 | while True: 136 | try: 137 | seq,ts = self._snd_queue.get(timeout=0.01) 138 | self._store[seq] = ts 139 | except Empty: 140 | break 141 | except Exception: 142 | print_exc() 143 | break 144 | 145 | def prune_store(self, ts): 146 | """ remove all timestamps before 'ts' """ 147 | for x in list(self._store.items()): 148 | k,v = x 149 | if v < ts: 150 | self._store.pop(k) 151 | 152 | def join_threads(threads): 153 | [t._event.set() for t in threads] 154 | [t.join() for t in threading.enumerate() if t is not threading.current_thread()] 155 | 156 | def main(): 157 | parser = OptionParser() 158 | parser.add_option('-s', '--server-host', 159 | dest='server_host', type='string', 160 | default='localhost', help='Server host') 161 | parser.add_option('-p', '--server-port', 162 | dest='server_port', type='string', 163 | default="8073", help='Server port, default 8073') 164 | parser.add_option('--pw', '--password', 165 | dest='password', type='string', default='', 166 | help='Kiwi login password') 167 | parser.add_option('--connect-timeout', '--connect_timeout', 168 | dest='connect_timeout', 169 | type='int', default=15, 170 | help='Retry timeout(sec) connecting to host') 171 | parser.add_option('--connect-retries', '--connect_retries', 172 | dest='connect_retries', 173 | type='int', default=0, 174 | help='Number of retries when connecting to host (retries forever by default)') 175 | parser.add_option('--busy-timeout', '--busy_timeout', 176 | dest='busy_timeout', 177 | type='int', default=15, 178 | help='Retry timeout(sec) when host is busy') 179 | parser.add_option('--busy-retries', '--busy_retries', 180 | dest='busy_retries', 181 | type='int', default=0, 182 | help='Number of retries when host is busy (retries forever by default)') 183 | parser.add_option('-k', '--socket-timeout', '--socket_timeout', 184 | dest='socket_timeout', type='int', default=10, 185 | help='Timeout(sec) for sockets') 186 | parser.add_option('--tlimit-pw', '--tlimit-password', 187 | dest='tlimit_password', type='string', default='', 188 | help='Connect time limit exemption password (if required)') 189 | parser.add_option('-u', '--user', 190 | dest='user', type='string', default='kiwirecorder.py', 191 | help='Kiwi connection user name') 192 | parser.add_option('-f', '--freq', 193 | dest='frequency', 194 | type='float', default=1000, 195 | help='Frequency to tune to, in kHz') 196 | parser.add_option('-z', '--zoom', 197 | dest='zoom', type='int', default=0, 198 | help='Zoom level 0-14') 199 | parser.add_option('--station', 200 | dest='station', 201 | type='string', default=None, 202 | help='Station ID to be appended to filename',) 203 | parser.add_option('--log', '--log-level', '--log_level', type='choice', 204 | dest='log_level', default='warn', 205 | choices=['debug', 'info', 'warn', 'error', 'critical'], 206 | help='Log level: debug|info|warn(default)|error|critical') 207 | (opt, unused_args) = parser.parse_args() 208 | 209 | ## clean up OptionParser which has cyclic references 210 | parser.destroy() 211 | 212 | opt.rigctl_enabled = False 213 | opt.is_kiwi_tdoa = False 214 | opt.tlimit = None 215 | opt.no_api = True 216 | opt.nolocal = False 217 | opt.S_meter = -1 218 | opt.ADC_OV = None 219 | opt.freq_pbc = None 220 | opt.wf_cal = None 221 | opt.netcat = False 222 | opt.wideband = False 223 | 224 | FORMAT = '%(asctime)-15s pid %(process)5d %(message)s' 225 | logging.basicConfig(level=logging.getLevelName(opt.log_level.upper()), format=FORMAT) 226 | #if opt.log_level.upper() == 'DEBUG': 227 | # gc.set_debug(gc.DEBUG_SAVEALL | gc.DEBUG_LEAK | gc.DEBUG_UNCOLLECTABLE) 228 | 229 | run_event = threading.Event() 230 | run_event.set() 231 | 232 | snd_queue,wf_queue = [Queue(),Queue()] 233 | snd_recorder = KiwiWorker(args=(KiwiSoundRecorder (opt, snd_queue), opt, False, run_event)) 234 | wf_recorder = KiwiWorker(args=(KiwiWaterfallRecorder(opt, wf_queue), opt, False, run_event)) 235 | consumer = Consumer(args=(opt,snd_queue,wf_queue,run_event)) 236 | 237 | threads = [snd_recorder, wf_recorder, consumer] 238 | 239 | try: 240 | opt.start_time = time.time() 241 | opt.ws_timestamp = int(time.time() + os.getpid()) & 0xffffffff 242 | opt.idx = 0 243 | snd_recorder.start() 244 | 245 | opt.start_time = time.time() 246 | opt.ws_timestamp = int(time.time() + os.getpid()+1) & 0xffffffff 247 | opt.idx = 0 248 | wf_recorder.start() 249 | 250 | consumer.start() 251 | 252 | while run_event.is_set(): 253 | time.sleep(.5) 254 | 255 | except KeyboardInterrupt: 256 | run_event.clear() 257 | join_threads(threads) 258 | print("KeyboardInterrupt: threads successfully closed") 259 | except Exception: 260 | print_exc() 261 | run_event.clear() 262 | join_threads(threads) 263 | print("Exception: threads successfully closed") 264 | 265 | #logging.debug('gc %s' % gc.garbage) 266 | 267 | if __name__ == '__main__': 268 | #import faulthandler 269 | #faulthandler.enable() 270 | main() 271 | # EOF 272 | -------------------------------------------------------------------------------- /samplerate/converters.py: -------------------------------------------------------------------------------- 1 | """Converters 2 | 3 | """ 4 | from __future__ import print_function, division 5 | from enum import Enum 6 | import numpy as np 7 | 8 | 9 | class ConverterType(Enum): 10 | """Enum of samplerate converter types. 11 | 12 | Pass any of the members, or their string or value representation, as 13 | ``converter_type`` in the resamplers. 14 | """ 15 | sinc_best = 0 16 | sinc_medium = 1 17 | sinc_fastest = 2 18 | zero_order_hold = 3 19 | linear = 4 20 | 21 | 22 | def _get_converter_type(identifier): 23 | """Return the converter type for `identifier`.""" 24 | if isinstance(identifier, str): 25 | return ConverterType[identifier] 26 | if isinstance(identifier, ConverterType): 27 | return identifier 28 | return ConverterType(identifier) 29 | 30 | 31 | def resample(input_data, ratio, converter_type='sinc_best', verbose=False): 32 | """Resample the signal in `input_data` at once. 33 | 34 | Parameters 35 | ---------- 36 | input_data : ndarray 37 | Input data. A single channel is provided as a 1D array of `num_frames` length. 38 | Input data with several channels is represented as a 2D array of shape 39 | (`num_frames`, `num_channels`). For use with `libsamplerate`, `input_data` 40 | is converted to 32-bit float and C (row-major) memory order. 41 | ratio : float 42 | Conversion ratio = output sample rate / input sample rate. 43 | converter_type : ConverterType, str, or int 44 | Sample rate converter. 45 | verbose : bool 46 | If `True`, print additional information about the conversion. 47 | 48 | Returns 49 | ------- 50 | output_data : ndarray 51 | Resampled input data. 52 | 53 | Note 54 | ---- 55 | If samples are to be processed in chunks, `Resampler` and 56 | `CallbackResampler` will provide better results and allow for variable 57 | conversion ratios. 58 | """ 59 | from samplerate.lowlevel import src_simple 60 | from samplerate.exceptions import ResamplingError 61 | 62 | input_data = np.require(input_data, requirements='C', dtype=np.float32) 63 | if input_data.ndim == 2: 64 | num_frames, channels = input_data.shape 65 | output_shape = (int(num_frames * ratio), channels) 66 | elif input_data.ndim == 1: 67 | num_frames, channels = input_data.size, 1 68 | output_shape = (int(num_frames * ratio), ) 69 | else: 70 | raise ValueError('rank > 2 not supported') 71 | 72 | output_data = np.empty(output_shape, dtype=np.float32) 73 | converter_type = _get_converter_type(converter_type) 74 | 75 | (error, input_frames_used, output_frames_gen) \ 76 | = src_simple(input_data, output_data, ratio, 77 | converter_type.value, channels) 78 | 79 | if error != 0: 80 | raise ResamplingError(error) 81 | 82 | if verbose: 83 | info = ('samplerate info:\n' 84 | '{} input frames used\n' 85 | '{} output frames generated\n' 86 | .format(input_frames_used, output_frames_gen)) 87 | print(info) 88 | 89 | return (output_data[:output_frames_gen, :] 90 | if channels > 1 else output_data[:output_frames_gen]) 91 | 92 | 93 | class Resampler(object): 94 | """Resampler. 95 | 96 | Parameters 97 | ---------- 98 | converter_type : ConverterType, str, or int 99 | Sample rate converter. 100 | num_channels : int 101 | Number of channels. 102 | """ 103 | def __init__(self, converter_type='sinc_fastest', channels=1): 104 | from samplerate.lowlevel import ffi, src_new, src_delete 105 | from samplerate.exceptions import ResamplingError 106 | 107 | converter_type = _get_converter_type(converter_type) 108 | state, error = src_new(converter_type.value, channels) 109 | self._state = ffi.gc(state, src_delete) 110 | self._converter_type = converter_type 111 | self._channels = channels 112 | if channels == 1: 113 | self._frame_buffer = np.zeros(0, dtype=np.float32) 114 | else: 115 | self._frame_buffer = np.zeros((0,channels), dtype=np.float32) 116 | if error != 0: 117 | raise ResamplingError(error) 118 | 119 | @property 120 | def kiwi_samplerate(self): 121 | return True 122 | 123 | @property 124 | def converter_type(self): 125 | """Converter type.""" 126 | return self._converter_type 127 | 128 | @property 129 | def channels(self): 130 | """Number of channels.""" 131 | return self._channels 132 | 133 | def reset(self): 134 | """Reset internal state.""" 135 | from samplerate.lowlevel import src_reset 136 | if self._channels == 1: 137 | self._frame_buffer = np.empty(0, dtype=np.float32) 138 | else: 139 | self._frame_buffer = np.empty((0,channels), dtype=np.float32) 140 | return src_reset(self._state) 141 | 142 | def set_ratio(self, new_ratio): 143 | """Set a new conversion ratio immediately.""" 144 | from samplerate.lowlevel import src_set_ratio 145 | return src_set_ratio(self._state, new_ratio) 146 | 147 | def process(self, input_data, ratio, end_of_input=False, verbose=False): 148 | """Resample the signal in `input_data`. 149 | 150 | Parameters 151 | ---------- 152 | input_data : ndarray 153 | Input data. A single channel is provided as a 1D array of `num_frames` length. 154 | Input data with several channels is represented as a 2D array of shape 155 | (`num_frames`, `num_channels`). For use with `libsamplerate`, `input_data` 156 | is converted to 32-bit float and C (row-major) memory order. 157 | ratio : float 158 | Conversion ratio = output sample rate / input sample rate. 159 | end_of_input : int 160 | Set to `True` if no more data is available, or to `False` otherwise. 161 | verbose : bool 162 | If `True`, print additional information about the conversion. 163 | 164 | Returns 165 | ------- 166 | output_data : ndarray 167 | Resampled input data. 168 | """ 169 | from samplerate.lowlevel import src_process 170 | from samplerate.exceptions import ResamplingError 171 | 172 | input_data = np.require(input_data, requirements='C', dtype=np.float32) 173 | self._frame_buffer = np.append(self._frame_buffer, input_data, axis=0) 174 | if input_data.ndim == 2: 175 | num_frames, channels = self._frame_buffer.shape 176 | output_shape = (int(num_frames * ratio), channels) 177 | elif input_data.ndim == 1: 178 | num_frames, channels = self._frame_buffer.shape[0], 1 179 | output_shape = (int(num_frames * ratio), ) 180 | else: 181 | raise ValueError('rank > 2 not supported') 182 | 183 | if channels != self._channels: 184 | raise ValueError('Invalid number of channels in input data.') 185 | 186 | output_data = np.empty(output_shape, dtype=np.float32) 187 | 188 | (error, input_frames_used, output_frames_gen) = src_process( 189 | self._state, self._frame_buffer, output_data, ratio, end_of_input) 190 | 191 | if error != 0: 192 | raise ResamplingError(error) 193 | 194 | if channels > 1: 195 | self._frame_buffer = self._frame_buffer[input_frames_used:, :] 196 | else: 197 | self._frame_buffer = self._frame_buffer[input_frames_used:] 198 | 199 | if verbose: 200 | info = ('samplerate info:\n' 201 | '{} input frames used\n' 202 | '{} output frames generated\n' 203 | '{} buffer size\n' 204 | .format(input_frames_used, output_frames_gen, self._frame_buffer.shape[0])) 205 | print(info) 206 | 207 | return (output_data[:output_frames_gen, :] 208 | if channels > 1 else output_data[:output_frames_gen]) 209 | 210 | 211 | class CallbackResampler(object): 212 | """CallbackResampler. 213 | 214 | Parameters 215 | ---------- 216 | callback : function 217 | Function that returns new frames on each call, or `None` otherwise. 218 | A single channel is provided as a 1D array of `num_frames` length. 219 | Input data with several channels is represented as a 2D array of shape 220 | (`num_frames`, `num_channels`). For use with `libsamplerate`, `input_data` 221 | is converted to 32-bit float and C (row-major) memory order. 222 | ratio : float 223 | Conversion ratio = output sample rate / input sample rate. 224 | converter_type : ConverterType, str, or int 225 | Sample rate converter. 226 | channels : int 227 | Number of channels. 228 | """ 229 | def __init__(self, callback, ratio, converter_type='sinc_fastest', 230 | channels=1): 231 | if channels < 1: 232 | raise ValueError('Invalid number of channels.') 233 | self._callback = callback 234 | self._ratio = ratio 235 | self._converter_type = _get_converter_type(converter_type) 236 | self._channels = channels 237 | self._state = None 238 | self._handle = None 239 | self._create() 240 | 241 | def _create(self): 242 | """Create new callback resampler.""" 243 | from samplerate.lowlevel import ffi, src_callback_new, src_delete 244 | from samplerate.exceptions import ResamplingError 245 | 246 | state, handle, error = src_callback_new( 247 | self._callback, self._converter_type.value, self._channels) 248 | if error != 0: 249 | raise ResamplingError(error) 250 | self._state = ffi.gc(state, src_delete) 251 | self._handle = handle 252 | 253 | def _destroy(self): 254 | """Destroy resampler state.""" 255 | if self._state: 256 | self._state = None 257 | self._handle = None 258 | 259 | def __enter__(self): 260 | return self 261 | 262 | def __exit__(self, *args): 263 | self._destroy() 264 | 265 | def set_starting_ratio(self, ratio): 266 | """ Set the starting conversion ratio for the next `read` call. """ 267 | from samplerate.lowlevel import src_set_ratio 268 | if self._state is None: 269 | self._create() 270 | src_set_ratio(self._state, ratio) 271 | self.ratio = ratio 272 | 273 | def reset(self): 274 | """Reset state.""" 275 | from samplerate.lowlevel import src_reset 276 | if self._state is None: 277 | self._create() 278 | src_reset(self._state) 279 | 280 | @property 281 | def ratio(self): 282 | """Conversion ratio = output sample rate / input sample rate.""" 283 | return self._ratio 284 | 285 | @ratio.setter 286 | def ratio(self, ratio): 287 | self._ratio = ratio 288 | 289 | def read(self, num_frames): 290 | """Read a number of frames from the resampler. 291 | 292 | Parameters 293 | ---------- 294 | num_frames : int 295 | Number of frames to read. 296 | 297 | Returns 298 | ------- 299 | output_data : ndarray 300 | Resampled frames as a (`num_output_frames`, `num_channels`) or 301 | (`num_output_frames`,) array. Note that this may return fewer frames 302 | than requested, for example when no more input is available. 303 | """ 304 | from samplerate.lowlevel import src_callback_read, src_error 305 | from samplerate.exceptions import ResamplingError 306 | 307 | if self._state is None: 308 | self._create() 309 | if self._channels > 1: 310 | output_shape = (num_frames, self._channels) 311 | elif self._channels == 1: 312 | output_shape = (num_frames, ) 313 | output_data = np.empty(output_shape, dtype=np.float32) 314 | 315 | ret = src_callback_read(self._state, self._ratio, num_frames, 316 | output_data) 317 | if ret == 0: 318 | error = src_error(self._state) 319 | if error: 320 | raise ResamplingError(error) 321 | 322 | return (output_data[:ret, :] 323 | if self._channels > 1 else output_data[:ret]) 324 | -------------------------------------------------------------------------------- /mod_pywebsocket/util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2011, Google Inc. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # * Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above 11 | # copyright notice, this list of conditions and the following disclaimer 12 | # in the documentation and/or other materials provided with the 13 | # distribution. 14 | # * Neither the name of Google Inc. nor the names of its 15 | # contributors may be used to endorse or promote products derived from 16 | # this software without specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | 31 | """WebSocket utilities.""" 32 | 33 | 34 | import array 35 | import errno 36 | 37 | # Import hash classes from a module available and recommended for each Python 38 | # version and re-export those symbol. Use sha and md5 module in Python 2.4, and 39 | # hashlib module in Python 2.6. 40 | try: 41 | import hashlib 42 | md5_hash = hashlib.md5 43 | sha1_hash = hashlib.sha1 44 | except ImportError: 45 | import md5 46 | import sha 47 | md5_hash = md5.md5 48 | sha1_hash = sha.sha 49 | 50 | try: 51 | from StringIO import StringIO 52 | except ImportError: 53 | from io import StringIO 54 | 55 | import logging 56 | import os 57 | import sys 58 | import re 59 | import socket 60 | import traceback 61 | import zlib 62 | 63 | try: 64 | from mod_pywebsocket import fast_masking 65 | except ImportError: 66 | pass 67 | 68 | 69 | def get_stack_trace(): 70 | """Get the current stack trace as string. 71 | 72 | This is needed to support Python 2.3. 73 | TODO: Remove this when we only support Python 2.4 and above. 74 | Use traceback.format_exc instead. 75 | """ 76 | out = StringIO.StringIO() 77 | traceback.print_exc(file=out) 78 | return out.getvalue() 79 | 80 | 81 | def prepend_message_to_exception(message, exc): 82 | """Prepend message to the exception.""" 83 | exc.args = (message + str(exc),) 84 | return 85 | 86 | 87 | def __translate_interp(interp, cygwin_path): 88 | """Translate interp program path for Win32 python to run cygwin program 89 | (e.g. perl). Note that it doesn't support path that contains space, 90 | which is typically true for Unix, where #!-script is written. 91 | For Win32 python, cygwin_path is a directory of cygwin binaries. 92 | 93 | Args: 94 | interp: interp command line 95 | cygwin_path: directory name of cygwin binary, or None 96 | Returns: 97 | translated interp command line. 98 | """ 99 | if not cygwin_path: 100 | return interp 101 | m = re.match('^[^ ]*/([^ ]+)( .*)?', interp) 102 | if m: 103 | cmd = os.path.join(cygwin_path, m.group(1)) 104 | return cmd + m.group(2) 105 | return interp 106 | 107 | 108 | def get_script_interp(script_path, cygwin_path=None): 109 | r"""Get #!-interpreter command line from the script. 110 | 111 | It also fixes command path. When Cygwin Python is used, e.g. in WebKit, 112 | it could run "/usr/bin/perl -wT hello.pl". 113 | When Win32 Python is used, e.g. in Chromium, it couldn't. So, fix 114 | "/usr/bin/perl" to "\perl.exe". 115 | 116 | Args: 117 | script_path: pathname of the script 118 | cygwin_path: directory name of cygwin binary, or None 119 | Returns: 120 | #!-interpreter command line, or None if it is not #!-script. 121 | """ 122 | fp = open(script_path) 123 | line = fp.readline() 124 | fp.close() 125 | m = re.match('^#!(.*)', line) 126 | if m: 127 | return __translate_interp(m.group(1), cygwin_path) 128 | return None 129 | 130 | 131 | def wrap_popen3_for_win(cygwin_path): 132 | """Wrap popen3 to support #!-script on Windows. 133 | 134 | Args: 135 | cygwin_path: path for cygwin binary if command path is needed to be 136 | translated. None if no translation required. 137 | """ 138 | __orig_popen3 = os.popen3 139 | 140 | def __wrap_popen3(cmd, mode='t', bufsize=-1): 141 | cmdline = cmd.split(' ') 142 | interp = get_script_interp(cmdline[0], cygwin_path) 143 | if interp: 144 | cmd = interp + ' ' + cmd 145 | return __orig_popen3(cmd, mode, bufsize) 146 | 147 | os.popen3 = __wrap_popen3 148 | 149 | 150 | def hexify(s): 151 | r= ' '.join(map(lambda x: '%02x' % x, bytearray(s))) 152 | return r 153 | 154 | 155 | def get_class_logger(o): 156 | """Return the logging class information.""" 157 | return logging.getLogger( 158 | '%s.%s' % (o.__class__.__module__, o.__class__.__name__)) 159 | 160 | 161 | class NoopMasker(object): 162 | """A NoOp masking object. 163 | 164 | This has the same interface as RepeatedXorMasker but just returns 165 | the string passed in without making any change. 166 | """ 167 | 168 | def __init__(self): 169 | """NoOp.""" 170 | pass 171 | 172 | def mask(self, s): 173 | """NoOp.""" 174 | return s 175 | 176 | 177 | class RepeatedXorMasker(object): 178 | 179 | """A masking object that applies XOR on the string. 180 | 181 | Applies XOR on the string given to mask method with the masking bytes 182 | given to the constructor repeatedly. This object remembers the position 183 | in the masking bytes the last mask method call ended and resumes from 184 | that point on the next mask method call. 185 | """ 186 | 187 | def __init__(self, masking_key): 188 | self._masking_key = masking_key 189 | self._masking_key_index = 0 190 | 191 | def _mask_using_swig(self, s): 192 | """Perform the mask via SWIG.""" 193 | masked_data = fast_masking.mask( 194 | s, self._masking_key, self._masking_key_index) 195 | self._masking_key_index = ( 196 | (self._masking_key_index + len(s)) % len(self._masking_key)) 197 | return masked_data 198 | 199 | def _mask_using_array(self, s): 200 | """Perform the mask via python.""" 201 | result = array.array('B') 202 | if sys.version_info > (3,): 203 | result.frombytes(bytes(s)) 204 | else: 205 | result.fromstring(bytes(s)) 206 | 207 | 208 | # Use temporary local variables to eliminate the cost to access 209 | # attributes 210 | if type(self._masking_key[0]) is int: 211 | masking_key = [x for x in self._masking_key] 212 | else: 213 | masking_key = map(ord, self._masking_key) 214 | masking_key_size = len(masking_key) 215 | masking_key_index = self._masking_key_index 216 | 217 | for i in range(len(result)): 218 | result[i] ^= masking_key[masking_key_index] 219 | masking_key_index = (masking_key_index + 1) % masking_key_size 220 | 221 | self._masking_key_index = masking_key_index 222 | 223 | if sys.version_info > (3,): 224 | return result.tobytes() 225 | else: 226 | return result.tostring() 227 | 228 | if 'fast_masking' in globals(): 229 | mask = _mask_using_swig 230 | else: 231 | mask = _mask_using_array 232 | 233 | 234 | # By making wbits option negative, we can suppress CMF/FLG (2 octet) and 235 | # ADLER32 (4 octet) fields of zlib so that we can use zlib module just as 236 | # deflate library. DICTID won't be added as far as we don't set dictionary. 237 | # LZ77 window of 32K will be used for both compression and decompression. 238 | # For decompression, we can just use 32K to cover any windows size. For 239 | # compression, we use 32K so receivers must use 32K. 240 | # 241 | # Compression level is Z_DEFAULT_COMPRESSION. We don't have to match level 242 | # to decode. 243 | # 244 | # See zconf.h, deflate.cc, inflate.cc of zlib library, and zlibmodule.c of 245 | # Python. See also RFC1950 (ZLIB 3.3). 246 | 247 | 248 | class _Deflater(object): 249 | 250 | def __init__(self, window_bits): 251 | self._logger = get_class_logger(self) 252 | 253 | self._compress = zlib.compressobj( 254 | zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -window_bits) 255 | 256 | def compress(self, bytes): 257 | compressed_bytes = self._compress.compress(bytes) 258 | self._logger.debug('Compress input %r', bytes) 259 | self._logger.debug('Compress result %r', compressed_bytes) 260 | return compressed_bytes 261 | 262 | def compress_and_flush(self, bytes): 263 | compressed_bytes = self._compress.compress(bytes) 264 | compressed_bytes += self._compress.flush(zlib.Z_SYNC_FLUSH) 265 | self._logger.debug('Compress input %r', bytes) 266 | self._logger.debug('Compress result %r', compressed_bytes) 267 | return compressed_bytes 268 | 269 | def compress_and_finish(self, bytes): 270 | compressed_bytes = self._compress.compress(bytes) 271 | compressed_bytes += self._compress.flush(zlib.Z_FINISH) 272 | self._logger.debug('Compress input %r', bytes) 273 | self._logger.debug('Compress result %r', compressed_bytes) 274 | return compressed_bytes 275 | 276 | 277 | class _Inflater(object): 278 | 279 | def __init__(self, window_bits): 280 | self._logger = get_class_logger(self) 281 | self._window_bits = window_bits 282 | 283 | self._unconsumed = '' 284 | 285 | self.reset() 286 | 287 | def decompress(self, size): 288 | if not (size == -1 or size > 0): 289 | raise Exception('size must be -1 or positive') 290 | 291 | data = '' 292 | 293 | while True: 294 | if size == -1: 295 | data += self._decompress.decompress(self._unconsumed) 296 | # See Python bug http://bugs.python.org/issue12050 to 297 | # understand why the same code cannot be used for updating 298 | # self._unconsumed for here and else block. 299 | self._unconsumed = '' 300 | else: 301 | data += self._decompress.decompress( 302 | self._unconsumed, size - len(data)) 303 | self._unconsumed = self._decompress.unconsumed_tail 304 | if self._decompress.unused_data: 305 | # Encountered a last block (i.e. a block with BFINAL = 1) and 306 | # found a new stream (unused_data). We cannot use the same 307 | # zlib.Decompress object for the new stream. Create a new 308 | # Decompress object to decompress the new one. 309 | # 310 | # It's fine to ignore unconsumed_tail if unused_data is not 311 | # empty. 312 | self._unconsumed = self._decompress.unused_data 313 | self.reset() 314 | if size >= 0 and len(data) == size: 315 | # data is filled. Don't call decompress again. 316 | break 317 | else: 318 | # Re-invoke Decompress.decompress to try to decompress all 319 | # available bytes before invoking read which blocks until 320 | # any new byte is available. 321 | continue 322 | else: 323 | # Here, since unused_data is empty, even if unconsumed_tail is 324 | # not empty, bytes of requested length are already in data. We 325 | # don't have to "continue" here. 326 | break 327 | 328 | if data: 329 | self._logger.debug('Decompressed %r', data) 330 | return data 331 | 332 | def append(self, data): 333 | self._logger.debug('Appended %r', data) 334 | self._unconsumed += data 335 | 336 | def reset(self): 337 | self._logger.debug('Reset') 338 | self._decompress = zlib.decompressobj(-self._window_bits) 339 | 340 | 341 | # Compresses/decompresses given octets using the method introduced in RFC1979. 342 | 343 | 344 | class _RFC1979Deflater(object): 345 | """A compressor class that applies DEFLATE to given byte sequence and 346 | flushes using the algorithm described in the RFC1979 section 2.1. 347 | """ 348 | 349 | def __init__(self, window_bits, no_context_takeover): 350 | self._deflater = None 351 | if window_bits is None: 352 | window_bits = zlib.MAX_WBITS 353 | self._window_bits = window_bits 354 | self._no_context_takeover = no_context_takeover 355 | 356 | def filter(self, bytes, end=True, bfinal=False): 357 | if self._deflater is None: 358 | self._deflater = _Deflater(self._window_bits) 359 | 360 | if bfinal: 361 | result = self._deflater.compress_and_finish(bytes) 362 | # Add a padding block with BFINAL = 0 and BTYPE = 0. 363 | result = result + chr(0) 364 | self._deflater = None 365 | return result 366 | 367 | result = self._deflater.compress_and_flush(bytes) 368 | if end: 369 | # Strip last 4 octets which is LEN and NLEN field of a 370 | # non-compressed block added for Z_SYNC_FLUSH. 371 | result = result[:-4] 372 | 373 | if self._no_context_takeover and end: 374 | self._deflater = None 375 | 376 | return result 377 | 378 | 379 | class _RFC1979Inflater(object): 380 | """A decompressor class a la RFC1979. 381 | 382 | A decompressor class for byte sequence compressed and flushed following 383 | the algorithm described in the RFC1979 section 2.1. 384 | """ 385 | 386 | def __init__(self, window_bits=zlib.MAX_WBITS): 387 | self._inflater = _Inflater(window_bits) 388 | 389 | def filter(self, bytes): 390 | # Restore stripped LEN and NLEN field of a non-compressed block added 391 | # for Z_SYNC_FLUSH. 392 | self._inflater.append(bytes + '\x00\x00\xff\xff') 393 | return self._inflater.decompress(-1) 394 | 395 | 396 | class DeflateSocket(object): 397 | """A wrapper class for socket object to intercept send and recv to perform 398 | deflate compression and decompression transparently. 399 | """ 400 | 401 | # Size of the buffer passed to recv to receive compressed data. 402 | _RECV_SIZE = 4096 403 | 404 | def __init__(self, socket): 405 | self._socket = socket 406 | 407 | self._logger = get_class_logger(self) 408 | 409 | self._deflater = _Deflater(zlib.MAX_WBITS) 410 | self._inflater = _Inflater(zlib.MAX_WBITS) 411 | 412 | def recv(self, size): 413 | """Receives data from the socket specified on the construction up 414 | to the specified size. Once any data is available, returns it even 415 | if it's smaller than the specified size. 416 | """ 417 | 418 | # TODO(tyoshino): Allow call with size=0. It should block until any 419 | # decompressed data is available. 420 | if size <= 0: 421 | raise Exception('Non-positive size passed') 422 | while True: 423 | data = self._inflater.decompress(size) 424 | if len(data) != 0: 425 | return data 426 | 427 | read_data = self._socket.recv(DeflateSocket._RECV_SIZE) 428 | if not read_data: 429 | return '' 430 | self._inflater.append(read_data) 431 | 432 | def sendall(self, bytes): 433 | self.send(bytes) 434 | 435 | def send(self, bytes): 436 | self._socket.sendall(self._deflater.compress_and_flush(bytes)) 437 | return len(bytes) 438 | 439 | 440 | # vi:sts=4 sw=4 et 441 | -------------------------------------------------------------------------------- /kiwi/wsclient.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Modified echo client from the pywebsocket examples 4 | """ 5 | 6 | import base64 7 | import logging 8 | import os 9 | import re 10 | import socket 11 | 12 | from mod_pywebsocket import common 13 | from mod_pywebsocket.extensions import DeflateFrameExtensionProcessor 14 | from mod_pywebsocket.extensions import PerMessageDeflateExtensionProcessor 15 | from mod_pywebsocket.extensions import _PerMessageDeflateFramer 16 | from mod_pywebsocket.extensions import _parse_window_bits 17 | from mod_pywebsocket import util 18 | 19 | 20 | _TIMEOUT_SEC = 10 21 | _UNDEFINED_PORT = -1 22 | 23 | _UPGRADE_HEADER = 'Upgrade: websocket\r\n' 24 | _UPGRADE_HEADER_HIXIE75 = 'Upgrade: WebSocket\r\n' 25 | _CONNECTION_HEADER = 'Connection: Upgrade\r\n' 26 | 27 | 28 | class ClientHandshakeError(Exception): 29 | pass 30 | 31 | 32 | def _build_method_line(resource): 33 | return ('GET %s HTTP/1.1\r\n' % resource).encode() 34 | 35 | 36 | def _origin_header(header, origin): 37 | # 4.1 13. concatenation of the string "Origin:", a U+0020 SPACE character, 38 | # and the /origin/ value, converted to ASCII lowercase, to /fields/. 39 | return '%s: %s\r\n' % (header, origin.lower()) 40 | 41 | 42 | def _format_host_header(host, port, secure): 43 | # 4.1 9. Let /hostport/ be an empty string. 44 | # 4.1 10. Append the /host/ value, converted to ASCII lowercase, to 45 | # /hostport/ 46 | hostport = host.lower() 47 | # 4.1 11. If /secure/ is false, and /port/ is not 80, or if /secure/ 48 | # is true, and /port/ is not 443, then append a U+003A COLON character 49 | # (:) followed by the value of /port/, expressed as a base-ten integer, 50 | # to /hostport/ 51 | if ((not secure and port != common.DEFAULT_WEB_SOCKET_PORT) or 52 | (secure and port != common.DEFAULT_WEB_SOCKET_SECURE_PORT)): 53 | hostport += ':' + str(port) 54 | # 4.1 12. concatenation of the string "Host:", a U+0020 SPACE 55 | # character, and /hostport/, to /fields/. 56 | return '%s: %s\r\n' % (common.HOST_HEADER, hostport) 57 | 58 | 59 | def _receive_bytes(socket, length): 60 | bytes = [] 61 | remaining = length 62 | while remaining > 0: 63 | received_bytes = socket.recv(remaining) 64 | if not received_bytes: 65 | raise IOError( 66 | 'Connection closed before receiving requested length ' 67 | '(requested %d bytes but received only %d bytes)' % 68 | (length, length - remaining)) 69 | bytes.append(received_bytes) 70 | remaining -= len(received_bytes) 71 | return bytearray().join(bytes).decode('utf-8') 72 | 73 | 74 | def _get_mandatory_header(fields, name): 75 | """Gets the value of the header specified by name from fields. 76 | 77 | This function expects that there's only one header with the specified name 78 | in fields. Otherwise, raises an ClientHandshakeError. 79 | """ 80 | 81 | values = fields.get(name.lower()) 82 | if values is None or len(values) == 0: 83 | raise ClientHandshakeError( 84 | '%s header not found: %r' % (name, values)) 85 | if len(values) > 1: 86 | raise ClientHandshakeError( 87 | 'Multiple %s headers found: %r' % (name, values)) 88 | return values[0] 89 | 90 | 91 | def _validate_mandatory_header(fields, name, 92 | expected_value, case_sensitive=False): 93 | """Gets and validates the value of the header specified by name from 94 | fields. 95 | 96 | If expected_value is specified, compares expected value and actual value 97 | and raises an ClientHandshakeError on failure. You can specify case 98 | sensitiveness in this comparison by case_sensitive parameter. This function 99 | expects that there's only one header with the specified name in fields. 100 | Otherwise, raises an ClientHandshakeError. 101 | """ 102 | 103 | value = _get_mandatory_header(fields, name) 104 | 105 | if ((case_sensitive and value != expected_value) or 106 | (not case_sensitive and value.lower() != expected_value.lower())): 107 | raise ClientHandshakeError( 108 | 'Illegal value for header %s: %r (expected) vs %r (actual)' % 109 | (name, expected_value, value)) 110 | 111 | 112 | class ClientHandshakeBase(object): 113 | """A base class for WebSocket opening handshake processors for each 114 | protocol version. 115 | """ 116 | 117 | def __init__(self): 118 | self._logger = util.get_class_logger(self) 119 | 120 | def _read_fields(self): 121 | # 4.1 32. let /fields/ be a list of name-value pairs, initially empty. 122 | fields = {} 123 | while True: # "Field" 124 | # 4.1 33. let /name/ and /value/ be empty byte arrays 125 | name = '' 126 | value = '' 127 | # 4.1 34. read /name/ 128 | name = self._read_name() 129 | if name is None: 130 | break 131 | # 4.1 35. read spaces 132 | # TODO(tyoshino): Skip only one space as described in the spec. 133 | ch = self._skip_spaces() 134 | # 4.1 36. read /value/ 135 | value = self._read_value(ch) 136 | # 4.1 37. read a byte from the server 137 | ch = _receive_bytes(self._socket, 1) 138 | if ch != '\n': # 0x0A 139 | raise ClientHandshakeError( 140 | 'Expected LF but found %r while reading value %r for ' 141 | 'header %r' % (ch, value, name)) 142 | self._logger.debug('Received %r header', name) 143 | # 4.1 38. append an entry to the /fields/ list that has the name 144 | # given by the string obtained by interpreting the /name/ byte 145 | # array as a UTF-8 stream and the value given by the string 146 | # obtained by interpreting the /value/ byte array as a UTF-8 byte 147 | # stream. 148 | fields.setdefault(name, []).append(value) 149 | # 4.1 39. return to the "Field" step above 150 | return fields 151 | 152 | def _read_name(self): 153 | # 4.1 33. let /name/ be empty byte arrays 154 | name = '' 155 | while True: 156 | # 4.1 34. read a byte from the server 157 | ch = _receive_bytes(self._socket, 1) 158 | if ch == '\r': # 0x0D 159 | return None 160 | elif ch == '\n': # 0x0A 161 | raise ClientHandshakeError( 162 | 'Unexpected LF when reading header name %r' % name) 163 | elif ch == ':': # 0x3A 164 | return name 165 | elif ch >= 'A' and ch <= 'Z': # Range 0x31 to 0x5A 166 | ch = chr(ord(ch) + 0x20) 167 | name += ch 168 | else: 169 | name += ch 170 | 171 | def _skip_spaces(self): 172 | # 4.1 35. read a byte from the server 173 | while True: 174 | ch = _receive_bytes(self._socket, 1) 175 | if ch == ' ': # 0x20 176 | continue 177 | return ch 178 | 179 | def _read_value(self, ch): 180 | # 4.1 33. let /value/ be empty byte arrays 181 | value = '' 182 | # 4.1 36. read a byte from server. 183 | while True: 184 | if ch == '\r': # 0x0D 185 | return value 186 | elif ch == '\n': # 0x0A 187 | raise ClientHandshakeError( 188 | 'Unexpected LF when reading header value %r' % value) 189 | else: 190 | value += ch 191 | ch = _receive_bytes(self._socket, 1) 192 | 193 | 194 | def _get_permessage_deflate_framer(extension_response): 195 | """Validate the response and return a framer object using the parameters in 196 | the response. This method doesn't accept the server_.* parameters. 197 | """ 198 | 199 | client_max_window_bits = None 200 | client_no_context_takeover = None 201 | 202 | client_max_window_bits_name = ( 203 | PerMessageDeflateExtensionProcessor. 204 | _CLIENT_MAX_WINDOW_BITS_PARAM) 205 | client_no_context_takeover_name = ( 206 | PerMessageDeflateExtensionProcessor. 207 | _CLIENT_NO_CONTEXT_TAKEOVER_PARAM) 208 | 209 | # We didn't send any server_.* parameter. 210 | # Handle those parameters as invalid if found in the response. 211 | 212 | for param_name, param_value in extension_response.get_parameters(): 213 | if param_name == client_max_window_bits_name: 214 | if client_max_window_bits is not None: 215 | raise ClientHandshakeError( 216 | 'Multiple %s found' % client_max_window_bits_name) 217 | 218 | parsed_value = _parse_window_bits(param_value) 219 | if parsed_value is None: 220 | raise ClientHandshakeError( 221 | 'Bad %s: %r' % 222 | (client_max_window_bits_name, param_value)) 223 | client_max_window_bits = parsed_value 224 | elif param_name == client_no_context_takeover_name: 225 | if client_no_context_takeover is not None: 226 | raise ClientHandshakeError( 227 | 'Multiple %s found' % client_no_context_takeover_name) 228 | 229 | if param_value is not None: 230 | raise ClientHandshakeError( 231 | 'Bad %s: Has value %r' % 232 | (client_no_context_takeover_name, param_value)) 233 | client_no_context_takeover = True 234 | 235 | if client_no_context_takeover is None: 236 | client_no_context_takeover = False 237 | 238 | return _PerMessageDeflateFramer(client_max_window_bits, 239 | client_no_context_takeover) 240 | 241 | 242 | class ClientHandshakeProcessor(ClientHandshakeBase): 243 | """WebSocket opening handshake processor for 244 | draft-ietf-hybi-thewebsocketprotocol-06 and later. 245 | """ 246 | 247 | def __init__(self, socket, host, port, origin=None, deflate_frame=False, use_permessage_deflate=False): 248 | super(ClientHandshakeProcessor, self).__init__() 249 | 250 | self._socket = socket 251 | self._host = host 252 | self._port = port 253 | self._origin = origin 254 | self._deflate_frame = deflate_frame 255 | self._use_permessage_deflate = use_permessage_deflate 256 | 257 | self._logger = util.get_class_logger(self) 258 | 259 | def handshake(self, resource): 260 | """Performs opening handshake on the specified socket. 261 | 262 | Raises: 263 | ClientHandshakeError: handshake failed. 264 | """ 265 | 266 | request_line = _build_method_line(resource) 267 | self._logger.debug('Client\'s opening handshake Request-Line: %r', request_line) 268 | 269 | fields = [] 270 | fields.append(_format_host_header(self._host, self._port, False)) 271 | fields.append(_UPGRADE_HEADER) 272 | fields.append(_CONNECTION_HEADER) 273 | if self._origin is not None: 274 | fields.append(_origin_header(common.ORIGIN_HEADER, self._origin)) 275 | 276 | original_key = os.urandom(16) 277 | self._key = base64.b64encode(original_key) 278 | self._logger.debug('%s: %r (%s)', common.SEC_WEBSOCKET_KEY_HEADER, self._key, util.hexify(original_key)) 279 | fields.append('%s: %s\r\n' % (common.SEC_WEBSOCKET_KEY_HEADER, self._key.decode())) 280 | fields.append('%s: %d\r\n' % (common.SEC_WEBSOCKET_VERSION_HEADER, common.VERSION_HYBI_LATEST)) 281 | extensions_to_request = [] 282 | 283 | if self._deflate_frame: 284 | extensions_to_request.append(common.ExtensionParameter(common.DEFLATE_FRAME_EXTENSION)) 285 | 286 | if self._use_permessage_deflate: 287 | extension = common.ExtensionParameter(common.PERMESSAGE_DEFLATE_EXTENSION) 288 | # Accept the client_max_window_bits extension parameter by default. 289 | extension.add_parameter(PerMessageDeflateExtensionProcessor._CLIENT_MAX_WINDOW_BITS_PARAM, None) 290 | extensions_to_request.append(extension) 291 | 292 | if len(extensions_to_request) != 0: 293 | fields.append('%s: %s\r\n' % (common.SEC_WEBSOCKET_EXTENSIONS_HEADER, common.format_extensions(extensions_to_request))) 294 | 295 | self._socket.sendall(request_line) 296 | for field in fields: 297 | self._socket.sendall(field.encode()) 298 | self._socket.sendall(b'\r\n') 299 | 300 | self._logger.debug('Sent client\'s opening handshake headers: %r', fields) 301 | self._logger.debug('Start reading Status-Line') 302 | 303 | status_line = '' 304 | while True: 305 | ch = _receive_bytes(self._socket, 1) 306 | status_line += ch 307 | if ch == '\n': 308 | break 309 | 310 | m = re.match('HTTP/\\d+.\\d+ (\\d\\d\\d) .*\r\n', status_line) 311 | if m is None: 312 | raise ClientHandshakeError('Wrong status line format: %r' % status_line) 313 | status_code = m.group(1) 314 | if status_code != '101': 315 | self._logger.debug('Unexpected status code %s with following headers: %r', status_code, self._read_fields()) 316 | raise ClientHandshakeError('Expected HTTP status code 101 but found %r' % status_code) 317 | 318 | self._logger.debug('Received valid Status-Line') 319 | self._logger.debug('Start reading headers until we see an empty line') 320 | 321 | fields = self._read_fields() 322 | 323 | ch = _receive_bytes(self._socket, 1) 324 | if ch != '\n': # 0x0A 325 | raise ClientHandshakeError( 326 | 'Expected LF but found %r while reading value %r for header ' 327 | 'name %r' % (ch, value, name)) 328 | 329 | self._logger.debug('Received an empty line') 330 | self._logger.debug('Server\'s opening handshake headers: %r', fields) 331 | 332 | _validate_mandatory_header(fields, common.UPGRADE_HEADER, common.WEBSOCKET_UPGRADE_TYPE, False) 333 | _validate_mandatory_header(fields, common.CONNECTION_HEADER, common.UPGRADE_CONNECTION_TYPE, False) 334 | 335 | accept = _get_mandatory_header(fields, common.SEC_WEBSOCKET_ACCEPT_HEADER) 336 | # Validate 337 | try: 338 | binary_accept = base64.b64decode(accept) 339 | except TypeError as e: 340 | raise HandshakeError( 341 | 'Illegal value for header %s: %r' % 342 | (common.SEC_WEBSOCKET_ACCEPT_HEADER, accept)) 343 | 344 | if len(binary_accept) != 20: 345 | raise ClientHandshakeError( 346 | 'Decoded value of %s is not 20-byte long' % 347 | common.SEC_WEBSOCKET_ACCEPT_HEADER) 348 | 349 | self._logger.debug('Response for challenge : %r (%s)', accept, util.hexify(binary_accept)) 350 | 351 | binary_expected_accept = util.sha1_hash(self._key + common.WEBSOCKET_ACCEPT_UUID.encode()).digest() 352 | expected_accept = base64.b64encode(binary_expected_accept) 353 | self._logger.debug( 354 | 'Expected response for challenge: %r (%s)', 355 | expected_accept, util.hexify(binary_expected_accept)) 356 | 357 | if accept.encode() != expected_accept: 358 | raise ClientHandshakeError( 359 | 'Invalid %s header: %r (expected: %s)' % 360 | (common.SEC_WEBSOCKET_ACCEPT_HEADER, accept, expected_accept)) 361 | 362 | deflate_frame_accepted = False 363 | permessage_deflate_accepted = False 364 | 365 | extensions_header = fields.get(common.SEC_WEBSOCKET_EXTENSIONS_HEADER.lower()) 366 | accepted_extensions = [] 367 | if extensions_header is not None and len(extensions_header) != 0: 368 | accepted_extensions = common.parse_extensions(extensions_header[0]) 369 | 370 | # TODO(bashi): Support the new style perframe compression extension. 371 | for extension in accepted_extensions: 372 | extension_name = extension.name() 373 | if (extension_name == common.DEFLATE_FRAME_EXTENSION and self._deflate_frame): 374 | deflate_frame_accepted = True 375 | processor = DeflateFrameExtensionProcessor(extension) 376 | unused_extension_response = processor.get_extension_response() 377 | self._deflate_frame = processor 378 | continue 379 | elif (extension_name == common.PERMESSAGE_DEFLATE_EXTENSION and self._use_permessage_deflate): 380 | permessage_deflate_accepted = True 381 | framer = _get_permessage_deflate_framer(extension) 382 | framer.set_compress_outgoing_enabled(True) 383 | self._use_permessage_deflate = framer 384 | continue 385 | 386 | raise ClientHandshakeError('Unexpected extension %r' % extension_name) 387 | 388 | if (self._deflate_frame and not deflate_frame_accepted): 389 | raise ClientHandshakeError('Requested %s, but the server rejected it' % common.DEFLATE_FRAME_EXTENSION) 390 | if (self._use_permessage_deflate and not permessage_deflate_accepted): 391 | raise ClientHandshakeError('Requested %s, but the server rejected it' % common.PERMESSAGE_DEFLATE_EXTENSION) 392 | 393 | # TODO(tyoshino): Handle Sec-WebSocket-Protocol 394 | # TODO(tyoshino): Handle Cookie, etc. 395 | 396 | 397 | class ClientConnection(object): 398 | """A wrapper for socket object to provide the mp_conn interface. 399 | mod_pywebsocket library is designed to be working on Apache mod_python's 400 | mp_conn object. 401 | """ 402 | 403 | def __init__(self, socket): 404 | self._socket = socket 405 | 406 | def write(self, data): 407 | try: 408 | self._socket.sendall(data) 409 | except Exception as e: 410 | logging.debug('ClientConnection write error: "%s"' % e) 411 | 412 | def read(self, n): 413 | return self._socket.recv(n) 414 | 415 | def get_remote_addr(self): 416 | return self._socket.getpeername() 417 | remote_addr = property(get_remote_addr) 418 | 419 | 420 | class ClientRequest(object): 421 | """A wrapper class just to make it able to pass a socket object to 422 | functions that expect a mp_request object. 423 | """ 424 | 425 | def __init__(self, socket): 426 | self._logger = util.get_class_logger(self) 427 | 428 | self._socket = socket 429 | self.connection = ClientConnection(socket) 430 | 431 | 432 | -------------------------------------------------------------------------------- /samplerate/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (built by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. Generated by 9 | # versioneer-0.18 (https://github.com/warner/python-versioneer) 10 | 11 | """Git implementation of _version.py.""" 12 | 13 | import errno 14 | import os 15 | import re 16 | import subprocess 17 | import sys 18 | 19 | 20 | def get_keywords(): 21 | """Get the keywords needed to look up the version information.""" 22 | # these strings will be replaced by git during git-archive. 23 | # setup.py/versioneer.py will grep for the variable names, so they must 24 | # each be defined on a line of their own. _version.py will just call 25 | # get_keywords(). 26 | git_refnames = " (HEAD -> master)" 27 | git_full = "ed73d7a39e61bfb34b03dade14ffab59aa27922a" 28 | git_date = "2019-04-10 08:46:22 +0200" 29 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 30 | return keywords 31 | 32 | 33 | class VersioneerConfig: 34 | """Container for Versioneer configuration parameters.""" 35 | 36 | 37 | def get_config(): 38 | """Create, populate and return the VersioneerConfig() object.""" 39 | # these strings are filled in when 'setup.py versioneer' creates 40 | # _version.py 41 | cfg = VersioneerConfig() 42 | cfg.VCS = "git" 43 | cfg.style = "pep440" 44 | cfg.tag_prefix = "" 45 | cfg.parentdir_prefix = "python-samplerate-" 46 | cfg.versionfile_source = "samplerate/_version.py" 47 | cfg.verbose = False 48 | return cfg 49 | 50 | 51 | class NotThisMethod(Exception): 52 | """Exception raised if a method is not valid for the current scenario.""" 53 | 54 | 55 | LONG_VERSION_PY = {} 56 | HANDLERS = {} 57 | 58 | 59 | def register_vcs_handler(vcs, method): # decorator 60 | """Decorator to mark a method as the handler for a particular VCS.""" 61 | def decorate(f): 62 | """Store f in HANDLERS[vcs][method].""" 63 | if vcs not in HANDLERS: 64 | HANDLERS[vcs] = {} 65 | HANDLERS[vcs][method] = f 66 | return f 67 | return decorate 68 | 69 | 70 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 71 | env=None): 72 | """Call the given command(s).""" 73 | assert isinstance(commands, list) 74 | p = None 75 | for c in commands: 76 | try: 77 | dispcmd = str([c] + args) 78 | # remember shell=False, so use git.cmd on windows, not just git 79 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 80 | stdout=subprocess.PIPE, 81 | stderr=(subprocess.PIPE if hide_stderr 82 | else None)) 83 | break 84 | except EnvironmentError: 85 | e = sys.exc_info()[1] 86 | if e.errno == errno.ENOENT: 87 | continue 88 | if verbose: 89 | print("unable to run %s" % dispcmd) 90 | print(e) 91 | return None, None 92 | else: 93 | if verbose: 94 | print("unable to find command, tried %s" % (commands,)) 95 | return None, None 96 | stdout = p.communicate()[0].strip() 97 | if sys.version_info[0] >= 3: 98 | stdout = stdout.decode() 99 | if p.returncode != 0: 100 | if verbose: 101 | print("unable to run %s (error)" % dispcmd) 102 | print("stdout was %s" % stdout) 103 | return None, p.returncode 104 | return stdout, p.returncode 105 | 106 | 107 | def versions_from_parentdir(parentdir_prefix, root, verbose): 108 | """Try to determine the version from the parent directory name. 109 | 110 | Source tarballs conventionally unpack into a directory that includes both 111 | the project name and a version string. We will also support searching up 112 | two directory levels for an appropriately named parent directory 113 | """ 114 | rootdirs = [] 115 | 116 | for i in range(3): 117 | dirname = os.path.basename(root) 118 | if dirname.startswith(parentdir_prefix): 119 | return {"version": dirname[len(parentdir_prefix):], 120 | "full-revisionid": None, 121 | "dirty": False, "error": None, "date": None} 122 | else: 123 | rootdirs.append(root) 124 | root = os.path.dirname(root) # up a level 125 | 126 | if verbose: 127 | print("Tried directories %s but none started with prefix %s" % 128 | (str(rootdirs), parentdir_prefix)) 129 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 130 | 131 | 132 | @register_vcs_handler("git", "get_keywords") 133 | def git_get_keywords(versionfile_abs): 134 | """Extract version information from the given file.""" 135 | # the code embedded in _version.py can just fetch the value of these 136 | # keywords. When used from setup.py, we don't want to import _version.py, 137 | # so we do it with a regexp instead. This function is not used from 138 | # _version.py. 139 | keywords = {} 140 | try: 141 | f = open(versionfile_abs, "r") 142 | for line in f.readlines(): 143 | if line.strip().startswith("git_refnames ="): 144 | mo = re.search(r'=\s*"(.*)"', line) 145 | if mo: 146 | keywords["refnames"] = mo.group(1) 147 | if line.strip().startswith("git_full ="): 148 | mo = re.search(r'=\s*"(.*)"', line) 149 | if mo: 150 | keywords["full"] = mo.group(1) 151 | if line.strip().startswith("git_date ="): 152 | mo = re.search(r'=\s*"(.*)"', line) 153 | if mo: 154 | keywords["date"] = mo.group(1) 155 | f.close() 156 | except EnvironmentError: 157 | pass 158 | return keywords 159 | 160 | 161 | @register_vcs_handler("git", "keywords") 162 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 163 | """Get version information from git keywords.""" 164 | if not keywords: 165 | raise NotThisMethod("no keywords at all, weird") 166 | date = keywords.get("date") 167 | if date is not None: 168 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 169 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 170 | # -like" string, which we must then edit to make compliant), because 171 | # it's been around since git-1.5.3, and it's too difficult to 172 | # discover which version we're using, or to work around using an 173 | # older one. 174 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 175 | refnames = keywords["refnames"].strip() 176 | if refnames.startswith("$Format"): 177 | if verbose: 178 | print("keywords are unexpanded, not using") 179 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 180 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 181 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 182 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 183 | TAG = "tag: " 184 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 185 | if not tags: 186 | # Either we're using git < 1.8.3, or there really are no tags. We use 187 | # a heuristic: assume all version tags have a digit. The old git %d 188 | # expansion behaves like git log --decorate=short and strips out the 189 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 190 | # between branches and tags. By ignoring refnames without digits, we 191 | # filter out many common branch names like "release" and 192 | # "stabilization", as well as "HEAD" and "master". 193 | tags = set([r for r in refs if re.search(r'\d', r)]) 194 | if verbose: 195 | print("discarding '%s', no digits" % ",".join(refs - tags)) 196 | if verbose: 197 | print("likely tags: %s" % ",".join(sorted(tags))) 198 | for ref in sorted(tags): 199 | # sorting will prefer e.g. "2.0" over "2.0rc1" 200 | if ref.startswith(tag_prefix): 201 | r = ref[len(tag_prefix):] 202 | if verbose: 203 | print("picking %s" % r) 204 | return {"version": r, 205 | "full-revisionid": keywords["full"].strip(), 206 | "dirty": False, "error": None, 207 | "date": date} 208 | # no suitable tags, so version is "0+unknown", but full hex is still there 209 | if verbose: 210 | print("no suitable tags, using unknown + full revision id") 211 | return {"version": "0+unknown", 212 | "full-revisionid": keywords["full"].strip(), 213 | "dirty": False, "error": "no suitable tags", "date": None} 214 | 215 | 216 | @register_vcs_handler("git", "pieces_from_vcs") 217 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 218 | """Get version from 'git describe' in the root of the source tree. 219 | 220 | This only gets called if the git-archive 'subst' keywords were *not* 221 | expanded, and _version.py hasn't already been rewritten with a short 222 | version string, meaning we're inside a checked out source tree. 223 | """ 224 | GITS = ["git"] 225 | if sys.platform == "win32": 226 | GITS = ["git.cmd", "git.exe"] 227 | 228 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 229 | hide_stderr=True) 230 | if rc != 0: 231 | if verbose: 232 | print("Directory %s not under git control" % root) 233 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 234 | 235 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 236 | # if there isn't one, this yields HEX[-dirty] (no NUM) 237 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 238 | "--always", "--long", 239 | "--match", "%s*" % tag_prefix], 240 | cwd=root) 241 | # --long was added in git-1.5.5 242 | if describe_out is None: 243 | raise NotThisMethod("'git describe' failed") 244 | describe_out = describe_out.strip() 245 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 246 | if full_out is None: 247 | raise NotThisMethod("'git rev-parse' failed") 248 | full_out = full_out.strip() 249 | 250 | pieces = {} 251 | pieces["long"] = full_out 252 | pieces["short"] = full_out[:7] # maybe improved later 253 | pieces["error"] = None 254 | 255 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 256 | # TAG might have hyphens. 257 | git_describe = describe_out 258 | 259 | # look for -dirty suffix 260 | dirty = git_describe.endswith("-dirty") 261 | pieces["dirty"] = dirty 262 | if dirty: 263 | git_describe = git_describe[:git_describe.rindex("-dirty")] 264 | 265 | # now we have TAG-NUM-gHEX or HEX 266 | 267 | if "-" in git_describe: 268 | # TAG-NUM-gHEX 269 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 270 | if not mo: 271 | # unparseable. Maybe git-describe is misbehaving? 272 | pieces["error"] = ("unable to parse git-describe output: '%s'" 273 | % describe_out) 274 | return pieces 275 | 276 | # tag 277 | full_tag = mo.group(1) 278 | if not full_tag.startswith(tag_prefix): 279 | if verbose: 280 | fmt = "tag '%s' doesn't start with prefix '%s'" 281 | print(fmt % (full_tag, tag_prefix)) 282 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 283 | % (full_tag, tag_prefix)) 284 | return pieces 285 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 286 | 287 | # distance: number of commits since tag 288 | pieces["distance"] = int(mo.group(2)) 289 | 290 | # commit: short hex revision ID 291 | pieces["short"] = mo.group(3) 292 | 293 | else: 294 | # HEX: no tags 295 | pieces["closest-tag"] = None 296 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 297 | cwd=root) 298 | pieces["distance"] = int(count_out) # total number of commits 299 | 300 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 301 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], 302 | cwd=root)[0].strip() 303 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 304 | 305 | return pieces 306 | 307 | 308 | def plus_or_dot(pieces): 309 | """Return a + if we don't already have one, else return a .""" 310 | if "+" in pieces.get("closest-tag", ""): 311 | return "." 312 | return "+" 313 | 314 | 315 | def render_pep440(pieces): 316 | """Build up version string, with post-release "local version identifier". 317 | 318 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 319 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 320 | 321 | Exceptions: 322 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 323 | """ 324 | if pieces["closest-tag"]: 325 | rendered = pieces["closest-tag"] 326 | if pieces["distance"] or pieces["dirty"]: 327 | rendered += plus_or_dot(pieces) 328 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 329 | if pieces["dirty"]: 330 | rendered += ".dirty" 331 | else: 332 | # exception #1 333 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 334 | pieces["short"]) 335 | if pieces["dirty"]: 336 | rendered += ".dirty" 337 | return rendered 338 | 339 | 340 | def render_pep440_pre(pieces): 341 | """TAG[.post.devDISTANCE] -- No -dirty. 342 | 343 | Exceptions: 344 | 1: no tags. 0.post.devDISTANCE 345 | """ 346 | if pieces["closest-tag"]: 347 | rendered = pieces["closest-tag"] 348 | if pieces["distance"]: 349 | rendered += ".post.dev%d" % pieces["distance"] 350 | else: 351 | # exception #1 352 | rendered = "0.post.dev%d" % pieces["distance"] 353 | return rendered 354 | 355 | 356 | def render_pep440_post(pieces): 357 | """TAG[.postDISTANCE[.dev0]+gHEX] . 358 | 359 | The ".dev0" means dirty. Note that .dev0 sorts backwards 360 | (a dirty tree will appear "older" than the corresponding clean one), 361 | but you shouldn't be releasing software with -dirty anyways. 362 | 363 | Exceptions: 364 | 1: no tags. 0.postDISTANCE[.dev0] 365 | """ 366 | if pieces["closest-tag"]: 367 | rendered = pieces["closest-tag"] 368 | if pieces["distance"] or pieces["dirty"]: 369 | rendered += ".post%d" % pieces["distance"] 370 | if pieces["dirty"]: 371 | rendered += ".dev0" 372 | rendered += plus_or_dot(pieces) 373 | rendered += "g%s" % pieces["short"] 374 | else: 375 | # exception #1 376 | rendered = "0.post%d" % pieces["distance"] 377 | if pieces["dirty"]: 378 | rendered += ".dev0" 379 | rendered += "+g%s" % pieces["short"] 380 | return rendered 381 | 382 | 383 | def render_pep440_old(pieces): 384 | """TAG[.postDISTANCE[.dev0]] . 385 | 386 | The ".dev0" means dirty. 387 | 388 | Eexceptions: 389 | 1: no tags. 0.postDISTANCE[.dev0] 390 | """ 391 | if pieces["closest-tag"]: 392 | rendered = pieces["closest-tag"] 393 | if pieces["distance"] or pieces["dirty"]: 394 | rendered += ".post%d" % pieces["distance"] 395 | if pieces["dirty"]: 396 | rendered += ".dev0" 397 | else: 398 | # exception #1 399 | rendered = "0.post%d" % pieces["distance"] 400 | if pieces["dirty"]: 401 | rendered += ".dev0" 402 | return rendered 403 | 404 | 405 | def render_git_describe(pieces): 406 | """TAG[-DISTANCE-gHEX][-dirty]. 407 | 408 | Like 'git describe --tags --dirty --always'. 409 | 410 | Exceptions: 411 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 412 | """ 413 | if pieces["closest-tag"]: 414 | rendered = pieces["closest-tag"] 415 | if pieces["distance"]: 416 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 417 | else: 418 | # exception #1 419 | rendered = pieces["short"] 420 | if pieces["dirty"]: 421 | rendered += "-dirty" 422 | return rendered 423 | 424 | 425 | def render_git_describe_long(pieces): 426 | """TAG-DISTANCE-gHEX[-dirty]. 427 | 428 | Like 'git describe --tags --dirty --always -long'. 429 | The distance/hash is unconditional. 430 | 431 | Exceptions: 432 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 433 | """ 434 | if pieces["closest-tag"]: 435 | rendered = pieces["closest-tag"] 436 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 437 | else: 438 | # exception #1 439 | rendered = pieces["short"] 440 | if pieces["dirty"]: 441 | rendered += "-dirty" 442 | return rendered 443 | 444 | 445 | def render(pieces, style): 446 | """Render the given version pieces into the requested style.""" 447 | if pieces["error"]: 448 | return {"version": "unknown", 449 | "full-revisionid": pieces.get("long"), 450 | "dirty": None, 451 | "error": pieces["error"], 452 | "date": None} 453 | 454 | if not style or style == "default": 455 | style = "pep440" # the default 456 | 457 | if style == "pep440": 458 | rendered = render_pep440(pieces) 459 | elif style == "pep440-pre": 460 | rendered = render_pep440_pre(pieces) 461 | elif style == "pep440-post": 462 | rendered = render_pep440_post(pieces) 463 | elif style == "pep440-old": 464 | rendered = render_pep440_old(pieces) 465 | elif style == "git-describe": 466 | rendered = render_git_describe(pieces) 467 | elif style == "git-describe-long": 468 | rendered = render_git_describe_long(pieces) 469 | else: 470 | raise ValueError("unknown style '%s'" % style) 471 | 472 | return {"version": rendered, "full-revisionid": pieces["long"], 473 | "dirty": pieces["dirty"], "error": None, 474 | "date": pieces.get("date")} 475 | 476 | 477 | def get_versions(): 478 | """Get version information or return default if unable to do so.""" 479 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 480 | # __file__, we can work backwards from there to the root. Some 481 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 482 | # case we can only use expanded keywords. 483 | 484 | cfg = get_config() 485 | verbose = cfg.verbose 486 | 487 | try: 488 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 489 | verbose) 490 | except NotThisMethod: 491 | pass 492 | 493 | try: 494 | root = os.path.realpath(__file__) 495 | # versionfile_source is the relative path from the top of the source 496 | # tree (where the .git directory might live) to this file. Invert 497 | # this to find the root from __file__. 498 | for i in cfg.versionfile_source.split('/'): 499 | root = os.path.dirname(root) 500 | except NameError: 501 | return {"version": "0+unknown", "full-revisionid": None, 502 | "dirty": None, 503 | "error": "unable to find root of source tree", 504 | "date": None} 505 | 506 | try: 507 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 508 | return render(pieces, cfg.style) 509 | except NotThisMethod: 510 | pass 511 | 512 | try: 513 | if cfg.parentdir_prefix: 514 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 515 | except NotThisMethod: 516 | pass 517 | 518 | return {"version": "0+unknown", "full-revisionid": None, 519 | "dirty": None, 520 | "error": "unable to compute version", "date": None} 521 | --------------------------------------------------------------------------------