├── .gitignore ├── README.md ├── args.specs.js ├── index.js ├── lib ├── args.mjs ├── bitseq.mjs ├── hrf.mjs └── sub.mjs ├── package-lock.json ├── package.json └── sub2c16.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # project drafts 133 | *.sub 134 | *.txt 135 | *.c16 136 | tmp 137 | 138 | # MacOS 139 | .DS_Store 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sub-to-c16 2 | 3 | ## Features 4 | 5 | Flipper Zero *.sub file to HackRF *.C16 data converter 6 | 7 | > 🚧 Project still under development and likely to be changed in the future 8 | 9 | > Currently only supports the [`RAW`](https://github.com/flipperdevices/flipperzero-firmware/blob/dev/lib/subghz/protocols/raw.c) protocol. 10 | 11 | ## Motivations 12 | 13 | The freshly available *Flipper Zero* has a huge community behind that uploads a lot of recorded-remotes files for many use-cases (HVAC, office lights, smart plugs, Tesla...). 14 | 15 | This tool will perform the time-to-signal conversion and generate both `C16` and `TXT` files that you can save on the HackRF's SD card and use them later. 16 | 17 | ## Requirements 18 | 19 | Node.js >= 14 20 | 21 | ## Usage 22 | 23 | ```bash 24 | npm start -- -f -if -a -sr -o 25 | ``` 26 | 27 | - Only the `-f` parameter is required. If no output path is specified (`-o`), the input file name will be used for both `C16` and `TXT` output files. 28 | 29 | - Output file in the `C16` extension means that the output files has pairs of complex `[I,Q]` signals, encoded on 16-bit signed integers. 30 | 31 | - Phase between `I` and `Q` is 90° (fixed). 32 | 33 | - If no amplitude percentage is supplied (`-a`), `100` is used by default. 34 | 35 | - If no sampling rate for the output file is supplied (`-sr`), `500ks/s` is used by default. 36 | 37 | - If no intermediate frequency is supplied (`-if`), its value will be `sampling rate / 100`. 38 | 39 | > Why an intermediate frequency? This is how SDR-based platforms works. You can refer to the following schematic. 40 | 41 | ![SDR-architecture](https://www.redalyc.org/journal/4139/413954888011/0121-1129-rfing-26-45-00137-gf1.png) 42 | 43 | ## Example 44 | 45 | Used sample is this [Tesla charge port opener sub file](https://github.com/Cocainer/ClippingFracks/blob/main/Sub-GHz/Vehicles/Tesla/BEST_PORT_OPENER/433.92MHz_AM650_Better_Tesla_Charge_Port_Opener.sub) 46 | 47 | - Amplitude is *40%* of the maximum allowed 48 | - Modulation frequency is *19Khz* 49 | - Sampling rate is *250ks/s* 50 | 51 | ```bash 52 | npm start -- -f 433.92MHz_AM650_Better_Tesla_Charge_Port_Opener.sub -if -a 40 -f 19000 -sr 250000 -o out 53 | ``` 54 | 55 | Below is the comparison between the C16 `[I,Q]` data and the sub file preview on [lab.flipper.net](https://lab.flipper.net/pulse-plotter) 56 | 57 | ![sub file converted into c16](./sub2c16.png) 58 | 59 | The HackRF's metadata file will have the following lines: 60 | 61 | ``` 62 | sample_rate=250000 63 | center_frequency=433920000 64 | ``` 65 | 66 | # References 67 | 68 | - https://github.com/eried/portapack-mayhem/wiki/C16-format 69 | 70 | - https://github.com/flipperdevices/flipperzero-firmware/blob/dev/lib/subghz/protocols/ 71 | 72 | - https://lab.flipper.net/pulse-plotter 73 | 74 | -------------------------------------------------------------------------------- /args.specs.js: -------------------------------------------------------------------------------- 1 | export const ARGS_SPECS = { 2 | file: String, 3 | sampling_rate: Number, 4 | intermediate_freq: Number, 5 | amplitude: Number, 6 | output: String 7 | }; 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { argsToUsageStr, parseArgs } from './lib/args.mjs'; 3 | import { durationsToBinSequence, sequenceTo16LEBuffer } from './lib/bitseq.mjs'; 4 | import { writeHRFFile } from './lib/hrf.mjs'; 5 | import { parseSub } from './lib/sub.mjs'; 6 | import { ARGS_SPECS } from './args.specs.js'; 7 | 8 | const args = parseArgs(ARGS_SPECS, process.argv); 9 | 10 | let { 11 | file, output, 12 | intermediate_freq, 13 | sampling_rate = 500000, 14 | amplitude = 100 15 | } = args; 16 | 17 | if (!file) { 18 | console.error(argsToUsageStr(ARGS_SPECS)); 19 | process.exit(-1); 20 | } 21 | 22 | // use 1/100th of sampling rate by default for IF if not specified 23 | if (!intermediate_freq) { 24 | intermediate_freq = Math.round(parseInt(sampling_rate) / 100); 25 | console.log('No intermediate frequency specified, using', (intermediate_freq / 1000), 'kHz'); 26 | } 27 | 28 | // use input file path for output if not specified 29 | if (!output) { 30 | output = path.parse(file).name; 31 | console.log('No output file specified, using', output); 32 | } 33 | 34 | const { filetype, version, frequency, preset, protocol, chunks } = parseSub(file); 35 | 36 | console.log('Sub File information:'); 37 | console.log('-', [filetype, version, frequency, preset, protocol].join('\n- ')); 38 | console.log('Found', chunks.length, 'pure data chunks'); 39 | 40 | const IQSequence = durationsToBinSequence(chunks.flat(1), sampling_rate, intermediate_freq, amplitude); 41 | const buff = sequenceTo16LEBuffer(IQSequence); 42 | const outFiles = writeHRFFile(output, buff, frequency, sampling_rate); 43 | console.log('Written', Math.round(buff.length / 1024), 'kiB,', IQSequence.length / sampling_rate, 'seconds in files', outFiles.join(', ')); 44 | -------------------------------------------------------------------------------- /lib/args.mjs: -------------------------------------------------------------------------------- 1 | export function parseArgs(specs = {}, args = []) { 2 | const params = args.slice(2); 3 | const parsed = {}; 4 | 5 | for (const [key, type] of Object.entries(specs)) { 6 | const idx = params.indexOf(`-${str2abbr(key)}`); 7 | if (idx > -1 && idx < params.length - 1) { 8 | parsed[key] = parseValue(params[idx + 1], type); 9 | } 10 | } 11 | 12 | return parsed; 13 | } 14 | 15 | export function argsToUsageStr(specs = {}) { 16 | return Object.keys(specs).reduce((p, key) => p += ` -${str2abbr(key)} <${key}>`, `Usage: npm start --`); 17 | } 18 | 19 | function parseValue(str, type) { 20 | if (type === Number) { 21 | const n = parseInt(str, 10); 22 | return isNaN(n) ? 0 : n; 23 | } 24 | else return str; 25 | } 26 | 27 | function str2abbr(str = '') { 28 | return str.split('_').map(c => c[0]).join(''); 29 | } 30 | -------------------------------------------------------------------------------- /lib/bitseq.mjs: -------------------------------------------------------------------------------- 1 | const HACKRF_OFFSET = 0; // set to 0 for signed 2 | 3 | export function durationsToBinSequence(durations, sampling_rate, intermediate_freq, amplitude) { 4 | const sequence = durations.map(d => { 5 | return usToSin(d > 0, Math.abs(d), sampling_rate, intermediate_freq, amplitude); 6 | }); 7 | return sequence.flat(1); 8 | } 9 | 10 | function usToSin(level, duration, sampling_rate, intermediate_freq, amplitude) { 11 | const ITERATIONS = sampling_rate * duration / (1000 * 1000); 12 | const DATA_STEP_PER_SAMPLE = 2 * Math.PI / (sampling_rate / intermediate_freq); 13 | const HACKRF_AMPLITUDE = (256**2 - 1) * (amplitude / 100); // generated sin wave amplitude 14 | 15 | //console.log(duration, 'us =', ITERATIONS); 16 | const samples = new Array(ITERATIONS).fill(0); 17 | return samples.map((_,i) => level ? 18 | [ 19 | HACKRF_OFFSET + Math.floor(Math.cos(i*DATA_STEP_PER_SAMPLE) * (HACKRF_AMPLITUDE / 2)), 20 | HACKRF_OFFSET + Math.floor(Math.sin(i*DATA_STEP_PER_SAMPLE) * (HACKRF_AMPLITUDE / 2)) 21 | ] : 22 | [HACKRF_OFFSET, HACKRF_OFFSET] 23 | ); 24 | } 25 | 26 | export function sequenceTo16LEBuffer(sequence) { 27 | const le16Buff = Buffer.alloc(sequence.length*4); // 2 bytes for I, 2 bytes for Q 28 | sequence.forEach(([i, q], cursor) => { 29 | const buffPtr = cursor * 4; 30 | le16Buff.writeInt16LE(i, buffPtr); 31 | le16Buff.writeInt16LE(q, buffPtr+2); 32 | }); 33 | return le16Buff; 34 | } 35 | -------------------------------------------------------------------------------- /lib/hrf.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | const HRF_EXT = ['C16', 'TXT']; 4 | 5 | export function writeHRFFile(file, buffer, frequency, sampling_rate) { 6 | const PATHS = HRF_EXT.map(ext => `${file}.${ext}`); 7 | fs.writeFileSync(PATHS[0], buffer); 8 | fs.writeFileSync(PATHS[1], generateMetaString(frequency, sampling_rate)); 9 | return PATHS; 10 | }; 11 | 12 | function generateMetaString(frequency, sampling_rate) { 13 | const meta = [ 14 | ['sample_rate', sampling_rate], 15 | ['center_frequency', frequency], 16 | ]; 17 | return meta.map(r => r.join('=')).join('\n'); 18 | } -------------------------------------------------------------------------------- /lib/sub.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | // 0.1.0 4 | const SUPPORTED_PROTOCOLS = [ 5 | 'RAW' 6 | ]; 7 | 8 | export function parseSub(file) { 9 | let sub_data = ''; 10 | try { 11 | sub_data = fs.readFileSync(file).toString(); 12 | } catch(ex) { 13 | console.error('Cannot read input file'); 14 | process.exit(-1); 15 | } 16 | 17 | const sub_chunks = sub_data.split('\n').map(r => r.trim()); 18 | const [ filetype, version, frequency, preset, protocol ] = sub_chunks; 19 | const infoObj = {}; 20 | 21 | chunkRowToObj(filetype, infoObj); 22 | chunkRowToObj(version, infoObj); 23 | chunkRowToObj(frequency, infoObj); 24 | chunkRowToObj(preset, infoObj); 25 | chunkRowToObj(protocol, infoObj); 26 | 27 | if (!SUPPORTED_PROTOCOLS.includes(infoObj.protocol)) { 28 | console.error('Failed to parse', file, ': Currently supported protocols are', SUPPORTED_PROTOCOLS.join(','), '(found: ' + infoObj.protocol + ')'); 29 | process.exit(-1); 30 | } 31 | 32 | infoObj.chunks = sub_chunks.slice(5) 33 | .map(r => r.split(':')[1] 34 | .split(' ') 35 | .map(e => e.trim()) 36 | .filter(e => e.length) 37 | .map(e => parseInt(e, 10) 38 | )); 39 | 40 | return infoObj; 41 | } 42 | 43 | function chunkRowToObj(row, base = {}) { 44 | const [ value, key ] = row.split(':').map(e => e.trim()); 45 | base[value.toLowerCase()] = key; 46 | } -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sub-2-c16", 3 | "version": "0.1.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "sub-2-c16", 9 | "version": "0.1.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "fs": "^0.0.1-security", 13 | "path": "^0.12.7" 14 | } 15 | }, 16 | "node_modules/fs": { 17 | "version": "0.0.1-security", 18 | "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", 19 | "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==" 20 | }, 21 | "node_modules/inherits": { 22 | "version": "2.0.3", 23 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 24 | "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" 25 | }, 26 | "node_modules/path": { 27 | "version": "0.12.7", 28 | "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", 29 | "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", 30 | "dependencies": { 31 | "process": "^0.11.1", 32 | "util": "^0.10.3" 33 | } 34 | }, 35 | "node_modules/process": { 36 | "version": "0.11.10", 37 | "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", 38 | "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", 39 | "engines": { 40 | "node": ">= 0.6.0" 41 | } 42 | }, 43 | "node_modules/util": { 44 | "version": "0.10.4", 45 | "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", 46 | "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", 47 | "dependencies": { 48 | "inherits": "2.0.3" 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sub-2-c16", 3 | "version": "0.1.0", 4 | "description": "Flipper Zero *.sub file to HackRF *.C16 data converter", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node index.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "François Leparoux ", 12 | "license": "MIT", 13 | "dependencies": { 14 | "fs": "^0.0.1-security", 15 | "path": "^0.12.7" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sub2c16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rascafr/sub-to-c16/bb621c302ddd3cba164021db0b97d7d98f391b60/sub2c16.png --------------------------------------------------------------------------------