├── src ├── pydemod │ ├── __init__.py │ ├── app │ │ ├── __init__.py │ │ ├── rds.py │ │ ├── weather_sensors.py │ │ └── amss.py │ ├── coding │ │ ├── __init__.py │ │ ├── crc.py │ │ ├── manchester.py │ │ ├── logic.py │ │ └── polynomial.py │ ├── filters │ │ ├── __init__.py │ │ └── shaping.py │ └── modulation │ │ ├── __init__.py │ │ ├── am.py │ │ └── phase.py ├── decode_amss.py ├── generate_rds.py ├── demodulate_rds.py └── decode_weather.py ├── .gitignore ├── .settings └── org.eclipse.core.resources.prefs ├── .project ├── .pydevproject └── README.md /src/pydemod/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | -------------------------------------------------------------------------------- /src/pydemod/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pydemod/coding/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pydemod/filters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pydemod/modulation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.settings/org.eclipse.core.resources.prefs: -------------------------------------------------------------------------------- 1 | #Thu Jul 28 00:40:04 CEST 2011 2 | eclipse.preferences.version=1 3 | encoding/=US-ASCII 4 | -------------------------------------------------------------------------------- /src/pydemod/coding/crc.py: -------------------------------------------------------------------------------- 1 | ''' 2 | CRC calculation routine 3 | ''' 4 | 5 | def crc(poly, deg, start, finalXor, block): 6 | allones = pow(2, deg) - 1 7 | crc = start 8 | for bit in block: 9 | msb = (crc >> (deg-1)) & 1 10 | crc = (crc << 1) & allones 11 | i = msb ^ bit 12 | if i != 0: 13 | crc = crc ^ poly 14 | return crc ^ finalXor 15 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | PyDemod 4 | 5 | 6 | 7 | 8 | 9 | org.python.pydev.PyDevBuilder 10 | 11 | 12 | 13 | 14 | 15 | org.python.pydev.pythonNature 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/pydemod/modulation/am.py: -------------------------------------------------------------------------------- 1 | # This file is part of Pydemod 2 | # Copyright Christophe Jacquet (F8FTK), 2014 3 | # Licence: GNU GPL v3 4 | # See: https://github.com/ChristopheJacquet/Pydemod 5 | 6 | import math 7 | import numpy 8 | 9 | def modulate(baseband_samples, sample_rate, frequency, phase=0): 10 | carrier = numpy.sin(2*math.pi * numpy.arange(len(baseband_samples)) * frequency / sample_rate + phase) 11 | return baseband_samples * carrier -------------------------------------------------------------------------------- /.pydevproject: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Default 6 | python 2.7 7 | 8 | /PyDemod/src 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/pydemod/coding/manchester.py: -------------------------------------------------------------------------------- 1 | 2 | def manchester_decode(pulseStream): 3 | i = 1 4 | #while pulseStream[i] != pulseStream[i-1]: 5 | # i = i + 1 6 | # print str(i) + " => " + str(pulseStream[i]) 7 | 8 | bits = [] 9 | 10 | # here pulseStream[i] is "guaranteed" to be the beginning of a bit 11 | while i < len(pulseStream): 12 | if pulseStream[i] == pulseStream[i-1]: 13 | # if so, sync has slipped 14 | # try to resync 15 | print(f"") 16 | i = i - 1 17 | bits.append(pulseStream[i] == 1) 18 | i = i + 2 19 | 20 | return bits 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Pydemod 2 | ------- 3 | 4 | Pydemod is a set of Python 3 libraries and tools for demodulating radio signals. It does not intend to compete with full-featured packages such as GNU Radio. Instead, it strives to allow radio enthusiasts to gain hands-on experience with modulation schemes. 5 | 6 | Pydemod relies on [NumPy](http://numpy.scipy.org/)/[SciPy](http://www.scipy.org/). 7 | 8 | Currently, the released modules include: 9 | * physical layer: 10 | * phase demodulation (_naïve_) 11 | * Manchester decoding 12 | * basic logical levels (TTL-like) decoding and clock synchronization 13 | * data link layer: 14 | * synchronization and error detection for polynomial codes 15 | * full implementation of [RDS](http://en.wikipedia.org/wiki/Radio_Data_System) and [AMSS](http://en.wikipedia.org/wiki/Amplitude_modulation_signalling_system) codes 16 | * CRC calculation 17 | * application layer: 18 | * functional AMSS decoder 19 | * functional temperature & humidity sensor decoder (supports protocols TX29 and Conrad) → [see blog post (in French)](https://jacquet.xyz/articles/2011/10/Decodage-capteur-thermo-hygro-TFA/) 20 | * You can very easily receive signals using an RTL-SDR dongle, using a command like this: `rtl_fm -M am -f 868.4M -s 160k - |./decode_weather.py --protocol tx29 --squelch 4000 --rawle -` 21 | 22 | Pydemod is licensed under the terms of the [GNU GPL v3](https://www.gnu.org/copyleft/gpl.html). 23 | 24 | ---- 25 | _Pydemod is developed by [Christophe Jacquet](https://jacquet.xyz/), F8FTK/HB9ITK._ 26 | -------------------------------------------------------------------------------- /src/pydemod/modulation/phase.py: -------------------------------------------------------------------------------- 1 | import numpy 2 | 3 | 4 | # naive phase demodulation 5 | def naive_phase_demod(samples): 6 | size = samples.size 7 | 8 | print(f"{size} samples, min={samples.min()}, max={samples.max()}") 9 | 10 | # find zero crossings 11 | signs = numpy.array(samples >= 0, int) # need to have integers 12 | 13 | differences = numpy.diff(signs) 14 | 15 | print(differences) 16 | 17 | crossings = numpy.nonzero(differences < 0)[0] 18 | 19 | # the first zero crossing is meaningless, since it does not count time since 20 | # another zero crossing, but since t = 0 21 | cumulWidths = crossings[1:] - crossings[0] 22 | print(f"cumulWidths = {cumulWidths}") 23 | 24 | # average period 25 | numPeriods = cumulWidths.size 26 | avgPeriod = cumulWidths[-1] / (1. * numPeriods) 27 | print(f"number of periods considered: {numPeriods}, average period length: {avgPeriod}") 28 | 29 | # deltaPhi 30 | deltaPhi = cumulWidths - numpy.linspace(avgPeriod, avgPeriod * numPeriods, numPeriods) 31 | print(f"deltaPhi = {deltaPhi} (sz={deltaPhi.size})") 32 | 33 | #filterNum = signal.firwin(5000, 1./5, window='hamming') 34 | #filterDen = 1 35 | #deltaPhiF = signal.lfilter(filterNum, filterDen, deltaPhi) 36 | 37 | # filter deltaPhi with a high-pass filter 38 | # TODO: properly design this filter 39 | # TODO: use scipy to apply this filter 40 | S = 50 41 | deltaPhiF = numpy.zeros(numPeriods) 42 | for i in range(S-1, numPeriods-S-1): 43 | win = deltaPhi[i-S//2 : i+S//2] 44 | # right bound is excluded in Python -> S samples 45 | deltaPhiF[i] = deltaPhi[i] - numpy.sum(win) / S 46 | 47 | return (avgPeriod, deltaPhiF) 48 | -------------------------------------------------------------------------------- /src/pydemod/coding/logic.py: -------------------------------------------------------------------------------- 1 | # This file is part of Pydemod 2 | # Copyright Christophe Jacquet (F8FTK), 2011, 2013 3 | # Licence: GNU GPL v3 4 | # See: https://code.google.com/p/pydemod/ 5 | 6 | 7 | import numpy 8 | 9 | def decode_0xAA_prefixed_frame(samples, sampleRate, bitrate=17258, verbose=False): 10 | threshold = numpy.mean(samples) 11 | 12 | if verbose: 13 | print(f"Min={min(samples)}, Max={max(samples)}, Threshold={threshold}") 14 | 15 | signs = numpy.array(samples > threshold, int) 16 | differences = numpy.diff(signs) 17 | changes = numpy.nonzero(differences)[0] 18 | 19 | if verbose: 20 | print(f"{changes.size} edges at instants: {changes}") 21 | 22 | bitlen = sampleRate / bitrate 23 | 24 | if verbose: 25 | print(f"Theoretical bit length: {bitlen} samples") 26 | 27 | result = numpy.array([]) 28 | 29 | i = (changes[0]+1) + bitlen/2 30 | bitpos = bitlen 31 | precBit = signs[i] 32 | 33 | # simple software PLL 34 | while i < samples.size: 35 | bit = signs[i] 36 | 37 | if precBit != bit: 38 | if verbose: 39 | print(f"Transition at {i}, expected at {bitlen/2-bitpos+i}") 40 | 41 | if bitpos < bitlen/2-1: 42 | bitpos += bitlen/10. 43 | if verbose: 44 | print("(+)") 45 | elif bitpos > bitlen/2+1: 46 | bitpos -= bitlen/10. 47 | if verbose: 48 | print("(-)") 49 | 50 | 51 | if bitpos >= bitlen: 52 | result = numpy.append(result, bit) 53 | bitpos -= bitlen 54 | 55 | if verbose: 56 | print(f"Sample at {i} => {bit} (bitpos={bitpos})") 57 | 58 | 59 | precBit = bit 60 | i += 1 61 | bitpos += 1 62 | return result.astype(int) 63 | -------------------------------------------------------------------------------- /src/decode_amss.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import scipy.io.wavfile as wavfile 4 | import numpy 5 | #from scipy import signal 6 | #import matplotlib.pyplot as mplot 7 | 8 | import pydemod.modulation.phase as phasemod 9 | import pydemod.coding.manchester as manchester 10 | import pydemod.coding.polynomial as poly 11 | import pydemod.app.amss as amss 12 | 13 | import sys 14 | 15 | 16 | # symbolLength: expressed in samples 17 | # (it is twice the bitrate of 46.875 since every bit is coded by 18 | # 2 manchester symbols 19 | def amss_deshape(signal, symbolLength): 20 | # logical version of the signal (0 - 1) 21 | logical = numpy.array(signal >= 0, int) 22 | changes = numpy.diff(logical) 23 | 24 | manchesterThreshold = 1.6*symbolLength 25 | 26 | changeInstants = numpy.nonzero(changes)[0] 27 | changeLengths = numpy.diff(changeInstants) 28 | 29 | print(f"changeLengths = {changeLengths}") 30 | 31 | # reconstruct pulse stream 32 | #pulseStream = changes[changeInstants] 33 | pulseStream = [] 34 | for i in range(changeLengths.size): 35 | if changeLengths[i] > manchesterThreshold and len(pulseStream) > 0: 36 | pulseStream.append(pulseStream[-1]) 37 | pulseStream.append(changes[changeInstants[i+1]]) 38 | 39 | return pulseStream 40 | 41 | 42 | 43 | 44 | ##### MAIN PROGRAM ##### 45 | 46 | (sampleRate, samples) = wavfile.read(sys.argv[1]) 47 | 48 | (avgPeriod, deltaPhiF) = phasemod.naive_phase_demod(samples) 49 | 50 | manchesterPeriod = sampleRate / 46.875 / avgPeriod / 2 51 | 52 | bits = manchester.manchester_decode(amss_deshape(deltaPhiF, manchesterPeriod)) 53 | 54 | 55 | # find pair-impulse sync 56 | # must find 2 pulses with the same value 57 | 58 | 59 | #print bits 60 | 61 | word_stream = poly.amss_code.bitstream_to_wordstream(bits) 62 | 63 | print(word_stream) 64 | 65 | 66 | s = amss.Station() 67 | 68 | s.process_stream(word_stream) 69 | 70 | 71 | #mplot.plot(deltaPhi) 72 | #mplot.plot(deltaPhiF) 73 | #mplot.show() 74 | -------------------------------------------------------------------------------- /src/pydemod/app/rds.py: -------------------------------------------------------------------------------- 1 | # This file is part of Pydemod 2 | # Copyright Christophe Jacquet (F8FTK), 2014 3 | # Licence: GNU GPL v3 4 | # See: https://github.com/ChristopheJacquet/Pydemod 5 | 6 | import numpy 7 | 8 | import pydemod.coding.polynomial as poly 9 | import pydemod.filters.shaping as shaping 10 | 11 | def generate_basic_wordstream(pi, psName): 12 | """ 13 | Generates a basic RDS stream composed of 0B groups for a given PI code 14 | and station name. 15 | """ 16 | 17 | if pi < 0 or pi > 0xFFFF: 18 | raise Exception("PI code must be between 0x0000 and 0xFFFF") 19 | 20 | if len(psName) > 8: 21 | raise Exception("PS name must not be more than 8 characters long") 22 | 23 | psName += " " * (8 - len(psName)) 24 | 25 | while True: 26 | for i in range(4): 27 | yield ('A', pi & 0xFFFF) 28 | 29 | yield ('B', 0x0800 | i) 30 | 31 | yield ("C'", pi & 0xFFFF) 32 | 33 | yield ('D', (ord(psName[i*2])<<8) + ord(psName[i*2+1])) 34 | 35 | 36 | 37 | def bitstream(gen, seconds): 38 | wordstream = [next(gen) for i in range(int(seconds * 1187.5 / 26))] 39 | return poly.rds_code.wordstream_to_bitstream(wordstream) 40 | 41 | 42 | def pulse_shaping_filter(length, sample_rate): 43 | return shaping.rrcosfilter(length, 1, 1/(2*1187.5), sample_rate+1) [1] 44 | 45 | 46 | def unmodulated_signal(bitstream, sample_rate = 228000): 47 | samples_per_bit = int(sample_rate / 1187.5) 48 | 49 | # Differentially encode 50 | diffbs = numpy.zeros(len(bitstream), dtype=int) 51 | for i in range(1, len(bitstream)): 52 | if diffbs[i-1] != bitstream[i]: 53 | diffbs[i] = 1 54 | 55 | # Positive symbol pattern 56 | symbol = numpy.zeros(samples_per_bit) 57 | symbol[0] = 1 58 | symbol[samples_per_bit//2-1] = -1 59 | 60 | # Generate the sample array 61 | samples = numpy.tile(symbol, len(diffbs)) 62 | for i in range(len(diffbs)): 63 | if diffbs[i] == 0: 64 | samples[i * samples_per_bit : (i+1)*samples_per_bit] *= -1 65 | 66 | # Apply the data-shaping filter 67 | shapedSamples = numpy.convolve(samples, pulse_shaping_filter(samples_per_bit*2, sample_rate)) 68 | 69 | return shapedSamples -------------------------------------------------------------------------------- /src/pydemod/filters/shaping.py: -------------------------------------------------------------------------------- 1 | # This file is part of Pydemod 2 | # Copyright Christophe Jacquet (F8FTK), 2014 3 | # Licence: GNU GPL v3 4 | # See: https://github.com/ChristopheJacquet/Pydemod 5 | # 6 | # Contains code from CommPy, used under the terms of the GPL 7 | # https://github.com/veeresht/CommPy/blob/master/commpy/filters.py 8 | # (c) 2012 Veeresh Taranalli 9 | 10 | import numpy 11 | 12 | # The following function is from: 13 | # https://github.com/veeresht/CommPy/blob/master/commpy/filters.py 14 | # See also: https://en.wikipedia.org/wiki/Root-raised-cosine_filter 15 | def rrcosfilter(N, alpha, Ts, Fs): 16 | """ 17 | Generates a root raised cosine (RRC) filter (FIR) impulse response. 18 | 19 | Parameters 20 | ---------- 21 | N : int 22 | Length of the filter in samples. 23 | 24 | alpha: float 25 | Roll off factor (Valid values are [0, 1]). 26 | 27 | Ts : float 28 | Symbol period in seconds. 29 | 30 | Fs : float 31 | Sampling Rate in Hz. 32 | 33 | Returns 34 | --------- 35 | 36 | h_rrc : 1-D ndarray of floats 37 | Impulse response of the root raised cosine filter. 38 | 39 | time_idx : 1-D ndarray of floats 40 | Array containing the time indices, in seconds, for 41 | the impulse response. 42 | """ 43 | 44 | T_delta = 1/float(Fs) 45 | time_idx = ((numpy.arange(N)-N/2))*T_delta 46 | sample_num = numpy.arange(N) 47 | h_rrc = numpy.zeros(N, dtype=float) 48 | 49 | for x in sample_num: 50 | t = (x-N/2)*T_delta 51 | if t == 0.0: 52 | h_rrc[x] = 1.0 - alpha + (4*alpha/numpy.pi) 53 | elif alpha != 0 and t == Ts/(4*alpha): 54 | h_rrc[x] = (alpha/numpy.sqrt(2))*(((1+2/numpy.pi)* \ 55 | (numpy.sin(numpy.pi/(4*alpha)))) + ((1-2/numpy.pi)*(numpy.cos(numpy.pi/(4*alpha))))) 56 | elif alpha != 0 and t == -Ts/(4*alpha): 57 | h_rrc[x] = (alpha/numpy.sqrt(2))*(((1+2/numpy.pi)* \ 58 | (numpy.sin(numpy.pi/(4*alpha)))) + ((1-2/numpy.pi)*(numpy.cos(numpy.pi/(4*alpha))))) 59 | else: 60 | h_rrc[x] = (numpy.sin(numpy.pi*t*(1-alpha)/Ts) + \ 61 | 4*alpha*(t/Ts)*numpy.cos(numpy.pi*t*(1+alpha)/Ts))/ \ 62 | (numpy.pi*t*(1-(4*alpha*t/Ts)*(4*alpha*t/Ts))/Ts) 63 | 64 | return time_idx, h_rrc 65 | -------------------------------------------------------------------------------- /src/generate_rds.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This file is part of Pydemod 4 | # Copyright Christophe Jacquet (F8FTK), 2014 5 | # Licence: GNU GPL v3 6 | # See: https://github.com/ChristopheJacquet/Pydemod 7 | 8 | import argparse 9 | import numpy 10 | import numpy.random as random 11 | import scipy.io.wavfile as wavfile 12 | from scipy import signal 13 | 14 | import pydemod.app.rds as rds 15 | import pydemod.modulation.am as am 16 | 17 | 18 | parser = argparse.ArgumentParser(description='Generates RDS bitstreams, or RDS baseband samples') 19 | 20 | parser.add_argument("--ps", type=str, default="PyDemod", help='Program Service Name') 21 | parser.add_argument("--pi", type=str, default="FFFF", help='PI code') 22 | parser.add_argument("--len", type=int, default=2, help='Duration in seconds') 23 | parser.add_argument("--bitstream", help='Generates a bitstream', action="store_true") 24 | parser.add_argument("--unmodulated", help='Generates the unmodulated signal at 228 kHz', action="store_true") 25 | parser.add_argument("--baseband", help='Generates basedband samples at 228 kHz', action="store_true") 26 | parser.add_argument("--phase", type=float, default=0, help='Phase of the 57 kHz carrier in radians (use in cunjunction with --baseband)') 27 | parser.add_argument("--frequency", type=float, default=57000, help='Frequency of the "57 kHz" carrier in hertz (use in cunjunction with --baseband)') 28 | parser.add_argument("--noise", type=float, default=0, help='Relative noise. RDS signal is 1. (use in cunjunction with --baseband)') 29 | parser.add_argument("--tune", type=float, default=None, help='Add a tune at the given frequency') 30 | parser.add_argument("--ootune", type=float, default=None, help='At an on/off tune at the given frequency, with a half-period of 1 second') 31 | parser.add_argument("--wavout", type=str, default=None, help='Output WAV file') 32 | 33 | args = parser.parse_args() 34 | 35 | bitstream = rds.bitstream(rds.generate_basic_wordstream(int(args.pi, 16), args.ps), args.len) 36 | 37 | if args.bitstream: 38 | print("".join(map(str, bitstream))) 39 | elif args.unmodulated or args.baseband: 40 | if args.wavout == None: 41 | print("--wavout required") 42 | exit() 43 | sample_rate = 228000 44 | 45 | shapedSamples = rds.unmodulated_signal(bitstream, sample_rate) 46 | 47 | if args.unmodulated: 48 | out = shapedSamples 49 | elif args.baseband: 50 | out = am.modulate(shapedSamples, sample_rate, args.frequency, args.phase) 51 | if args.tune: 52 | out += 2 * numpy.sin(2*numpy.pi * args.tune * numpy.arange(len(out)) / sample_rate) 53 | elif args.ootune: 54 | out += numpy.sin(2*numpy.pi * args.ootune * numpy.arange(len(out)) / sample_rate) * (1 + signal.square(2*numpy.pi * .5 * numpy.arange(len(out)) / sample_rate)) 55 | if args.noise > 0: 56 | out = out + random.rand(len(out)) * args.noise*max(abs(out)) 57 | 58 | iout = (out * 20000./max(abs(out)) ).astype(numpy.dtype('>i2')) 59 | 60 | wavfile.write(args.wavout, sample_rate, iout) 61 | else: 62 | print("Either --bitstream or --unmodulated or --baseband must be provided.") 63 | -------------------------------------------------------------------------------- /src/pydemod/coding/polynomial.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of Pydemod 4 | # Copyright Christophe Jacquet (F8FTK), 2011, 2014 5 | # Licence: GNU GPL v3 6 | # See: https://github.com/ChristopheJacquet/Pydemod 7 | 8 | import numpy 9 | 10 | class Code: 11 | def __init__(self, poly, word_size, offset_words): 12 | self.word_size = word_size 13 | self.poly = numpy.array(poly, dtype=int) 14 | self.offset_words = offset_words 15 | 16 | # calculate the P matrix by polynomial division 17 | # each row is: e(i)*x^10 mod rds_poly 18 | # where e(i) is the i-th base vector in the canonical orthogonal base 19 | self.check_size = self.poly.size - 1 20 | self.matP = numpy.empty([0, self.check_size], dtype=int) 21 | for i in range(word_size): 22 | (q, r) = numpy.polydiv(numpy.identity(self.word_size+self.check_size, dtype=int)[i], self.poly) 23 | #print q, r 24 | # r may be "left-trimmed" => add missing zeros 25 | if self.check_size - r.size > 0: 26 | #print r 27 | #print numpy.zeros(check_size - r.size) 28 | r = numpy.append(numpy.zeros(self.check_size - r.size, dtype=int), r) 29 | 30 | rr = numpy.mod(numpy.array([r], dtype=int), 2) 31 | self.matP = numpy.append(self.matP, rr, axis=0) 32 | 33 | self.matG = numpy.append(numpy.identity(self.word_size, dtype=int), self.matP, axis=1) 34 | self.matH = numpy.append(self.matP, numpy.identity(self.check_size, dtype=int), axis=0) 35 | 36 | #self.offset_words = numpy.array(offset_words, dtype=int) 37 | self.syndromes = {} 38 | for ow_name, ow in offset_words.items(): 39 | # actually it's useless to call syndrome here, because of the way 40 | # our H is constructed. Do the block-wise matrix multiplication 41 | # to be convinced of this. 42 | self.syndromes[ow_name] = self.syndrome(numpy.append(numpy.zeros(self.word_size, dtype=int), numpy.array(ow, dtype=int))) 43 | 44 | 45 | def syndrome(self, v): 46 | return numpy.mod(numpy.dot(v, self.matH), 2) 47 | 48 | 49 | def bitstream_to_wordstream(self, bitstream): 50 | bits = numpy.array(bitstream, dtype=int) 51 | wordStream = [] 52 | i = self.word_size+self.check_size 53 | while i <= bits.size: 54 | candidate = bits[i-self.word_size-self.check_size:i] 55 | for sname, synd in self.syndromes.items(): 56 | if (synd == self.syndrome(candidate)).all(): 57 | wordStream.append((sname, candidate[0:self.word_size])) 58 | i = i + self.word_size + self.check_size - 1 59 | i = i + 1 60 | return wordStream 61 | 62 | 63 | def wordstream_to_bitstream(self, wordstream): 64 | def to_bin(x): 65 | res = [] 66 | for i in range(self.word_size): 67 | res.insert(0, x % 2) 68 | x >>= 1 69 | return res 70 | 71 | # Construct the matrix of binary words for the given wordstream, one word per 72 | # row, one bit per column. 73 | words = numpy.array(list(map(lambda offset_word: to_bin(offset_word[1]), wordstream))) 74 | 75 | # Construct the matrix of offset words (as many offset words as data words), 76 | # using the same convention. 77 | offsets = numpy.array(list(map(lambda offset_word: numpy.array(self.offset_words[offset_word[0]]), wordstream))) 78 | 79 | # We get the bitstream by matrix-multiplying (in Z/2Z) the matrix of words 80 | # (one word per row) with G. This gives out the matrix of the words+checksums, 81 | # one word+checksum per row. 82 | bitstream = numpy.dot(numpy.array(words), self.matG) % 2 83 | 84 | # Now we need to add the offset words to the checksums, i.e. to the columns 85 | # self.word_size to self.word_size+self.check_size-1. 86 | bitstream[:,self.word_size:] ^= offsets 87 | 88 | return bitstream.flatten() 89 | 90 | 91 | def __repr__(self): 92 | return "Poly = " + repr(self.poly) + "\nWord size = " + repr(self.word_size) + "\nP = " + repr(self.matP) + "\nG = " + repr(self.matG) + "\nH = " + repr(self.matH) + "\nSyndromes = " + repr(self.syndromes) 93 | 94 | 95 | def __str__(self): 96 | return repr(self) 97 | 98 | 99 | amss_code = Code([1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1], 36, {1: [0, 1, 0, 1, 1, 0, 1, 0, 1, 0, 1], 2:[1, 0, 1, 1, 0, 1, 0, 1, 0, 1, 1]}) 100 | rds_code = Code([1, 0, 1, 1, 0, 1, 1, 1, 0, 0, 1], 16, {'A': [0, 0, 1, 1, 1, 1, 1, 1, 0, 0], 'B': [0, 1, 1, 0, 0, 1, 1, 0, 0, 0], 'C': [0, 1, 0, 1, 1, 0, 1, 0, 0, 0], "C'": [1, 1, 0, 1, 0, 1, 0, 0, 0, 0], 'D': [0, 1, 1, 0, 1, 1, 0, 1, 0, 0]}) 101 | 102 | 103 | 104 | def main(): 105 | print(amss_code) 106 | 107 | if __name__ == "__main__": 108 | main() -------------------------------------------------------------------------------- /src/demodulate_rds.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This file is part of Pydemod 4 | # Copyright Christophe Jacquet (F8FTK), 2014 5 | # Licence: GNU GPL v3 6 | # See: https://github.com/ChristopheJacquet/Pydemod 7 | 8 | 9 | # Sources of documentation: 10 | # http://www.db-thueringen.de/servlets/DerivateServlet/Derivate-18898/54_IWK_2009_1_0_08.pdf 11 | # http://dsp.stackexchange.com/questions/8456/how-to-perform-carrier-phase-recovery-in-software#8462 12 | 13 | from scipy import signal 14 | import numpy 15 | import matplotlib.pyplot as plt 16 | import math 17 | import cmath 18 | 19 | import scipy.io.wavfile as wavfile 20 | 21 | import sys 22 | 23 | import io 24 | 25 | import argparse 26 | 27 | import pydemod.filters.shaping as shaping_filter 28 | 29 | 30 | parser = argparse.ArgumentParser(description='Demodulates RDS bitstreams from FM multiplex basedband', epilog='The demodulated bitstream may be decoded using, for instance, RDS Surveyor: java -jar rdssurveyor.jar -inbinstrfile bitstream_file') 31 | 32 | parser.add_argument("--input", type=str, default=None, help='Input WAV file at 228 kHz') 33 | parser.add_argument("--output", type=str, default=None, help="Output bitstream (text file composed of 0's and 1's)") 34 | 35 | args = parser.parse_args() 36 | 37 | if args.input == None or args.output == None: 38 | print("Must provide --input and --output") 39 | 40 | 41 | # captured using: rtl_fm -f 87.8M -s 228000 -l 0 fi878.raw 42 | 43 | sampleRate, samples = wavfile.read(args.input) 44 | 45 | if sampleRate != 228000: 46 | print("Only supports WAV files at 228 kHz currently.") 47 | 48 | samples = samples.astype(float) / max(abs( samples )) 49 | 50 | 51 | print( "Sample rate: {} Hz, duration: {} s".format(sampleRate, len(samples) / float(sampleRate)) ) 52 | 53 | 54 | filt57k = signal.remez(512, numpy.array([0, 53000, 54000, 60000, 61000, sampleRate/2]), numpy.array([0, 1, 0]), fs = sampleRate) 55 | 56 | rdsBand = signal.convolve(samples, filt57k) 57 | 58 | 59 | # quadrature sampling 60 | 61 | # ensure array is of length 4*N 62 | rdsBand = rdsBand[:(len(rdsBand)//4)*4] 63 | 64 | c57 = numpy.tile( [1., -1.], len(rdsBand)//4 ) 65 | 66 | xi = rdsBand[::2] * c57 67 | xq = rdsBand[1::2] * (-c57) 68 | 69 | 70 | # low-pass filter 71 | 72 | filtLP = signal.remez(400, [0, 2400, 3000, sampleRate/4], [1, 0], fs=sampleRate/2) 73 | 74 | xfi = signal.convolve(xi, filtLP) 75 | xfq = signal.convolve(xq, filtLP) 76 | 77 | 78 | # shape filter 79 | 80 | shapeFilt = shaping_filter.rrcosfilter(300, 1, 1/(2*1187.5), sampleRate/2) [1] 81 | 82 | xsfi = signal.convolve(xfi, shapeFilt) 83 | xsfq = signal.convolve(xfq, shapeFilt) 84 | 85 | 86 | if len(xsfi) % 2 == 1: 87 | xsfi = xsfi[:-1] 88 | xsfq = xsfq[:-1] 89 | 90 | # downsample 91 | 92 | xdi = (xsfi[::2] + xsfi[1::2]) / 2 93 | xdq = xsfq[::2] 94 | 95 | 96 | 97 | # remove phase drift using a moving average 98 | 99 | smooth = 1/200. * numpy.ones(200) 100 | 101 | angles = numpy.where(xdi >= 0, numpy.arctan2(xdq, xdi), numpy.arctan2(-xdq, -xdi)) 102 | 103 | theta = (signal.convolve(angles, smooth)) [-len(xdi):] 104 | 105 | xr = (xdi + 1j * xdq) * numpy.exp(-1j * theta) 106 | 107 | 108 | # binarize the phase modulation 109 | 110 | bi = (numpy.real(xr) >= 0) + 0 111 | 112 | 113 | # sample symbols using a discrete PLL 114 | 115 | #sampleInstants = numpy.zeros(len(bi)) 116 | 117 | period = 24 118 | halfPeriod = period/2 119 | corr = period / 24. 120 | phase = 0 121 | 122 | res = "" 123 | pin = 0 124 | 125 | stats = {0: 0, 1: 1} 126 | oddity = 0 127 | 128 | latestXrSquared = [0]*8 129 | lxsIndex = 0 130 | theta = [0] 131 | #thetaShift = [0] 132 | shift = 0 133 | 134 | #xpi = [] 135 | #xpq = [] 136 | #xppi = [] 137 | #xppq = [] 138 | 139 | 140 | for i in range(1, len(bi)): 141 | if bi[i-1] != bi[i]: # transition 142 | if phase < halfPeriod-2: 143 | phase += corr 144 | #sampleInstants[i] = .5 145 | elif phase > halfPeriod+2: 146 | phase -= corr 147 | #sampleInstants[i] = -.5 148 | else: 149 | pass 150 | #sampleInstants[i] = .1 151 | if phase >= period: 152 | #sampleInstants[i] = 2*bi[i]-1 153 | phase -= period 154 | 155 | latestXrSquared[lxsIndex] = (xdi[i] + 1j * xdq[i])**2 156 | lxsIndex += 1 157 | if lxsIndex >= len(latestXrSquared): 158 | lxsIndex = 0 159 | 160 | th = shift + cmath.phase(sum(latestXrSquared)) / 2 161 | if abs(th - theta[-1]) > 2: 162 | if th < theta[-1]: 163 | shift += math.pi 164 | th += math.pi 165 | #thetaShift.append(10) 166 | else: 167 | shift -= math.pi 168 | th -= math.pi 169 | #thetaShift.append(-10) 170 | #bitphase ^= 1 171 | else: 172 | pass 173 | #thetaShift.append(0) 174 | theta.append(th) 175 | 176 | oddity += 1 177 | 178 | if oddity == 2: 179 | oddity = 0 180 | 181 | yp = (xdi[i] + 1j * xdq[i]) 182 | #xpi.append(yp.real) 183 | #xpq.append(yp.imag) 184 | 185 | ypp = cmath.exp(-1j * th) * yp 186 | #xppi.append(ypp.real) 187 | #xppq.append(ypp.imag) 188 | 189 | # bit decode 190 | nin = 1 * (ypp.real > 0) 191 | stats[nin] += 1 192 | res += "{}".format(pin ^ nin) 193 | pin = nin 194 | 195 | 196 | phase += 1 197 | 198 | # write output 199 | 200 | f = io.open(args.output, "w") 201 | f.write(res) 202 | f.close() 203 | -------------------------------------------------------------------------------- /src/pydemod/app/weather_sensors.py: -------------------------------------------------------------------------------- 1 | # This file is part of Pydemod 2 | # Copyright Christophe Jacquet (F8FTK), 2011, 2013, 2016 3 | # Copyright Krzysztof Burghardt, 2016 4 | # Licence: GNU GPL v3 5 | # See: https://code.google.com/p/pydemod/ 6 | 7 | 8 | import pydemod.coding.crc as crc 9 | import numpy 10 | 11 | 12 | class Report: 13 | def __init__(self, temperature, humidity, type, id, channel=None): 14 | self.temperature = temperature 15 | self.humidity = humidity 16 | self.type = type 17 | self.id = id 18 | self.channel = channel 19 | 20 | def __eq__(self, other): 21 | return (self.temperature == other.temperature and self.humidity == other.humidity and 22 | self.type == other.type and self.id == other.id and self.channel == other.channel) 23 | 24 | def __str__(self): 25 | return "//{}/{:02X}{}: Temp={:+0.1f} C, Humid={} %".format( 26 | self.type, self.id, "/{}".format(self.channel) if self.channel != None else "", 27 | self.temperature, self.humidity) 28 | 29 | def __hash__(self): 30 | return hash(repr(self)) 31 | 32 | 33 | def most_frequent_report(reports): 34 | counts = {} 35 | most_frequent_report = None 36 | most_frequent_count = 0 37 | for r in reports: 38 | counts[r] = counts.setdefault(r, 0) + 1 39 | if counts[r] > most_frequent_count: 40 | most_frequent_count = counts[r] 41 | most_frequent_report = r 42 | return most_frequent_report 43 | 44 | 45 | def take(vec, u, l): 46 | return numpy.dot( numpy.power(2, numpy.arange(l-1, -1, -1)), vec[u:u+l]) 47 | 48 | 49 | def int_from_bits(bits): 50 | res = 0 51 | for b in bits: 52 | res = (res<<1) + int(b) 53 | return res 54 | 55 | 56 | def parse_bitfield(descriptor, bitfield): 57 | # Check if bitfield has the length prescribed by the descriptor. 58 | if sum(d[1] for d in descriptor) != len(bitfield): 59 | return None 60 | pos = 0 61 | result = {} 62 | for d in descriptor: 63 | if len(d) == 2: 64 | field, length = d 65 | endianness = True # Big Endian 66 | elif len(d) == 3: 67 | field, length, endianness = d 68 | if field: 69 | result[field] = int_from_bits(bitfield[pos:pos+length] if endianness else bitfield[pos+length-1:pos-1:-1]) 70 | pos += length 71 | return result 72 | 73 | 74 | # 75 | # Frame structure on an example: 76 | # AA - sync byte (sometimes multiple sync bytes) 77 | # 2D \ presumably a vendor/sensor type identifier 78 | # D4 / 79 | # 96 \ follows the same protocol as LaCrosse's TX29 -> 9 nibbles following 80 | # 45 | 0x64 -> sensor ID ? 81 | # 47 | 0x574 -> (temperature + 40) * 10 in BCD --> 17.4 C 82 | # 4D | 0x4D -> 77 % humidity 83 | # 0B / 84 | # The CRC is calculated on the last 4 bytes before the 1-byte CRC 85 | # 86 | # CRC parameters: 8 bits, polynomial x^8 + x^5 + x^4 + 1 (0x31) 87 | # initial value 0, final xor value 0 88 | # 89 | def decode_tx29(binaryMsg): 90 | ''' 91 | Decoding a LaCrosse/TFA sensor message. 92 | ''' 93 | # lengths counts 4 bits (nibbles) excluding 1st one 94 | givenLength = take(binaryMsg, 0, 4) 95 | actualLength = len(binaryMsg) / 4 - 1 96 | print("Length: actual={:02X}, received={:02X}, valid={}".format(actualLength, givenLength, "yes" if (givenLength == actualLength) else "no")) 97 | givenCRC = take(binaryMsg, binaryMsg.size-8, 8) 98 | computedCRC = crc.crc(0x31, 8, 0, 0, binaryMsg[:-8]) 99 | print("CRC: computed={:02X}, received={:02X}, valid={}".format(computedCRC, givenCRC, "yes" if (givenCRC == computedCRC) else "no")) 100 | devID = take(binaryMsg, 4, 6) 101 | print("Device: id={:02X}".format(devID)) 102 | newBattery = take(binaryMsg, 10, 1) 103 | weakBattery = take(binaryMsg, 24, 1) 104 | print("Battery: new={}, weak={}".format("yes" if newBattery else "no", "yes" if weakBattery else "no")) 105 | temperature = (take(binaryMsg, 12, 4) * 10 + take(binaryMsg, 16, 4) + take(binaryMsg, 20, 4) * .1) - 40 106 | reserved = take(binaryMsg, 11, 1) 107 | print("Reserved 0 bit: received={:01X}, valid={}".format(reserved, "yes" if (reserved == 0) else "no")) 108 | humidity = take(binaryMsg, 25, 7) 109 | if humidity == 106: 110 | humidity = "N/A" 111 | print("Temperature: {:+0.1f} C -- Humidity: {} %".format(temperature, humidity)) 112 | if givenCRC == computedCRC: 113 | return Report(temperature, humidity, "TX29", devID) 114 | 115 | 116 | def conrad_crc(msg, final): 117 | c = 0 118 | for bit in msg: 119 | if int(bit) != (c&1): 120 | c = (c>>1) ^ 12 121 | else: 122 | c = c>>1 123 | return c ^ final 124 | 125 | 126 | def decode_conrad(message): 127 | """ 128 | TFA Dostmann 30.3200, s014, TCM, Conrad. 129 | """ 130 | data = parse_bitfield([ 131 | (None, 2), 132 | ("id", 8), 133 | (None, 2), 134 | ("channel", 2), 135 | ("temp_a", 4), 136 | ("temp_b", 4), 137 | ("temp_c", 4), 138 | ("humid_a", 4), 139 | ("humid_b", 4), 140 | ("final", 4, False), 141 | ("crc", 4, False),], message) 142 | # If the message does not have the expected length, return. 143 | if data == None: 144 | return 145 | temperature = ((data["temp_c"]*256 + data["temp_b"]*16 + data["temp_a"])/10. - 90 - 32) * 5/9. 146 | humidity = data["humid_b"]*16 + data["humid_a"] 147 | 148 | crc = conrad_crc(message[2:34], data["final"]) 149 | 150 | print("Id={:02X}, Ch={}, Temp={:+0.1f} C, Humid={} %, CRC={} vs {}".format( 151 | data["id"], data["channel"], temperature, humidity, crc, data["crc"])) 152 | 153 | if crc == data["crc"]: 154 | return Report(temperature, humidity, "Conrad", data["id"], data["channel"]) -------------------------------------------------------------------------------- /src/decode_weather.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This file is part of Pydemod 4 | # Copyright Christophe Jacquet (F8FTK), 2011, 2013, 2016 5 | # Licence: GNU GPL v3 6 | # See: https://code.google.com/p/pydemod/ 7 | 8 | # Examples: 9 | # rtl_fm -M am -f 434.05M -s 160k - |./decode_weather.py --protocol conrad --squelch 4000 --rawle - 10 | # rtl_fm -M am -f 868.4M -s 160k - |./decode_weather.py --protocol tx29 --squelch 4000 --rawle - 11 | 12 | import pydemod.coding.logic as logic 13 | from pydemod.app import weather_sensors 14 | 15 | import scipy.io.wavfile as wavfile 16 | import numpy 17 | 18 | import sys 19 | import io 20 | import argparse 21 | import struct 22 | 23 | 24 | parser = argparse.ArgumentParser(description='Decodes TFA temperature and humidity sensors') 25 | 26 | parser.add_argument("--protocol", type=str, default="", help='Protocol: tx29 or conrad') 27 | parser.add_argument("--bitrate", type=int, default=17200, help='Bit rate of the transmission, in bit/s') 28 | parser.add_argument("--synclen", type=int, default=8, help='Number of sync bits (these bits will be ignored)') 29 | parser.add_argument("--wav", type=str, default="", help='Run in WAV mode. Expects a WAV file that contains a single data frame') 30 | parser.add_argument("--raw", type=str, default="", help='Run in raw mode, continuously. Expects a raw file sampled at 160 kHz, 16-bit signed big endian. Use "-" to read from stdin. \nExample: rtl_fm -M am -f 868.4M -s 160k - |./decode_weather.py --squelch 4000 --raw -') 31 | parser.add_argument("--rawle", type=str, default="", help='Same as --raw, but reads 16-bit signed *little* endian samples') 32 | parser.add_argument("--squelch", type=int, default=4000, help='Squelch level for raw mode') 33 | parser.add_argument("--window_duration", type=int, default=None, help='Squelch window duration, in samples') 34 | parser.add_argument("--passthrough", type=str, default='') 35 | parser.add_argument("--verbose", help='Print additional debug messages', action="store_true") 36 | 37 | args = parser.parse_args() 38 | 39 | 40 | def rx_tx29(samples, sampleRate, unused_squelch): 41 | frame = logic.decode_0xAA_prefixed_frame(samples, sampleRate, bitrate=bitrate, verbose=args.verbose) 42 | 43 | if frame.size < framelen: 44 | frame = numpy.append(frame, [0] * (framelen - frame.size)) 45 | 46 | print("Frame: size {0} bits, contents {1}, framelen={2}".format(frame.size, "".join(map(str, frame.tolist())), framelen)) 47 | 48 | if frame.size >= framelen: 49 | frame = frame[:framelen] 50 | # reconstruct bytes 51 | matrix = numpy.mat(numpy.reshape(frame, (framelen/8, 8))) 52 | byteSeq = matrix * numpy.mat("128;64;32;16;8;4;2;1") 53 | allBytes = [int(byteSeq[i]) for i in range(0,len(byteSeq))] 54 | print("Frame hex contents: " + " ".join(["{0:02X}".format(b) for b in allBytes])) 55 | 56 | return weather_sensors.decode_tx29(frame[(synclen + 16):]) 57 | 58 | 59 | def rx_conrad(samples, sampleRate, squelch): 60 | threshold = squelch #numpy.mean(samples) 61 | binarized = numpy.array(samples > threshold, dtype=int) 62 | diff = binarized[1:] - binarized[:-1] 63 | edges = ((diff > 0).nonzero())[0] 64 | lengths = edges[1:] - edges[:-1] 65 | reports = [] 66 | s = "" 67 | for l in lengths: 68 | if l < 550: 69 | s += "0" 70 | elif l < 1000: 71 | s += "1" 72 | else: 73 | if len(s) > 1: 74 | print(s) 75 | r = weather_sensors.decode_conrad(s) 76 | s = "" 77 | if r: 78 | reports.append(r) 79 | return weather_sensors.most_frequent_report(reports) 80 | 81 | 82 | def decode(callback=None): 83 | global bitrate, synclen, framelen 84 | 85 | bitrate = args.bitrate 86 | synclen = args.synclen 87 | 88 | if args.protocol == "tx29": 89 | rx = rx_tx29 90 | framelen = 56 + synclen 91 | repeats = 1 92 | elif args.protocol == "conrad": 93 | rx = rx_conrad 94 | framelen = 42 95 | repeats = 6 96 | bitrate = 200 97 | 98 | print("Bitrate: {0}, synclen: {1}, framelen: {2}".format(bitrate, synclen, framelen)) 99 | 100 | if len(args.wav) > 0: 101 | (sampleRate, samples) = wavfile.read(args.wav) 102 | # FIXME decode(samples, sampleRate) 103 | elif len(args.raw) > 0 or len(args.rawle) > 0: 104 | if len(args.raw) > 0: 105 | filename = args.raw 106 | else: 107 | filename = args.rawle 108 | if(filename == "-"): 109 | filename = sys.stdin.fileno() 110 | srate = 160000 111 | fin = io.open(filename, mode="rb") 112 | 113 | framedur = 1.2 * ( framelen / float(bitrate) * srate ) * repeats 114 | print("Using frame duration {0:0.1f} samples".format(framedur)) 115 | print("Squelch at {0}, should be slightly greater than usual max; use --squelch to change".format(args.squelch)) 116 | 117 | inFrameCount = 0 118 | 119 | num = int(srate/10) 120 | if len(args.raw) > 0: 121 | fmt = f">{num}H" 122 | else: 123 | fmt = f"<{num}H" 124 | 125 | while True: 126 | b = fin.read(num*2) 127 | if len(b) == num*2: 128 | vals = struct.unpack(fmt, b) 129 | 130 | vsum = sum(vals) 131 | vmin = min(vals) 132 | vmax = max(vals) 133 | nvals = numpy.array(vals) 134 | aboveSquelch = (nvals >= args.squelch).sum() 135 | fracAboveSquelch = float(aboveSquelch) / len(vals) 136 | percentile90 = numpy.percentile(nvals, 95) 137 | 138 | sys.stdout.write("\rMin: {} - Mean: {} - Max: {} - 90th percentile: {} - Above squelch: {} % ".format(vmin, vsum / num, vmax, percentile90, int(fracAboveSquelch*100))) 139 | sys.stdout.flush() 140 | 141 | # Iterate over all samples if currently decoding frame or if a sample 142 | # opens the squelch. 143 | if inFrameCount > 0 or vmax > args.squelch: 144 | for val in vals: 145 | if(inFrameCount > 0): 146 | frameSamples.append(val) 147 | inFrameCount += 1 148 | if(inFrameCount > framedur): 149 | print("\n---------------------------------------") 150 | report = rx(numpy.array(frameSamples, dtype=float), srate, args.squelch) 151 | print("==> {}".format(report)) 152 | print("---------------------------------------") 153 | inFrameCount = 0 154 | if report and callback: 155 | callback(report) 156 | elif(val > args.squelch): 157 | frameSamples = [0, 0, val] 158 | inFrameCount = 3 159 | else: 160 | print("\nEOF") 161 | break 162 | 163 | else: 164 | print("You must use --wav or --raw. See help.") 165 | 166 | 167 | if __name__ == "__main__": 168 | decode() -------------------------------------------------------------------------------- /src/pydemod/app/amss.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # This file is part of Pydemod 4 | # Copyright Christophe Jacquet (F8FTK), 2011 5 | # Licence: GNU GPL v3 6 | # See: https://github.com/ChristopheJacquet/Pydemod 7 | 8 | import numpy 9 | #import pydemod.coding.polynomial as poly 10 | import math 11 | 12 | import pydemod.coding.crc as crc 13 | 14 | def copyto(dest, src, addr): 15 | for i in range(src.size): 16 | dest[addr+i] = src[i] 17 | 18 | 19 | def decode_mjd(mjd): 20 | """ 21 | Decodes a Modified Julian Date 22 | """ 23 | yp = int((mjd - 15078.2)/365.25) 24 | mp = int( ( mjd - 14956.1 - math.floor(yp * 365.25) ) / 30.6001 ) 25 | day = int(mjd - 14956 - math.floor( yp * 365.25 ) - math.floor( mp * 30.6001 )) 26 | if mp == 14 or mp == 15: 27 | k = 1 28 | else: 29 | k = 0 30 | year = int(1900 + yp + k) 31 | month = mp - 1 - k * 12 32 | return (year, month, day) 33 | 34 | class Station: 35 | entry_types = ["Multiplex description", "Label", "Conditional access parameters", "AF: Multiple frequency network", "AF: Schedule definition", "Application information", "Announcement support and switching data", "AF: Region definition", "Time and date information", "Audio information", "FAC channel parameters", "AF: Other services", "Language and country", "AF: Detailed region definition", "Packet stream FEC parameters"] 36 | 37 | def take(self, vec, u, l): 38 | return numpy.dot( numpy.power(2, numpy.arange(l-1, -1, -1)), vec[u:u+l]) 39 | 40 | def takestr(self, vec, u, nbchars): 41 | s = "" 42 | for i in range(nbchars): 43 | s = s + chr(self.take(vec, u+8*i, 8)) 44 | return s 45 | 46 | 47 | def process_block(self, type, word): 48 | print(f"Block type: {type}") 49 | if type == 1: 50 | vflag = self.take(word, 0, 1) 51 | n_segments = self.take(word, 4, 4)+1 52 | 53 | if vflag != self.current_vflag: 54 | if self.num_segments != -1 and not self.processed and self.segments_ok[0]: 55 | # if partially received, but segment 0 OK, try to decode anyway 56 | self.process_sdc_group(self.current_data) 57 | # start of new data 58 | self.current_data = numpy.array(32 * n_segments * [0]) 59 | self.current_vflag = vflag 60 | self.num_segments = n_segments 61 | self.segments_ok = [0] * self.num_segments 62 | self.processed = False 63 | 64 | print("\tVersion flag: {0}".format(vflag)); 65 | print("\tAM carrier mode: {0}".format(self.take(word, 1, 3)) ); 66 | print("\tNumber of segments: {0}".format(n_segments) ); 67 | print("\tLanguage: {0}".format(self.take(word, 8, 4)) ); 68 | print("\tService Identifier: {0:06X}".format(self.take(word, 12, 24) )); 69 | elif type == 2: 70 | addr = self.take(word, 0, 4) 71 | print("\tSegment address: {0}".format(addr)) 72 | 73 | if self.num_segments != -1: 74 | self.segments_ok[addr] = 1 75 | copyto(self.current_data, word[4:36], 32*addr) 76 | print("\tMemorizing data: segment {0}/{1}. Segments ok: {2}".format(addr+1, self.num_segments, self.segments_ok)) 77 | 78 | if numpy.sum(self.segments_ok) == self.num_segments and not self.processed: 79 | self.processed = True 80 | self.process_sdc_group(self.current_data) 81 | 82 | 83 | def process_sdc_group(self, data): 84 | print("************************************************************") 85 | print("SDC ENTRY GROUP: total {0} bits ({1} bytes)".format(data.size, data.size/8)) 86 | given_crc = self.take(data, data.size-16, 16) 87 | data = data[:data.size-16] 88 | print("\tCRC: calc={0:04X}, given={1:04X}".format(crc.crc(0b0001000000100001, 16, 0xFFFF, 0xFFFF, data), given_crc)) 89 | print("************************************************************") 90 | 91 | cont = True 92 | while cont: 93 | pos = self.process_sdc_entry(data) 94 | if pos < data.size: 95 | data = data[pos:] 96 | else: 97 | cont = 0 98 | 99 | 100 | # return total entry length 101 | def process_sdc_entry(self, data): 102 | len = self.take(data, 0, 7) 103 | version = self.take(data, 7, 1) 104 | type = self.take(data, 8, 4) 105 | 106 | print("SDC ENTRY: type={0}, version={1}, len=4 bits + {2} bytes (rem={3})".format(type, version, len, data.size/8)) 107 | print(">> " + self.entry_types[type]) 108 | print("------------------------------------------------------------") 109 | 110 | if type == 1: 111 | print("Short Id: {0}".format(self.take(data, 12, 2))) 112 | label = self.takestr(data, 16, len) 113 | print("Label: '{0}'".format(label)) 114 | 115 | if type == 4: 116 | print("Schedule Id: {0}".format(self.take(data, 12, 4))) 117 | print("Days of week: {0:07b}".format(self.take(data, 16, 7))) 118 | time = self.take(data, 23, 11) 119 | print("Start time: {0:02d}:{1:02d}".format(time//60, time % 60)) 120 | print("Duration: {0} min".format(self.take(data, 34, 14))) 121 | 122 | if type == 7: 123 | print("Region Id: {0}".format(self.take(data, 12, 4))) 124 | print("Latitude: {0}".format(self.take(data, 16, 8))) 125 | print("Longitude: {0}".format(self.take(data, 24, 9))) 126 | print("Latitude ext: {0}".format(self.take(data, 33, 7))) 127 | print("Longitude ext: {0}".format(self.take(data, 40, 8))) 128 | 129 | for i in range(len-4): 130 | print("CIRAF zone #{0}".format(self.take(data, 48+i*8, 8))) 131 | 132 | if type == 8: 133 | mjd = self.take(data, 12, 17) 134 | (year, month, day) = decode_mjd(mjd) 135 | time = self.take(data, 29, 11) 136 | print("Date: {0:04d}-{1:02d}-{2:02d}".format(year, month, day)) 137 | print("UTC time: {0:02d}:{1:02d}".format(time//60, time%60)) 138 | 139 | if type == 11: 140 | siaFlag = self.take(data, 12, 1) 141 | siaField = self.take(data, 13, 2) 142 | region = self.take(data, 15, 1) 143 | same = self.take(data, 16, 1) 144 | sysId = self.take(data, 19, 5) 145 | data = data[24:] 146 | aflen = len - 1 # must no touch len 147 | if region==1: # if region/schedule bit set, additional byte: 148 | regionID = self.take(data, 0, 4) 149 | scheduleID = self.take(data, 4, 4) 150 | data = data[8:] 151 | aflen = aflen - 1 152 | 153 | 154 | print("AF, same={0}, system={1}, len={2}, reg/sched={3}".format(same, sysId, len, region)) 155 | 156 | if region == 1: 157 | print("Restricted to region Id {0} / schedule Id {1}".format(regionID, scheduleID)) 158 | 159 | if sysId==0: 160 | print("DRM service, DRM Id = {0:06X}".format(self.take(data, 0, 24))) 161 | for i in range(0, aflen, 2): 162 | mult = self.take(data, i*8+24, 1) 163 | freq = self.take(data, i*8+25, 15) 164 | print("\tfreq = {0} kHz".format(freq + mult*9*freq)) 165 | 166 | if sysId == 1: 167 | print("AM service with AMSS, Id = {0:06X}".format(self.take(data, 0, 24))) 168 | for i in range(0, aflen-3, 2): 169 | print("\tfreq = {0} kHz".format(self.take(data, i*16+24, 16))) 170 | 171 | if sysId==2: 172 | print("AM service without AMSS\n") 173 | for i in range(0, aflen, 2): 174 | print("\tfreq = {0} kHz".format(self.take(data, i*16, 16))) 175 | 176 | if sysId==4: 177 | print("FM-RDS service, 16-bit PI = {0:04X}".format(self.take(data, 0, 16))) 178 | for i in range(0, aflen-1, 2): 179 | print("\tfreq = {0:0.01f} MHz".format(87.5 + .1 * self.take(data, i*8+16, 8)) ) 180 | 181 | if sysId==9: 182 | print("DAB service, ECC + audio service Id = {0:06X}".format(self.take(data, 0, 24))) 183 | 184 | 185 | if type == 12: 186 | lang = self.takestr(data, 16, 3) 187 | cc = self.takestr(data, 40, 2) 188 | print("Language code: {0}".format(lang)) 189 | print("Country code: {0}".format(cc)) 190 | 191 | print("============================================================") 192 | return 16+8*len 193 | 194 | 195 | def process_stream(self, wordStream): 196 | for type, word in wordStream: 197 | self.process_block(type, word) 198 | 199 | if self.num_segments != -1 and not self.processed and self.segments_ok[0]: 200 | # if partially received, but segment 0 OK, try to decode anyway 201 | self.process_sdc_group(self.current_data) 202 | 203 | def __init__(self): 204 | self.current_vflag = -1 205 | self.current_data = -1 206 | self.num_segments = -1 207 | self.segments_ok = -1 208 | self.processed = False 209 | 210 | --------------------------------------------------------------------------------