├── .gitignore ├── bin └── behringerctl.js ├── cli ├── error.js ├── output.js ├── commands │ ├── screenshot.js │ ├── midi.js │ ├── devices.js │ ├── presets.js │ └── firmware.js └── index.js ├── doc ├── behringer-deq2496-mainboard.jpg └── behringer-deq2496.md ├── device ├── index.js ├── fbq1000.js ├── deq2496v1.js └── deq2496v2.js ├── algo ├── xor.js ├── checksumTZ.js ├── midiFirmwareCoder.js └── sevenEightCoder.js ├── package.json ├── test ├── firmware-test.js └── sevenEightCoder-test.js ├── midiData.js ├── util.js ├── README.md ├── firmware.js └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /bin/behringerctl.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const cli = require('../cli/index.js'); 4 | 5 | cli(); 6 | -------------------------------------------------------------------------------- /cli/error.js: -------------------------------------------------------------------------------- 1 | class OperationsError extends Error { 2 | } 3 | 4 | module.exports = { 5 | OperationsError, 6 | }; 7 | -------------------------------------------------------------------------------- /doc/behringer-deq2496-mainboard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Malvineous/behringerctl/HEAD/doc/behringer-deq2496-mainboard.jpg -------------------------------------------------------------------------------- /device/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | DEQ2496v1: require('./deq2496v1.js'), 3 | DEQ2496v2: require('./deq2496v2.js'), 4 | FBQ1000: require('./fbq1000.js'), 5 | }; 6 | -------------------------------------------------------------------------------- /cli/output.js: -------------------------------------------------------------------------------- 1 | function pad(field, width, fnColour) { 2 | const s = '' + field; 3 | return fnColour(s) + ''.padEnd(width - s.length); 4 | } 5 | 6 | function padLeft(field, width, fnColour) { 7 | const s = '' + field; 8 | return ''.padEnd(width - s.length)+ fnColour(s); 9 | } 10 | 11 | module.exports = console.log; 12 | module.exports.pad = pad; 13 | module.exports.padLeft = padLeft; 14 | -------------------------------------------------------------------------------- /algo/xor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Encode and decode data with a simple XOR cipher. 3 | * 4 | * XOR is symmetric, so the same function both encrypts and decrypts. 5 | * 6 | * @param String key 7 | * Encryption key. 8 | * 9 | * @param Buffer|Array dataIn 10 | * Input data to encrypt or decrypt. 11 | * 12 | * @return Buffer ciphertext or cleartext. 13 | */ 14 | function xor(key, dataIn) 15 | { 16 | let data = Buffer.from(dataIn); 17 | if (typeof(key) === 'string') { 18 | key = key.split('').map(c => c.charCodeAt(0)); 19 | } 20 | for (let i = 0; i < data.length; i++) { 21 | data[i] ^= key[i % key.length]; 22 | } 23 | return data; 24 | } 25 | 26 | module.exports = xor; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "behringerctl", 3 | "version": "1.0.0", 4 | "description": "Control supported Behringer devices over MIDI", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha test" 8 | }, 9 | "bin": { 10 | "behringerctl": "./bin/behringerctl.js" 11 | }, 12 | "keywords": [ 13 | "deq2496" 14 | ], 15 | "author": "Adam Nielsen ", 16 | "license": "GPL-3.0", 17 | "dependencies": { 18 | "chalk": "^3.0.0", 19 | "command-line-args": "^5.1.1", 20 | "command-line-usage": "^6.1.0", 21 | "debug": "^4.1.1", 22 | "glob": "^7.1.6", 23 | "midi": "^1.0.0", 24 | "path": "^0.12.7", 25 | "pngjs": "^3.4.0" 26 | }, 27 | "devDependencies": { 28 | "mocha": "^7.0.0" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/Malvineous/behringerctl.git" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/Malvineous/behringerctl/issues" 36 | }, 37 | "homepage": "https://github.com/Malvineous/behringerctl#readme" 38 | } 39 | -------------------------------------------------------------------------------- /test/firmware-test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const Behringer = require('../index.js'); 4 | 5 | describe('encoding firmware', () => { 6 | 7 | const text = 'UPDATING FIRMWARE'; 8 | const textBytes = text.split('').map(c => c.charCodeAt(0)); 9 | const input = [ 10 | ...textBytes, 11 | ...new Array(256 - textBytes.length).fill(0), 12 | ]; 13 | const b = new Behringer(); 14 | const output = b.packBlock(0xFF00, input); 15 | 16 | it('correct length is produced', () => { 17 | assert.equal(output.length, 256 + 3); 18 | }); 19 | 20 | it('correct offset is stored', () => { 21 | assert.equal(output[0], 0xFF); 22 | assert.equal(output[1], 0x00); 23 | }); 24 | 25 | it('correct CRC is generated', () => { 26 | assert.equal(output[2], 0x47); 27 | }); 28 | 29 | }); 30 | 31 | describe('encoding firmware 2', () => { 32 | 33 | const text = 'READY... PLEASE CYCLE POWER'; 34 | const textBytes = text.split('').map(c => c.charCodeAt(0)); 35 | const input = [ 36 | ...textBytes, 37 | ...new Array(256 - textBytes.length).fill(0), 38 | ]; 39 | const b = new Behringer(); 40 | const output = b.packBlock(0xFF00, input); 41 | 42 | it('correct CRC is generated', () => { 43 | assert.equal(output[2], 0x48); 44 | }); 45 | 46 | }); 47 | -------------------------------------------------------------------------------- /algo/checksumTZ.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implementation of Behringer's SysEx checksum. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | /** 21 | * Checksum function for Behringer firmware write blocks. 22 | * 23 | * Named TZ as assuming those are the initials of the algorithm designer, given 24 | * the other XOR keys used. 25 | * 26 | * Algorithm reverse engineered by Adam Nielsen 27 | * 28 | * @param data 29 | * Array of bytes. 30 | * 31 | * @return Number, 8-bit unsigned checksum value. 32 | */ 33 | function checksumTZ(data) { 34 | let crc = 0; 35 | for (let b of data) { 36 | for (let j = 0; j < 8; j++) { 37 | if (!((b ^ crc) & 1)) crc ^= 0x19; 38 | b >>= 1; 39 | // Rotate (shift right, move lost LSB to new MSB) 40 | crc = ((crc & 1) << 7) | (crc >> 1); 41 | } 42 | } 43 | return crc ^ 0xbf; 44 | } 45 | 46 | module.exports = checksumTZ; 47 | -------------------------------------------------------------------------------- /test/sevenEightCoder-test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const sevenEightCoder = require('../algo/sevenEightCoder.js'); 4 | 5 | describe('encoding 8-bit data in 7 bits', () => { 6 | 7 | it('must encode one 7-byte group', () => { 8 | const input = [ 9 | 0xFF, 0x55, 0xAA, 0x00, 0x7F, 0x80, 0x01, 10 | ]; 11 | const output = sevenEightCoder.encode(input); 12 | 13 | assert.deepEqual(output, [ 14 | 0x7F, 0x55, 0x2A, 0x00, 0x7F, 0x00, 0x01, 0x52, 15 | ]); 16 | }); 17 | 18 | it('must encode two 7-byte groups', () => { 19 | const input = [ 20 | 0xFF, 0x55, 0xAA, 0x00, 0x7F, 0x80, 0x01, 21 | 0x01, 0x55, 0xAA, 0x00, 0x7F, 0x80, 0xFF, 22 | ]; 23 | const output = sevenEightCoder.encode(input); 24 | 25 | assert.deepEqual(output, [ 26 | 0x7F, 0x55, 0x2A, 0x00, 0x7F, 0x00, 0x01, 0x52, 27 | 0x01, 0x55, 0x2A, 0x00, 0x7F, 0x00, 0x7F, 0x13, 28 | ]); 29 | }); 30 | 31 | it('must encode an incomplete group', () => { 32 | const input = [ 33 | 0xFF, 0x55, 0xAA, 0x00, 0x7F, 34 | ]; 35 | const output = sevenEightCoder.encode(input); 36 | 37 | assert.deepEqual(output, [ 38 | 0x7F, 0x55, 0x2A, 0x00, 0x7F, 0x00, 0x00, 0x50, 39 | ]); 40 | }); 41 | 42 | }); 43 | 44 | describe('decoding 8-bit data from 7 bits', () => { 45 | 46 | it('must decode one 7-byte group', () => { 47 | const input = [ 48 | 0x7F, 0x55, 0x2A, 0x00, 0x7F, 0x00, 0x01, 0x52, 49 | ]; 50 | const output = sevenEightCoder.decode(input); 51 | 52 | assert.deepEqual(output, [ 53 | 0xFF, 0x55, 0xAA, 0x00, 0x7F, 0x80, 0x01, 54 | ]); 55 | }); 56 | 57 | it('must decode two 7-byte groups', () => { 58 | const input = [ 59 | 0x7F, 0x55, 0x2A, 0x00, 0x7F, 0x00, 0x01, 0x52, 60 | 0x01, 0x55, 0x2A, 0x00, 0x7F, 0x00, 0x7F, 0x13, 61 | ]; 62 | const output = sevenEightCoder.decode(input); 63 | 64 | assert.deepEqual(output, [ 65 | 0xFF, 0x55, 0xAA, 0x00, 0x7F, 0x80, 0x01, 66 | 0x01, 0x55, 0xAA, 0x00, 0x7F, 0x80, 0xFF, 67 | ]); 68 | }); 69 | 70 | }); 71 | -------------------------------------------------------------------------------- /algo/midiFirmwareCoder.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implementation of Behringer's 4 kB firmware-via-MIDI cipher. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | /** 21 | * Encode and decode data encrypted by Behringer's 4 kB block cipher used before 22 | * MIDI firmware update data is written to flash. 23 | * 24 | * Algorithm reverse engineered by Adam Nielsen 25 | * 26 | * @param Number baseBlockNum 27 | * Firmware block address in units of 0x1000. For firmware address 0x4000, 28 | * this value should be 4. It is the same as `firmwareOffset >> 12` or 29 | * `blockNumber >> 4` where `blockNumber` is the two-byte prefix on the 30 | * front of the first 256-byte block in the 4 kB page. 31 | * 32 | * @return Buffer. 33 | */ 34 | function midiFirmwareCoder(baseBlockNum, dataIn) 35 | { 36 | let data = Buffer.from(dataIn); 37 | 38 | // If the block is zero, the function won't change the data, so this magic 39 | // number is used. 40 | let key = baseBlockNum || 0x545A; 41 | 42 | for (let pos = 0; pos < data.length;) { 43 | // Let's be fancy and execute the `if` statement without using an `if`. 44 | //if (key & 1) key ^= 0x8005; 45 | key ^= ( 46 | ((key & 1) << 15) 47 | | ((key & 1) << 2) 48 | | (key & 1) 49 | ); 50 | 51 | // This rotate operation is a bit redundant, because the above XOR 52 | // always clears the lower bit. 53 | //key = ((key & 1) << 15) | (key >> 1); 54 | key >>= 1; 55 | 56 | data[pos++] ^= key & 0xFF; 57 | data[pos++] ^= key >> 8; 58 | } 59 | 60 | return data; 61 | } 62 | 63 | module.exports = midiFirmwareCoder; 64 | -------------------------------------------------------------------------------- /algo/sevenEightCoder.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implementation of Behringer's 7/8 coder, for passing 8-bit data via MIDI. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | /** 21 | * Encode and decode data stored in Behringer 7/8 coding. 22 | * 23 | * This works in groups of seven bytes, removing the high bit from all seven 24 | * bytes and storing them in an eighth 7-bit byte. 25 | * 26 | * It is used to encode 8-bit binary firmware data such that it can be 27 | * transmitted as MIDI System Exclusive (SysEx) events, which require that none 28 | * of the SysEx data bytes have the high bit set. 29 | * 30 | * Algorithm reverse engineered by Adam Nielsen 31 | */ 32 | class SevenEightCoder 33 | { 34 | 35 | /// Take 8-bit data and return it expanded to fit in 7-bit bytes. 36 | static encode(input) 37 | { 38 | let out = []; 39 | for (let i = 0; i < input.length; i += 7) { 40 | let buffer = 0; 41 | for (let j = 0; j < 7; j++) { 42 | let d; 43 | if (i >= input.length) { 44 | d = 0; 45 | } else { 46 | d = input[i + j]; 47 | } 48 | out.push(d & 0x7F); 49 | buffer <<= 1; 50 | buffer |= d >> 7; 51 | } 52 | out.push(buffer); 53 | } 54 | return out; 55 | } 56 | 57 | static decode(input) 58 | { 59 | let out = []; 60 | let buffer = []; 61 | for (let i = 0; i < input.length; i += 8) { 62 | const highBits = input[i + 7]; 63 | for (let j = 0; j < 7; j++) { 64 | const dec = input[i + j] | ((highBits << j << 1) & 0x80); 65 | out.push(dec); 66 | } 67 | } 68 | return out; 69 | } 70 | }; 71 | 72 | module.exports = SevenEightCoder; 73 | -------------------------------------------------------------------------------- /midiData.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for handling MIDI data. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const debug = require('debug')('behringerctl:midiData'); 21 | const g_debug = debug; 22 | 23 | class MIDIData 24 | { 25 | /// Is the supplied data in MIDI SysEx format? 26 | static isSysEx(binMIDI) 27 | { 28 | let isSysEx = false; 29 | if ((binMIDI[0] === 0xF0) && (binMIDI[binMIDI.length - 1] == 0xF7)) { 30 | isSysEx = true; 31 | for (const b of binMIDI) { 32 | if ((b & 0x80) && (b < 0xF0)) { 33 | isSysEx = false; 34 | break; 35 | } 36 | } 37 | } 38 | return isSysEx; 39 | } 40 | 41 | /// Parse raw MIDI data and dig out SysEx events. 42 | static processMIDI(binMIDI, fnCallback) 43 | { 44 | let pos = 0; 45 | while (pos < binMIDI.length) { 46 | switch (binMIDI[pos]) { 47 | case 0xF0: // sysex 48 | let end = pos + 1; 49 | while (end < binMIDI.length) { 50 | if (binMIDI[end] & 0x80) break; 51 | end++; 52 | } 53 | if (binMIDI[end] === 0xF7) { 54 | const event = binMIDI.slice(pos, end); 55 | end++; 56 | fnCallback(event); 57 | } else { 58 | debug(`Unexpected end to SysEx 0x${binMIDI[end].toString(16)}`); 59 | } 60 | pos = end; 61 | break; 62 | default: 63 | debug(`Unexpected MIDI event 0x${binMIDI[pos].toString(16)}`); 64 | break; 65 | } 66 | } 67 | } 68 | 69 | /// Parse a single sysex message and return the header and data chunk. 70 | static parseSysEx(binSysEx) 71 | { 72 | const companyId = (binSysEx[1] << 16) | (binSysEx[2] << 8) | binSysEx[3]; 73 | if (companyId != 0x002032) { 74 | debug(`Invalid companyId 0x${companyId.toString(16)}, ignoring`); 75 | return; 76 | } 77 | return { 78 | deviceId: binSysEx[4], 79 | modelId: binSysEx[5], 80 | command: binSysEx[6], 81 | binData: binSysEx.slice(7), 82 | }; 83 | } 84 | }; 85 | 86 | module.exports = MIDIData; 87 | -------------------------------------------------------------------------------- /util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Behringer device control library, utility functions. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const debug = require('debug')('behringerctl:util'); 21 | 22 | const models = { 23 | deq2496: 0x12, 24 | fbq1000: 0x1f, 25 | ANY: 0x7F, 26 | }; 27 | 28 | const commands = { 29 | identify: 0x01, 30 | identifyResponse: 0x02, 31 | writeSinglePreset: 0x20, 32 | writeModulePresets: 0x21, 33 | writeSingleValue: 0x22, 34 | setMIDIChannel: 0x24, 35 | writeFlash: 0x34, 36 | writeFlashResponse: 0x35, 37 | screenshotResponse: 0x36, 38 | readSinglePreset: 0x60, 39 | readModulePreset: 0x61, 40 | getScreenshot: 0x76, 41 | ANY: 0xFF, 42 | }; 43 | 44 | class BehringerUtil 45 | { 46 | /// Convert a SysEx command byte into text. 47 | static getCommandName(c) 48 | { 49 | const commandId = parseInt(c); 50 | let commandName = 'unknown'; 51 | for (const i of Object.keys(commands)) { 52 | if (commands[i] === commandId) { 53 | commandName = i; 54 | break; 55 | } 56 | } 57 | return `${commandName}(${commandId})`; 58 | } 59 | 60 | /// Convert a SysEx target model byte into text. 61 | static getModelName(m) 62 | { 63 | const modelId = parseInt(m); 64 | let modelName = 'unknown'; 65 | for (const i of Object.keys(models)) { 66 | if (models[i] === modelId) { 67 | modelName = i; 68 | break; 69 | } 70 | } 71 | return `${modelName}(${modelId})`; 72 | } 73 | 74 | static blocksToImage(blocks, firstBlock, endBlock, keepMissing = false) 75 | { 76 | let imageBlocks = []; 77 | for (let i = firstBlock; i < endBlock; i++) { 78 | if (!blocks[i]) { 79 | // This block doesn't exist 80 | if (keepMissing) { 81 | // Simulate an unwritten flash block 82 | imageBlocks.push(Buffer.alloc(0x1000, 0xFF)); 83 | } else { 84 | if (imageBlocks.length != 0) { 85 | // But we've put blocks in before, this is now a gap, so end 86 | debug(`Blocks ${firstBlock} to ${i - 1} are good, block ${i} is ` 87 | + `missing, ending early before reaching end block ${endBlock}`); 88 | break; 89 | } 90 | } 91 | } else { 92 | imageBlocks.push(blocks[i]); 93 | } 94 | } 95 | return Buffer.concat(imageBlocks); 96 | } 97 | }; 98 | 99 | BehringerUtil.commands = commands; 100 | BehringerUtil.models = models; 101 | 102 | module.exports = BehringerUtil; 103 | -------------------------------------------------------------------------------- /cli/commands/screenshot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Command line interface implementation for `screenshot` function. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const chalk = require('chalk'); 21 | const commandLineArgs = require('command-line-args'); 22 | const debug = require('debug')('behringerctl:cli:screenshot'); 23 | 24 | const { OperationsError } = require('../error.js'); 25 | const output = require('../output.js'); 26 | 27 | class Operations 28 | { 29 | constructor() 30 | { 31 | } 32 | 33 | async destructor() 34 | { 35 | } 36 | 37 | async show(params) 38 | { 39 | const ss = await this.behringer.getScreenshot(); 40 | if (params['ascii']) { 41 | for (const row of ss.pixels) { 42 | process.stdout.write('>' + row.reduce((line, p) => line + (p ? '%' : ' '), '') + '<\n'); 43 | } 44 | } else { 45 | for (let row = 0; row < ss.pixels.length; row += 2) { 46 | let line = ''; 47 | for (let col = 0; col < ss.pixels[row].length; col++) { 48 | const char = 49 | (ss.pixels[row][col] ? 1 : 0) + 50 | (ss.pixels[row + 1][col] ? 2 : 0) 51 | ; 52 | const chars = [ 53 | ' ', 54 | '\u2580', 55 | '\u2584', 56 | '\u2588', 57 | ]; 58 | line += chars[char]; 59 | } 60 | process.stdout.write('>' + line + '<\n'); 61 | } 62 | } 63 | } 64 | 65 | static async exec(createInstance, args) 66 | { 67 | let cmdDefinitions = [ 68 | { name: 'name', defaultOption: true }, 69 | ]; 70 | const cmd = commandLineArgs(cmdDefinitions, { argv: args, stopAtFirstUnknown: true }); 71 | 72 | if (!cmd.name) { 73 | throw new OperationsError(`subcommand required`); 74 | } 75 | 76 | let proc = new Operations(); 77 | try { 78 | proc.behringer = createInstance(); 79 | } catch (e) { 80 | throw new OperationsError(`Unable to set up MIDI connection: ${e.message}`); 81 | } 82 | 83 | try { 84 | const def = Operations.names[cmd.name] && Operations.names[cmd.name].optionList; 85 | if (def) { 86 | const runOptions = commandLineArgs(def, { argv: cmd._unknown || [] }); 87 | await proc[cmd.name](runOptions); 88 | } else { 89 | throw new OperationsError(`unknown command: ${cmd.name}`); 90 | } 91 | 92 | } finally { 93 | if (proc.destructor) await proc.destructor(); 94 | proc = undefined; 95 | } 96 | } 97 | } 98 | 99 | Operations.names = { 100 | show: { 101 | summary: 'Display the screenshot in the console with Unicode chars', 102 | optionList: [ 103 | { 104 | name: 'ascii', 105 | type: Boolean, 106 | description: 'Use ASCII instead of Unicode', 107 | } 108 | ], 109 | }, 110 | }; 111 | 112 | module.exports = Operations; 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | This is a command-line utility that can send commands to supported Behringer 4 | devices over a MIDI interface. 5 | 6 | It can also be used as a module (for which the CLI is an example) should you 7 | wish to control Behringer devices in your own NodeJS programs. 8 | 9 | ## Supported devices 10 | 11 | ### DEQ2496 12 | 13 | * Working: Reading and writing presets, screenshots, MIDI channel changing 14 | * Incomplete: Adjusting parameters (non SysEx), selecting 15 | different menu pages on the LCD, firmware upload 16 | 17 | ### Others 18 | 19 | Other Behringer devices should be able to be identified, however none of their 20 | functions will work unless they are shared with a supported device. 21 | Contributions are welcome if anyone wishes to add support for additional 22 | devices! 23 | 24 | ## Installation 25 | 26 | npm install -g behringerctl 27 | 28 | ## Use 29 | 30 | First, identify which MIDI ports you have available: 31 | 32 | behringerctl midi list 33 | 34 | If the selected devices are not correct, you'll need to specify which MIDI 35 | interface to use on each subsequent command: 36 | 37 | behringerctl --midi-in 2 --midi-out 5 ... 38 | 39 | This will be omitted to keep the examples clear so specify it if you need to. 40 | 41 | Next, see if you have any supported devices: 42 | 43 | behringerctl devices list 44 | 45 | This will take some time as it waits for a few seconds to give every device a 46 | chance to respond. If you see devices listed, take note of the device ID as 47 | you will need to specify it in further commands, like this: 48 | 49 | behringerctl --device-id 0 ... 50 | 51 | You may now be specifying `--midi-in`, `--midi-out` and `--device-id` on every 52 | single command! 53 | 54 | You can now send commands to this device: 55 | 56 | # Get same model name returned by `devices list` 57 | behringerctl --device-id 0 devices identify 58 | 59 | # Dump screen contents if you have a large enough terminal window 60 | behringerctl --device-id 0 screenshot show 61 | 62 | # Set MIDI channel from 1 (device ID 0) to 4 (device ID 3) 63 | behringerctl --device-id 0 devices config --midi-channel 4 64 | 65 | # Set MIDI channel back to 1 (device ID 0) 66 | behringerctl --device-id 3 devices config --midi-channel 1 67 | 68 | Be aware that changing the MIDI channel also changes the device ID, which is 69 | always one integer less than the MIDI channel. 70 | 71 | The available commands are listed in the help: 72 | 73 | behringerctl help 74 | behringerctl help devices config 75 | 76 | ### Examples 77 | 78 | Export all the presets from one DEQ2496 and import them into another (or back 79 | into the same unit after a factory reset): 80 | 81 | behringerctl --device-id 0 presets export --index 0 --count 65 --prefix preset 82 | behringerctl --device-id 1 presets import --index 0 --count 65 --prefix preset 83 | 84 | Note that at the time of writing the 16-character preset titles will be 85 | truncated to 10 characters during the export due to a firmware bug. You can 86 | edit the exported files in a hex editor and add back the missing characters to 87 | the end of the file, which will then reimport with the full title. 88 | 89 | ## Use as a module 90 | 91 | See `cli/index.js` to get started. 92 | 93 | ## Notes 94 | 95 | ### General 96 | 97 | * If you change the MIDI channel with `setMIDIChannel`, you will also be 98 | changing the `deviceId`, so you'll need to use the new value (minus 1) for 99 | subsequent commands sent to the same device. 100 | 101 | ### DEQ2496 102 | 103 | See [doc/behringer-deq2496.md](doc/behringer-deq2496.md). 104 | -------------------------------------------------------------------------------- /cli/commands/midi.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Command line interface implementation for `midi` function. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const chalk = require('chalk'); 21 | const commandLineArgs = require('command-line-args'); 22 | const debug = require('debug')('behringerctl:cli:midi'); 23 | const midi = require('midi'); 24 | 25 | const { OperationsError } = require('../error.js'); 26 | const output = require('../output.js'); 27 | 28 | class Operations 29 | { 30 | constructor() 31 | { 32 | } 33 | 34 | async destructor() 35 | { 36 | } 37 | 38 | async list(params) 39 | { 40 | let portList = []; 41 | 42 | output( 43 | chalk.white.inverse('Selected'.padEnd(8)), 44 | chalk.white.inverse('Direction'.padEnd(9)), 45 | chalk.white.inverse('Index'.padEnd(5)), 46 | chalk.white.inverse('Description'.padEnd(32)), 47 | ); 48 | 49 | const midiInput = new midi.Input(); 50 | const inputPortCount = midiInput.getPortCount(); 51 | let defaultPort = undefined; 52 | for (let i = 0; i < inputPortCount; i++) { 53 | const portName = midiInput.getPortName(i); 54 | if (defaultPort === undefined) { 55 | // Pick first port that doesn't look like a MIDI Through one. 56 | if (!portName.includes('hrough')) { 57 | defaultPort = i; 58 | } 59 | } 60 | output( 61 | chalk.greenBright((i === defaultPort ? '*' : ' ').padEnd(8)), 62 | 'Input'.padEnd(9), 63 | chalk.whiteBright(('' + i).padStart(5)), 64 | portName 65 | ); 66 | } 67 | midiInput.closePort(); 68 | 69 | const midiOutput = new midi.Output(); 70 | const outputPortCount = midiOutput.getPortCount(); 71 | for (let i = 0; i < outputPortCount; i++) { 72 | const portName = midiOutput.getPortName(i); 73 | if (defaultPort === undefined) { 74 | // Pick first port that doesn't look like a MIDI Through one. 75 | if (!portName.includes('hrough')) { 76 | defaultPort = i; 77 | } 78 | } 79 | output( 80 | chalk.greenBright((i === defaultPort ? '*' : ' ').padEnd(8)), 81 | 'Output'.padEnd(9), 82 | chalk.whiteBright(('' + i).padStart(5)), 83 | portName 84 | ); 85 | } 86 | midiOutput.closePort(); 87 | } 88 | 89 | static async exec(createInstance, args) 90 | { 91 | let cmdDefinitions = [ 92 | { name: 'name', defaultOption: true }, 93 | ]; 94 | const cmd = commandLineArgs(cmdDefinitions, { argv: args, stopAtFirstUnknown: true }); 95 | 96 | if (!cmd.name) { 97 | throw new OperationsError(`subcommand required`); 98 | } 99 | 100 | let proc = new Operations(); 101 | 102 | try { 103 | const def = Operations.names[cmd.name] && Operations.names[cmd.name].optionList; 104 | if (def) { 105 | const runOptions = commandLineArgs(def, { argv: cmd._unknown || [] }); 106 | await proc[cmd.name](runOptions); 107 | } else { 108 | throw new OperationsError(`unknown command: ${cmd.name}`); 109 | } 110 | 111 | } finally { 112 | if (proc.destructor) await proc.destructor(); 113 | proc = undefined; 114 | } 115 | } 116 | } 117 | 118 | Operations.names = { 119 | list: { 120 | summary: 'List available MIDI devices to use for device communication', 121 | optionList: [], 122 | }, 123 | }; 124 | 125 | module.exports = Operations; 126 | -------------------------------------------------------------------------------- /cli/commands/devices.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Command line interface implementation for `devices` function. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const chalk = require('chalk'); 21 | const commandLineArgs = require('command-line-args'); 22 | const debug = require('debug')('behringerctl:cli:devices'); 23 | const fs = require('fs'); 24 | 25 | const { OperationsError } = require('../error.js'); 26 | const output = require('../output.js'); 27 | 28 | class Operations 29 | { 30 | constructor() { 31 | } 32 | 33 | async destructor() { 34 | } 35 | 36 | async list(params) 37 | { 38 | let p = 0; 39 | const throbber = '-/|\\'; 40 | function status() { 41 | p = (p + 1) % throbber.length; 42 | process.stdout.write('\rWaiting for devices to respond...' + throbber[p] + '\r'); 43 | } 44 | let hStatus = setInterval(status, 100); 45 | 46 | const deviceList = await this.behringer.find(); 47 | 48 | clearInterval(hStatus); 49 | process.stdout.write('\r\u001B[J\r'); 50 | 51 | if (!deviceList.length) { 52 | output('No supported devices found!'); 53 | return; 54 | } 55 | 56 | output( 57 | chalk.white.inverse('Model ID'.padEnd(8)), 58 | chalk.white.inverse('Device ID'.padEnd(9)), 59 | chalk.white.inverse('Model name'.padEnd(24)), 60 | ); 61 | for (const d of deviceList) { 62 | output( 63 | output.pad(d.modelId, 8, chalk.whiteBright), 64 | output.pad(d.deviceId, 9, chalk.greenBright), 65 | chalk.yellowBright(d.modelName) 66 | ); 67 | } 68 | } 69 | 70 | async identify(params) 71 | { 72 | try { 73 | const identity = await this.behringer.identify(); 74 | output( 75 | chalk.yellowBright(identity.modelName) 76 | ); 77 | } catch (e) { 78 | throw new OperationsError(`identify failed: ${e.message}`); 79 | } 80 | } 81 | 82 | async config(params) 83 | { 84 | if (params['midi-channel'] !== undefined) { 85 | const channel = parseInt(params['midi-channel']) - 1; 86 | if (channel < 0) { 87 | throw new OperationsError('Channel cannot be less than 1.'); 88 | } 89 | this.behringer.setMIDIChannel(channel); 90 | output( 91 | 'MIDI channel set to:', 92 | chalk.greenBright(channel + 1), 93 | '(device ID', 94 | chalk.yellowBright(channel) + ')' 95 | ); 96 | output('There is no confirmation from the device whether this was successful or not.'); 97 | } 98 | } 99 | 100 | async message(params) 101 | { 102 | //await this.behringer.readMemory(); 103 | if (params['text'] === undefined) { 104 | throw new OperationsError('Must specify --text.'); 105 | } 106 | await this.behringer.setLCDMessage(params['text']); 107 | } 108 | 109 | async sendsyx(params) 110 | { 111 | if (!params['read']) { 112 | throw new OperationsError('Missing filename to --read.'); 113 | } 114 | let dataIn = fs.readFileSync(params['read']); 115 | 116 | let events = []; 117 | while (dataIn.length > 2) { 118 | if (!(dataIn[0] & 0x80)) { 119 | throw new OperationsError('Invalid MIDI content'); 120 | } 121 | const end = dataIn.slice(1).findIndex(v => v & 0x80); 122 | const event = dataIn.slice(0, end + 2); 123 | events.push(event); 124 | dataIn = dataIn.slice(end + 2); 125 | } 126 | 127 | for (let i = 0; i < events.length; i++) { 128 | output( 129 | 'Sending SysEx event', 130 | chalk.yellowBright(i + 1), 131 | 'of', 132 | chalk.yellowBright(events.length) 133 | ); 134 | this.behringer.midiOut.write(events[i]); 135 | 136 | // Put a delay in as writing too fast causes messages to be lost. 137 | await new Promise((resolve, reject) => setTimeout(resolve, 250)); 138 | } 139 | } 140 | 141 | static async exec(createInstance, args) 142 | { 143 | let cmdDefinitions = [ 144 | { name: 'name', defaultOption: true }, 145 | ]; 146 | const cmd = commandLineArgs(cmdDefinitions, { argv: args, stopAtFirstUnknown: true }); 147 | 148 | if (!cmd.name) { 149 | throw new OperationsError(`subcommand required.`); 150 | } 151 | 152 | let proc = new Operations(); 153 | try { 154 | proc.behringer = createInstance(); 155 | } catch (e) { 156 | throw new OperationsError(`Unable to set up MIDI connection: ${e.message}`); 157 | } 158 | 159 | try { 160 | const def = Operations.names[cmd.name] && Operations.names[cmd.name].optionList; 161 | if (def) { 162 | const runOptions = commandLineArgs(def, { argv: cmd._unknown || [] }); 163 | await proc[cmd.name](runOptions); 164 | } else { 165 | throw new OperationsError(`unknown command: ${cmd.name}`); 166 | } 167 | 168 | } finally { 169 | if (proc.destructor) await proc.destructor(); 170 | proc = undefined; 171 | } 172 | } 173 | } 174 | 175 | Operations.names = { 176 | list: { 177 | summary: 'Query all connected devices', 178 | optionList: [], 179 | }, 180 | identify: { 181 | summary: 'Identify the selected device', 182 | optionList: [], 183 | }, 184 | config: { 185 | summary: 'Configure the selected device', 186 | optionList: [ 187 | { 188 | name: 'midi-channel', 189 | type: Number, 190 | description: 'Set the device to listen on a different MIDI channel', 191 | }, 192 | ], 193 | }, 194 | message: { 195 | summary: 'Write a message on the device\'s display', 196 | optionList: [ 197 | { 198 | name: 'text', 199 | type: String, 200 | description: 'Text to show', 201 | }, 202 | ], 203 | }, 204 | sendsyx: { 205 | summary: 'Read SysEx data from a file and transmit it out the selected MIDI interface', 206 | optionList: [ 207 | { 208 | name: 'read', 209 | type: String, 210 | description: '*.syx firmware file to read', 211 | }, 212 | ], 213 | }, 214 | }; 215 | 216 | module.exports = Operations; 217 | -------------------------------------------------------------------------------- /firmware.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Behringer device control library, firmware component. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const debug = require('debug')('behringerctl:firmware'); 21 | 22 | const device = require('./device/index.js'); 23 | const midiData = require('./midiData.js'); 24 | const util = require('./util.js'); 25 | 26 | /// Accessed through `index.js` as `Behringer.firmware` 27 | class BehringerFirmware 28 | { 29 | /// Work out which device the SysEx data is targeted at. 30 | /** 31 | * This may match multiple device models, e.g. model 0x12 is used for both 32 | * DEQ2496v1 and DEQ2496v2. 33 | * 34 | * Return Array of zero or more Device classes. 35 | */ 36 | static identifyMIDITarget(binMIDI) 37 | { 38 | if (!midiData.isSysEx(binMIDI)) { 39 | throw new Error('Supplied data is not in MIDI format.'); 40 | } 41 | 42 | // Just get the first event 43 | const sysExInfo = midiData.parseSysEx(binMIDI); 44 | if (!sysExInfo) { 45 | throw new Error('Supplied MIDI data is not in a known Behringer format.'); 46 | } 47 | 48 | debug(`Device model is 0x${sysExInfo.modelId.toString(16)}, looking for a match`); 49 | let matchedDevices = []; 50 | for (let dev in device) { 51 | if (sysExInfo.modelId === device[dev].modelId) { 52 | debug(`Matched model ${dev}`); 53 | matchedDevices.push(dev); 54 | } 55 | } 56 | 57 | return matchedDevices; 58 | } 59 | 60 | /** 61 | * Read a firmware file and return information about it. 62 | * 63 | * @param Buffer dataIn 64 | * Input data buffer, e.g. returned from fs.readFileSync(). 65 | * 66 | * @param string device 67 | * Device type. Optional if the data is MIDI SysEx as the device can be 68 | * guessed from the MIDI data. 69 | * 70 | * @return Array containing information about the firmware. 71 | */ 72 | static decode(dataIn, deviceName = null) 73 | { 74 | let selectedDevice = null; 75 | 76 | // Convert the device string into a Device instance. 77 | if (deviceName) { 78 | for (let dev in device) { 79 | if (dev === deviceName) { 80 | selectedDevice = device[dev]; 81 | break; 82 | } 83 | } 84 | } 85 | 86 | let isSysEx = midiData.isSysEx(dataIn); 87 | 88 | let detail = {}; 89 | 90 | let blocks = []; 91 | if (isSysEx) { 92 | const binMIDI = dataIn; 93 | detail['Format'] = 'Raw MIDI SysEx'; 94 | 95 | // Try to guess the device model from the MIDI data. 96 | if (!selectedDevice) { 97 | const matchedDevices = this.identifyMIDITarget(binMIDI); 98 | if (matchedDevices.length === 0) { 99 | throw new Error('Unknown device model number in MIDI data.'); 100 | } 101 | 102 | if (matchedDevices.length > 1) { 103 | throw new Error('MIDI device model number matched too many devices! ' 104 | + 'Please specify the device model to use: [' 105 | + matchedDevices.join(', ') + ']' 106 | ); 107 | } 108 | selectedDevice = device[matchedDevices[0]]; 109 | } 110 | 111 | let lcdMessages = {}; 112 | 113 | let dataBlockCount = 0; 114 | const fwHandler = selectedDevice.getFirmwareDecoder(); 115 | 116 | midiData.processMIDI(binMIDI, event => { 117 | const eventInfo = midiData.parseSysEx(event); 118 | 119 | const fwBlock = fwHandler.addMIDIWrite(eventInfo); 120 | if (!fwBlock) return; // ignored 121 | 122 | if (fwBlock.message) { 123 | lcdMessages[dataBlockCount] = fwBlock.message; 124 | } else { 125 | dataBlockCount++; 126 | } 127 | }); 128 | 129 | blocks = fwHandler.getBlocks(); 130 | detail['LCD Messages'] = lcdMessages; 131 | detail['SysEx target model'] = util.getModelName(selectedDevice.modelId); 132 | 133 | } else { 134 | detail['Format'] = 'Raw binary'; 135 | const blockCount = dataIn.length >> 12; // ÷ 0x1000 136 | for (let blockNum = 0; blockNum < blockCount; blockNum++) { 137 | const offset = blockNum << 12; 138 | const blockContent = dataIn.slice(offset, offset + 0x1000); 139 | 140 | // If the block only contains 0xFF bytes, drop it as this is 141 | // an empty flash block. 142 | blocks[blockNum] = null; 143 | for (let i = 0; i < blockContent.length; i++) { 144 | if (blockContent[i] != 0xFF) { 145 | blocks[blockNum] = blockContent; 146 | break; 147 | } 148 | } 149 | } 150 | 151 | // Try to see if we can identify the firmware. 152 | if (!selectedDevice) { 153 | for (let dev in device) { 154 | if (device[dev].identifyFirmware(blocks)) { 155 | selectedDevice = device[dev]; 156 | break; 157 | } 158 | } 159 | } 160 | } 161 | 162 | return { 163 | device: selectedDevice, 164 | blocks: blocks, 165 | detail: detail, 166 | }; 167 | } 168 | 169 | static encode(deviceModel, address, dataIn, messages = {}) 170 | { 171 | if (!device[deviceModel]) throw new Error('Unsupported device model'); 172 | 173 | const dev = device[deviceModel]; 174 | 175 | let blockCount = 0, messageCount = 0; 176 | let midiBlocks = []; 177 | dev.encodeFirmware(address, dataIn, messages, (binSysExContent, blockType) => { 178 | midiBlocks.push(Buffer.from([ 179 | 0xF0, 180 | 0x00, 181 | 0x20, 182 | 0x32, 183 | 0x7F, // any device ID 184 | dev.modelId, 185 | 0x34, // write fw block 186 | ])); 187 | midiBlocks.push(binSysExContent); 188 | midiBlocks.push(Buffer.from([ 189 | 0xF7, 190 | ])); 191 | if (blockType === 0) blockCount++; 192 | else if (blockType === 1) messageCount++; 193 | }); 194 | 195 | return { 196 | blockCount: blockCount, 197 | messageCount: messageCount, 198 | binFirmware: Buffer.concat(midiBlocks), 199 | }; 200 | } 201 | }; 202 | 203 | module.exports = BehringerFirmware; 204 | -------------------------------------------------------------------------------- /cli/commands/presets.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Command line interface implementation for `presets` function. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const chalk = require('chalk'); 21 | const commandLineArgs = require('command-line-args'); 22 | const debug = require('debug')('behringerctl:cli:presets'); 23 | const fs = require('fs'); 24 | 25 | const { OperationsError } = require('../error.js'); 26 | const output = require('../output.js'); 27 | 28 | class Operations 29 | { 30 | constructor() 31 | { 32 | } 33 | 34 | async destructor() 35 | { 36 | } 37 | 38 | async list(params) 39 | { 40 | output( 41 | chalk.white.inverse('Index'.padEnd(5)), 42 | chalk.white.inverse('Title'.padEnd(16)), 43 | ); 44 | 45 | // Reduce the timeout a bit because the DEQ2496 doesn't respond if the 46 | // preset is empty, even if there are valid presets later on. 47 | this.behringer.defaultTimeout = 500; 48 | 49 | for (let i = 0; i < 65; i++) { 50 | try { 51 | const preset = await this.behringer.readPreset(i); 52 | output( 53 | output.pad(i, 5, chalk.whiteBright), 54 | chalk.greenBright(preset.title) 55 | ); 56 | } catch (e) { 57 | output( 58 | output.pad(i, 5, chalk.whiteBright), 59 | chalk.blueBright('empty') 60 | ); 61 | } 62 | } 63 | } 64 | 65 | async export(params) 66 | { 67 | if (params['index'] === undefined) { 68 | throw new OperationsError('Missing --index.'); 69 | } 70 | if (params['prefix'] === undefined) { 71 | throw new OperationsError('Missing --prefix.'); 72 | } 73 | const count = params['count'] || 1; 74 | const start = parseInt(params['index']); 75 | const end = start + count; 76 | 77 | // Reduce the timeout a bit because the DEQ2496 doesn't respond if the 78 | // preset is empty, even if there are valid presets later on. 79 | this.behringer.defaultTimeout = 500; 80 | 81 | for (let i = start; i < end; i++) { 82 | try { 83 | const preset = await this.behringer.readPreset(i); 84 | const filename = `${params['prefix']}-${i}.bin`; 85 | output( 86 | output.pad(i, 2, chalk.whiteBright) + ':', 87 | output.pad(preset.title, 16, chalk.greenBright), 88 | '->', 89 | chalk.yellowBright(filename) 90 | ); 91 | fs.writeFileSync(filename, Buffer.from(preset.presetRaw)); 92 | 93 | } catch (e) { 94 | output( 95 | output.pad(i, 2, chalk.whiteBright) + ':', 96 | chalk.blueBright('empty') 97 | ); 98 | } 99 | } 100 | } 101 | 102 | async import(params) 103 | { 104 | if (params['index'] === undefined) { 105 | throw new OperationsError('Missing --index.'); 106 | } 107 | if (params['prefix'] === undefined) { 108 | throw new OperationsError('Missing --prefix.'); 109 | } 110 | const count = params['count'] || 1; 111 | const start = parseInt(params['index']); 112 | const end = start + count; 113 | 114 | // Reduce the timeout a bit because the DEQ2496 doesn't respond if the 115 | // preset is empty, even if there are valid presets later on. 116 | this.behringer.defaultTimeout = 500; 117 | 118 | for (let i = start; i < end; i++) { 119 | const filename = `${params['prefix']}-${i}.bin`; 120 | let data; 121 | try { 122 | data = fs.readFileSync(filename); 123 | } catch (e) { 124 | output( 125 | output.pad(i, 2, chalk.whiteBright) + ':', 126 | chalk.redBright('Skipping, unable to read'), 127 | chalk.yellowBright(filename) 128 | ); 129 | continue; 130 | } 131 | 132 | try { 133 | this.behringer.writePreset(i, data); 134 | // Wait for a bit to give it time to save. 135 | await new Promise((resolve, reject) => setTimeout(() => resolve(), 500)); 136 | } catch (e) { 137 | throw new OperationsError('writePreset() failed.'); 138 | } 139 | 140 | let verify; 141 | try { 142 | verify = await this.behringer.readPreset(i); 143 | 144 | } catch (e) { 145 | output( 146 | output.pad(i, 2, chalk.whiteBright) + ':', 147 | chalk.blueBright('empty') 148 | ); 149 | continue; 150 | } 151 | 152 | // Check the data 153 | if (Buffer.compare(Buffer.from(verify.presetRaw), data)) { 154 | output( 155 | output.pad(i, 2, chalk.whiteBright) + ':', 156 | output.pad(verify.title, 16, chalk.greenBright), 157 | '<-', 158 | chalk.yellowBright(filename) 159 | ); 160 | } else { 161 | output( 162 | output.pad(i, 2, chalk.whiteBright) + ':', 163 | chalk.redBright('Verify failed, data was not written correctly.') 164 | ); 165 | } 166 | 167 | } 168 | } 169 | 170 | static async exec(createInstance, args) 171 | { 172 | let cmdDefinitions = [ 173 | { name: 'name', defaultOption: true }, 174 | ]; 175 | const cmd = commandLineArgs(cmdDefinitions, { argv: args, stopAtFirstUnknown: true }); 176 | 177 | if (!cmd.name) { 178 | throw new OperationsError(`subcommand required`); 179 | } 180 | 181 | let proc = new Operations(); 182 | try { 183 | proc.behringer = createInstance(); 184 | } catch (e) { 185 | throw new OperationsError(`Unable to set up MIDI connection: ${e.message}`); 186 | } 187 | 188 | try { 189 | const def = Operations.names[cmd.name] && Operations.names[cmd.name].optionList; 190 | if (def) { 191 | const runOptions = commandLineArgs(def, { argv: cmd._unknown || [] }); 192 | await proc[cmd.name](runOptions); 193 | } else { 194 | throw new OperationsError(`unknown command: ${cmd.name}`); 195 | } 196 | 197 | } finally { 198 | if (proc.destructor) await proc.destructor(); 199 | proc = undefined; 200 | } 201 | } 202 | } 203 | 204 | Operations.names = { 205 | list: { 206 | summary: 'List all presets', 207 | optionList: [], 208 | }, 209 | export: { 210 | summary: 'Save presets to files', 211 | optionList: [ 212 | { 213 | name: 'index', 214 | type: Number, 215 | description: 'First preset to export (0..64)', 216 | }, 217 | { 218 | name: 'count', 219 | type: Number, 220 | description: 'Number of presets to export (default is 1)', 221 | }, 222 | { 223 | name: 'prefix', 224 | type: String, 225 | description: 'Filename prefix ("out" will save "out-0.bin")', 226 | }, 227 | ], 228 | }, 229 | import: { 230 | summary: 'Program the device with presets loaded from local files', 231 | optionList: [ 232 | { 233 | name: 'index', 234 | type: Number, 235 | description: 'First preset to replace (0..64)', 236 | }, 237 | { 238 | name: 'count', 239 | type: Number, 240 | description: 'Number of presets to write (default is 1)', 241 | }, 242 | { 243 | name: 'prefix', 244 | type: String, 245 | description: 'Filename prefix ("out" will load "out-0.bin")', 246 | }, 247 | ], 248 | }, 249 | }; 250 | 251 | module.exports = Operations; 252 | -------------------------------------------------------------------------------- /cli/index.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const commandLineArgs = require('command-line-args'); 3 | const commandLineUsage = require('command-line-usage'); 4 | const debug = require('debug')('behringerctl'); 5 | const glob = require('glob'); 6 | const path = require('path'); 7 | const midi = require('midi'); 8 | 9 | const { OperationsError } = require('./error.js'); 10 | 11 | const Behringer = require('../index.js'); 12 | 13 | const print = console.log; 14 | 15 | function loadCommands() 16 | { 17 | let commands = {}; 18 | for (const file of glob.sync(__dirname + '/commands/*.js' )) { 19 | const filename = path.resolve(file); 20 | const command = path.basename(filename, '.js'); 21 | commands[command] = require(filename); 22 | } 23 | return commands; 24 | } 25 | 26 | function findDefaultPort(midiInterface) 27 | { 28 | const portCount = midiInterface.getPortCount(); 29 | for (let i = 0; i < portCount; i++) { 30 | const portName = midiInterface.getPortName(i); 31 | // Pick first port that doesn't look like a MIDI Through one. 32 | if (!portName.includes('hrough')) { 33 | return i; 34 | } 35 | } 36 | 37 | // No through ports, return the first one. 38 | if (portCount > 0) return 0; 39 | 40 | // No ports at all! 41 | return null; 42 | } 43 | 44 | async function main() 45 | { 46 | const commands = loadCommands(); 47 | 48 | let cmdDefinitions = [ 49 | { name: 'debug', type: Boolean }, 50 | { name: 'midi-in', type: Number }, 51 | { name: 'midi-out', type: Number }, 52 | { name: 'model-id', type: Number }, 53 | { name: 'device-id', type: Number }, 54 | { name: 'all-devices', type: Boolean }, 55 | { name: 'debug-monitor', type: Boolean }, 56 | { name: 'name', defaultOption: true }, 57 | ]; 58 | let argv = process.argv; 59 | 60 | let cmd = commandLineArgs(cmdDefinitions, { argv, stopAtFirstUnknown: true }); 61 | 62 | if (cmd.debug) Debug.mute(false); 63 | 64 | if (!cmd.name || cmd.name === 'help') { 65 | let help = []; 66 | 67 | let subDef = [ 68 | { name: 'name', defaultOption: true, multiple: true }, 69 | ]; 70 | 71 | let helpParams = {}; 72 | try { 73 | helpParams = commandLineArgs(subDef, { argv: cmd._unknown || [] }); 74 | } catch (e) { 75 | switch (e.name) { 76 | case 'UNKNOWN_OPTION': 77 | case 'UNKNOWN_VALUE': 78 | case 'ALREADY_SET': 79 | console.error(e.message); 80 | process.exit(2); 81 | break; 82 | default: 83 | throw e; 84 | } 85 | } 86 | 87 | if (!helpParams.name) { 88 | help.push({ 89 | header: 'Behringer device control utility', 90 | content: './behringerctl [options] [parameters]\n' + 91 | './behringerctl help ', 92 | }); 93 | 94 | help.push({ 95 | header: 'Options', 96 | content: [ 97 | { 98 | name: '--midi-in', 99 | summary: 'Index of MIDI device to receive on, from `midi list`', 100 | }, 101 | { 102 | name: '--midi-out', 103 | summary: 'Index of MIDI device to sent commands to, from `midi list`', 104 | }, 105 | { 106 | name: '--model-id', 107 | summary: 'Optional model number to direct commands to', 108 | }, 109 | { 110 | name: '--device-id', 111 | summary: 'Device number to direct commands to, from `devices list`', 112 | }, 113 | { 114 | name: '--all-devices', 115 | summary: 'Instead of --device-id, send the command to every listening device', 116 | }, 117 | { 118 | name: '--debug-monitor', 119 | summary: 'Wait at exit, monitoring for any further messages (set DEBUG env var to "*")', 120 | }, 121 | ], 122 | }); 123 | 124 | let subCommands = []; 125 | for (const c of Object.keys(commands)) { 126 | const cmdClass = commands[c]; 127 | 128 | if (!cmdClass.names) { 129 | console.error('Malformed class definition for', c); 130 | return; 131 | } 132 | for (const sub of Object.keys(cmdClass.names)) { 133 | subCommands.push({ 134 | name: `${c} ${sub}`, 135 | summary: cmdClass.names[sub].summary, 136 | }); 137 | } 138 | } 139 | help.push({ 140 | header: 'Commands', 141 | content: subCommands, 142 | }); 143 | 144 | help.push({ 145 | header: 'Example', 146 | content: 147 | '# Find available MIDI interfaces\n' + 148 | './behringerctl midi list\n' + 149 | '\n' + 150 | '# Use those MIDI interfaces to find a Behringer device\n' + 151 | './behringerctl --midi-in 1 --midi-out 1 devices list\n' + 152 | '\n' + 153 | '# Send the device a command\n' + 154 | './behringerctl --midi-in 1 --midi-out 1 --device-id 0 devices identify', 155 | }); 156 | 157 | } else if (helpParams.name.length != 2) { 158 | console.error('missing parameter: help [ ]'); 159 | return; 160 | 161 | } else { 162 | if (!commands[helpParams.name[0]]) { 163 | console.error('Unknown command:', helpParams.name[0]); 164 | return; 165 | } 166 | 167 | const subInfo = commands[helpParams.name[0]].names[helpParams.name[1]]; 168 | if (!subInfo) { 169 | console.error('Unknown subcommand:', helpParams.name[1]); 170 | return; 171 | } 172 | 173 | const hasParams = subInfo.optionList.length > 0; 174 | 175 | const strParams = hasParams ? ' [parameters]' : ''; 176 | help.push({ 177 | header: 'Behringer device control utility', 178 | content: `./behringerctl ${helpParams.name[0]} ${helpParams.name[1]}${strParams}`, 179 | }); 180 | 181 | help.push({ 182 | content: subInfo.summary, 183 | }); 184 | 185 | if (hasParams) { 186 | help.push({ 187 | header: 'Parameters', 188 | ...subInfo, 189 | }); 190 | } 191 | } 192 | 193 | process.stdout.write(commandLineUsage(help)); 194 | process.stdout.write('\n'); 195 | return; 196 | } 197 | 198 | if (!commands[cmd.name]) { 199 | console.error(`Unknown command: ${cmd.name}`); 200 | process.exit(1); 201 | } 202 | 203 | let cleanup = () => {}; 204 | 205 | function createInstance() 206 | { 207 | const midiOutput = new midi.Output(); 208 | const outStream = midi.createWriteStream(midiOutput); 209 | const outPort = cmd['midi-out'] || findDefaultPort(midiOutput); 210 | if (outPort === null) { 211 | console.error('No output MIDI ports detected!'); 212 | process.exit(2); 213 | } 214 | midiOutput.openPort(outPort); 215 | 216 | const midiInput = new midi.Input(); 217 | const inPort = cmd['midi-in'] || findDefaultPort(midiInput); 218 | if (inPort === null) { 219 | console.error('No input MIDI ports detected!'); 220 | process.exit(2); 221 | } 222 | midiInput.openPort(inPort); 223 | 224 | // Get sysex, ignore timing + active sense 225 | midiInput.ignoreTypes(false, true, true); 226 | 227 | debug(`Using MIDI ports: in=${inPort} out=${outPort}`); 228 | 229 | const b = new Behringer(outStream); 230 | midiInput.on('message', (deltaTime, message) => b.onMessage(message)); 231 | 232 | let deviceId = null; 233 | if (cmd['device-id'] === undefined) { 234 | if (cmd['all-devices']) { 235 | deviceId = undefined; // any/all 236 | } 237 | // else leave is null (treated as device ID is unset) 238 | } else { 239 | deviceId = parseInt(cmd['device-id']); 240 | } 241 | b.selectDevice(cmd['model-id'], deviceId); 242 | 243 | // Close the ports when we're done or the app will never exit as it's 244 | // waiting to receive more MIDI messages. 245 | cleanup = () => { 246 | midiOutput.closePort(); 247 | if (!cmd['debug-monitor']) { 248 | midiInput.closePort(); 249 | } 250 | }; 251 | return b; 252 | } 253 | 254 | try { 255 | await commands[cmd.name].exec(createInstance, cmd._unknown || []); 256 | } catch (e) { 257 | if (e instanceof OperationsError) { 258 | console.error(chalk.redBright(cmd.name + ':'), e.message); 259 | process.exit(2); 260 | } 261 | switch (e.name) { 262 | case 'UNKNOWN_OPTION': 263 | case 'UNKNOWN_VALUE': 264 | case 'ALREADY_SET': 265 | console.error(chalk.redBright(cmd.name + ':'), e.message); 266 | process.exit(2); 267 | break; 268 | default: 269 | if (debug.enabled) throw e; 270 | console.error(chalk.redBright('Unhandled error:'), e.message); 271 | process.exit(2); 272 | break; 273 | } 274 | } 275 | cleanup(); 276 | } 277 | 278 | module.exports = main; 279 | -------------------------------------------------------------------------------- /device/fbq1000.js: -------------------------------------------------------------------------------- 1 | /** 2 | * FBQ1000 specific code. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const debug = require('debug')('behringerctl:device:deq2496v2'); 21 | const PNG = require('pngjs').PNG; 22 | 23 | const midiFirmwareCoder = require('../algo/midiFirmwareCoder.js'); 24 | const sevenEightCoder = require('../algo/sevenEightCoder.js'); 25 | const util = require('../util.js'); 26 | const xor = require('../algo/xor.js'); 27 | 28 | // Key used to encrypt MIDI flash writes. 29 | const KEY_FW_BLOCK = "TZ'04"; 30 | 31 | // This key is used to encrypt the firmware stored in flash. The key is 32 | // obtained from the bootloader if it's available (e.g. from a full flash dump) 33 | // but since it's missing from the official firmware releases we fall back to 34 | // this key. 35 | const KEY_FW_APP = "- ORIGINAL BEHRINGER CODE - COPYRIGHT 2004 - BGER/TZ - \u0000"; 36 | 37 | class FBQ1000FirmwareDecoder 38 | { 39 | constructor() 40 | { 41 | this.subblocks = []; 42 | } 43 | 44 | /// Decode the content of a MIDI sysex firmware write message. 45 | addMIDIWrite(eventInfo) 46 | { 47 | if (eventInfo.command != 0x34) { 48 | debug(`Ignoring SysEx command ${eventInfo.command.toString(16)}`); 49 | return null; 50 | } 51 | 52 | // Remove the 7/8 coding, restoring the full 8-bit bytes. 53 | const data8bit = sevenEightCoder.decode(eventInfo.binData); 54 | 55 | // Decrypt the data with a simple XOR cipher. 56 | const data = xor(KEY_FW_BLOCK, data8bit); 57 | 58 | const blockNumber = (data[0] << 8) | data[1]; 59 | const flashContent = data.slice(3); 60 | 61 | if (blockNumber === 0xFF00) { 62 | const trimmed = flashContent.slice(0, flashContent.indexOf(0)); 63 | const message = trimmed.toString('utf-8'); 64 | return { 65 | message: message, 66 | }; 67 | } 68 | 69 | this.subblocks[blockNumber] = flashContent; 70 | 71 | return { 72 | blockNumber: blockNumber, 73 | crc: data[2], 74 | binData: flashContent, 75 | }; 76 | } 77 | 78 | getBlocks() 79 | { 80 | let blocks = []; 81 | for (let i = 0; i < 0x80; i++) { 82 | let nextBlock = []; 83 | for (let s = 0; s < 16; s++) { 84 | let subblock = this.subblocks[(i << 4) + s]; 85 | if (!subblock) { 86 | nextBlock = null; 87 | break; 88 | } 89 | nextBlock.push(subblock); 90 | } 91 | if (!nextBlock) continue; 92 | blocks[i] = this.decodeBlock( 93 | i, 94 | Buffer.concat(nextBlock) 95 | ); 96 | } 97 | return blocks; 98 | } 99 | 100 | decodeBlock(blockNum, blockData) 101 | { 102 | // Another layer of encryption to remove. 103 | return midiFirmwareCoder(blockNum, blockData); 104 | } 105 | }; 106 | 107 | class FBQ1000 108 | { 109 | static identifyFirmware(blocks) 110 | { 111 | if (blocks[2]) { 112 | const sig = blocks[2].slice(0xC94, 0xC94 + 25).toString('utf8'); 113 | if (sig === 'DEQ2496V2 BOOTLOADER V2.2') return true; 114 | 115 | } else if (blocks[4]) { 116 | const sig = blocks[4].slice(0x01C, 0x01C + 4).toString('utf8'); 117 | if (sig === 'COPY') return true; 118 | } 119 | 120 | return false; 121 | } 122 | 123 | static getFirmwareDecoder() 124 | { 125 | return new FBQ1000FirmwareDecoder(); 126 | } 127 | 128 | static examineFirmware(blocks) 129 | { 130 | let info = { 131 | id: 'FBQ1000', 132 | detail: [], 133 | images: [], 134 | }; 135 | 136 | // Add a special image for a full firmware dump 137 | info.images.push({ 138 | offset: 0, 139 | capacity: 0x80000, 140 | data: util.blocksToImage(blocks, 0, 0x80, true), 141 | title: '(raw dump of flash chip content, see docs)', 142 | }); 143 | 144 | let appKeyDec; 145 | 146 | // Combine the blocks into images 147 | if (blocks[0] !== undefined) { 148 | const imgContent = util.blocksToImage(blocks, 0, 4); 149 | 150 | info.images.push({ 151 | title: 'Bootloader', 152 | data: imgContent, 153 | offset: 0, 154 | capacity: 0x4000, 155 | }); 156 | 157 | function cut(offset, length) { 158 | return imgContent.slice(offset, offset + length); 159 | } 160 | 161 | info.id = cut(0x2C94, 25); 162 | 163 | const bootKey = cut(0x3002, 0x38); 164 | info.detail.push({ 165 | title: 'Bootloader encryption key', 166 | value: bootKey.toString('utf8'), 167 | preserveTrailing: true, 168 | }); 169 | 170 | const appKeyEnc = cut(0x303A, 0x38); 171 | appKeyDec = xor(bootKey, appKeyEnc); 172 | 173 | info.detail.push({ 174 | title: 'Application encryption key', 175 | value: appKeyDec.toString('utf8'), 176 | preserveTrailing: true, 177 | }); 178 | 179 | info.detail.push({ 180 | title: 'MIDI firmware update encryption key', 181 | value: cut(0x2C84, 5).toString('utf8'), 182 | preserveTrailing: true, 183 | }); 184 | 185 | info.detail.push({ 186 | title: 'Bootloader LCD banner', 187 | value: cut(0x308A, 0x19).toString('utf8'), 188 | }); 189 | } 190 | 191 | if (blocks[4] !== undefined) { 192 | const imgContent = util.blocksToImage(blocks, 4, 0x5B); 193 | 194 | info.images.push({ 195 | title: 'Application (raw)', 196 | data: imgContent, 197 | offset: 0x4000, 198 | capacity: 0x74000 - 0x4000, 199 | }); 200 | 201 | // Use the default known one if we can't get it from the bootloader. 202 | if (!appKeyDec) appKeyDec = KEY_FW_APP; 203 | 204 | const imgAppDec = xor(appKeyDec, imgContent); 205 | info.images.push({ 206 | title: 'Application (decrypted)', 207 | data: imgAppDec, 208 | offset: 0x4000, 209 | capacity: 0x74000 - 0x4000, 210 | }); 211 | } 212 | 213 | if (blocks[0x5B] !== undefined) { 214 | const imgContent = util.blocksToImage(blocks, 0x5B, 0x74); 215 | 216 | info.images.push({ 217 | title: 'Unused', 218 | data: imgContent, 219 | offset: 0x5B000, 220 | capacity: 0x74000 - 0x5B000, 221 | }); 222 | } 223 | 224 | if (blocks[0x74] !== undefined) { 225 | const imgContent = util.blocksToImage(blocks, 0x74, 0x7C); 226 | 227 | info.images.push({ 228 | title: 'Presets', 229 | data: imgContent, 230 | offset: 0x74000, 231 | capacity: 0x7C000 - 0x74000, 232 | }); 233 | } 234 | 235 | if (blocks[0x7C] !== undefined) { 236 | const imgContent = util.blocksToImage(blocks, 0x7C, 0x7E); 237 | 238 | info.images.push({ 239 | title: 'Scratch space', 240 | data: imgContent, 241 | offset: 0x7C000, 242 | capacity: 0x7E000 - 0x7C000, 243 | }); 244 | } 245 | 246 | if (blocks[0x7E] !== undefined) { 247 | const imgContent = util.blocksToImage(blocks, 0x7E, 0x80); 248 | 249 | info.images.push({ 250 | title: 'Boot screen', 251 | data: imgContent, 252 | offset: 0x7E000, 253 | capacity: 0x80000 - 0x7E000, 254 | }); 255 | 256 | let pngBoot = new PNG({ 257 | width: 320, 258 | height: 80, 259 | inputColorType: 0, // greyscale 260 | colorType: 0, // greyscale 261 | }); 262 | 263 | for (let p = 0; p < imgContent.length; p++) { 264 | const pixelByte = imgContent[p]; 265 | for (let bit = 0; bit < 8; bit++) { 266 | const pixel = (pixelByte & (0x80 >> bit)) ? 0xFF : 0x00; 267 | const y = (p / 40) >>> 0, x = (p % 40) * 8 + bit; 268 | let offset = 4 * (320 * y + x); 269 | pngBoot.data[offset] = pixel; 270 | pngBoot.data[offset+1] = pixel; 271 | pngBoot.data[offset+2] = pixel; 272 | pngBoot.data[offset+3] = 0xFF; 273 | } 274 | } 275 | 276 | info.images.push({ 277 | title: 'Boot screen (converted to .png)', 278 | data: PNG.sync.write(pngBoot), 279 | offset: 0x7E000, 280 | }); 281 | } 282 | 283 | return info; 284 | } 285 | }; 286 | 287 | FBQ1000.modelId = util.models.fbq1000; 288 | 289 | module.exports = FBQ1000; 290 | -------------------------------------------------------------------------------- /device/deq2496v1.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DEQ2496v1 specific code. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const debug = require('debug')('behringerctl:device:deq2496v1'); 21 | 22 | const midiFirmwareCoder = require('../algo/midiFirmwareCoder.js'); 23 | const sevenEightCoder = require('../algo/sevenEightCoder.js'); 24 | const util = require('../util.js'); 25 | const xor = require('../algo/xor.js'); 26 | 27 | // Key used to encrypt MIDI flash writes. 28 | const KEY_FW_BLOCK = "TZ'02"; 29 | 30 | // This key is used to encrypt the firmware stored in flash. The key is 31 | // obtained from the bootloader if it's available (e.g. from a full flash dump) 32 | // but since it's missing from the official firmware releases we fall back to 33 | // this key. 34 | const KEY_FW_APP = "- ORIGINAL BEHRINGER CODE - COPYRIGHT 2002 - BGER/TZ - \u0000"; 35 | 36 | class DEQ2496v1FirmwareDecoder 37 | { 38 | constructor() 39 | { 40 | this.subblocks = []; 41 | } 42 | 43 | /// Decode the content of a MIDI sysex firmware write message. 44 | addMIDIWrite(eventInfo) 45 | { 46 | if (eventInfo.command != 0x34) { 47 | debug(`Ignoring SysEx command ${eventInfo.command.toString(16)}`); 48 | return null; 49 | } 50 | 51 | // Remove the 7/8 coding, restoring the full 8-bit bytes. 52 | const data8bit = sevenEightCoder.decode(eventInfo.binData); 53 | 54 | // Decrypt the data with a simple XOR cipher. 55 | const data = xor(KEY_FW_BLOCK, data8bit); 56 | 57 | const blockNumber = (data[0] << 8) | data[1]; 58 | const flashContent = data.slice(3); 59 | 60 | if (blockNumber === 0xFF00) { 61 | const trimmed = flashContent.slice(0, flashContent.indexOf(0)); 62 | const message = trimmed.toString('utf-8'); 63 | return { 64 | message: message, 65 | }; 66 | } 67 | 68 | this.subblocks[blockNumber] = flashContent; 69 | 70 | return { 71 | blockNumber: blockNumber, 72 | crc: data[2], 73 | binData: flashContent, 74 | }; 75 | } 76 | 77 | getBlocks() 78 | { 79 | let blocks = []; 80 | for (let i = 0; i < 0x80; i++) { 81 | let nextBlock = []; 82 | for (let s = 0; s < 16; s++) { 83 | let subblock = this.subblocks[(i << 4) + s]; 84 | if (!subblock) { 85 | nextBlock = null; 86 | break; 87 | } 88 | nextBlock.push(subblock); 89 | } 90 | if (!nextBlock) continue; 91 | blocks[i] = this.decodeBlock( 92 | i, 93 | Buffer.concat(nextBlock) 94 | ); 95 | } 96 | return blocks; 97 | } 98 | 99 | decodeBlock(blockNum, blockData) 100 | { 101 | // Another layer of encryption to remove. 102 | return midiFirmwareCoder(blockNum, blockData); 103 | } 104 | }; 105 | 106 | class DEQ2496v1 107 | { 108 | static identifyFirmware(blocks) 109 | { 110 | if (blocks[0]) { 111 | const sig = blocks[2].slice(0xC94, 0xC94 + 25).toString('utf8'); 112 | // if (sig === 'DEQ2496V2 BOOTLOADER V2.2') return true; 113 | 114 | } else if (blocks[2]) { 115 | const sig = blocks[2].slice(0x020, 0x020 + 3).toString('utf8'); 116 | debug('sig', sig); 117 | if (sig === 'SIG') return true; 118 | } 119 | 120 | return false; 121 | } 122 | 123 | static getFirmwareDecoder() 124 | { 125 | return new DEQ2496v1FirmwareDecoder(); 126 | } 127 | 128 | static encodeFirmware(address, binData, fnCallback) 129 | { 130 | // XOR encrypt just the application blocks 131 | if (address == 0x4000) { 132 | debug('Address 0x4000 means app, encrypting with app key'); 133 | binData = xor(KEY_FW_APP, binData); 134 | } 135 | 136 | const blockCount = binData.length >> 12; // ÷ 0x1000 137 | for (let i = 0; i < blockCount; i++) { 138 | const blockNum = (address >> 12) + i; 139 | 140 | const offset = i << 12; 141 | let blockContent = binData.slice(offset, offset + 0x1000); 142 | 143 | // Only the application blocks are encrypted. 144 | if ((blockNum >= 0x04) && (blockNum < 0x5B)) { 145 | blockContent = midiFirmwareCoder(blockNum, blockContent); 146 | } 147 | 148 | // Split the 4 kB block up into 256 byte chunks. 149 | for (let sub = 0; sub < 16; sub++) { 150 | const offset = sub << 8; 151 | let subblock = blockContent.slice(offset, offset + 256); 152 | 153 | let checksum = checksumTZ(subblock); 154 | 155 | let midiBlockNum = (blockNum << 4) | sub; 156 | 157 | let header = Buffer.from([ 158 | midiBlockNum >> 8, 159 | midiBlockNum & 0xFF, 160 | checksum, 161 | ]); 162 | 163 | subblock = Buffer.concat([header, subblock]); 164 | 165 | // Encrypt the data with a simple XOR cipher. 166 | subblock = xor(KEY_FW_BLOCK, subblock); 167 | 168 | // Add the 7/8 coding, turning the 8-bit data into 7-bit clean. 169 | subblock = sevenEightCoder.encode(subblock); 170 | 171 | fnCallback(Buffer.from(subblock)); 172 | } 173 | } 174 | } 175 | 176 | static examineFirmware(blocks) { 177 | let info = { 178 | id: 'DEQ2496v1', 179 | detail: [], 180 | images: [], 181 | }; 182 | 183 | // Add a special image for a full firmware dump 184 | info.images.push({ 185 | offset: 0, 186 | capacity: 0x80000, 187 | data: util.blocksToImage(blocks, 0, 0x80, true), 188 | title: '(raw dump of flash chip content, see docs)', 189 | }); 190 | 191 | let appKeyDec; 192 | 193 | // Combine the blocks into images 194 | if (blocks[0] !== undefined) { 195 | const imgContent = util.blocksToImage(blocks, 0, 4); 196 | 197 | info.images.push({ 198 | title: 'Bootloader', 199 | data: imgContent, 200 | offset: 0, 201 | capacity: 0x4000, 202 | }); 203 | 204 | function cut(offset, length) { 205 | return imgContent.slice(offset, offset + length); 206 | } 207 | 208 | info.id = cut(0x2C94, 25); 209 | 210 | const bootKey = cut(0x3002, 0x38); 211 | info.detail.push({ 212 | title: 'Bootloader encryption key', 213 | value: bootKey.toString('utf8'), 214 | preserveTrailing: true, 215 | }); 216 | 217 | const appKeyEnc = cut(0x303A, 0x38); 218 | appKeyDec = xor(bootKey, appKeyEnc); 219 | 220 | info.detail.push({ 221 | title: 'Application encryption key', 222 | value: appKeyDec.toString('utf8'), 223 | preserveTrailing: true, 224 | }); 225 | 226 | info.detail.push({ 227 | title: 'MIDI firmware update encryption key', 228 | value: cut(0x2C84, 5).toString('utf8'), 229 | preserveTrailing: true, 230 | }); 231 | 232 | info.detail.push({ 233 | title: 'Bootloader LCD banner', 234 | value: cut(0x308A, 0x19).toString('utf8'), 235 | }); 236 | } 237 | 238 | if (blocks[2] !== undefined) { 239 | const imgContent = util.blocksToImage(blocks, 2, 0x5F); 240 | 241 | info.images.push({ 242 | title: 'Application (raw)', 243 | data: imgContent, 244 | offset: 0x2000, 245 | capacity: 0x5F000 - 0x2000, 246 | }); 247 | 248 | // Use the default known one if we can't get it from the bootloader. 249 | if (!appKeyDec) appKeyDec = KEY_FW_APP; 250 | 251 | const imgAppDec = xor(appKeyDec, imgContent); 252 | info.images.push({ 253 | title: 'Application (decrypted)', 254 | data: imgAppDec, 255 | offset: 0x2000, 256 | capacity: 0x5F000 - 0x2000, 257 | }); 258 | } 259 | 260 | if (blocks[0x5F] !== undefined) { 261 | const imgContent = util.blocksToImage(blocks, 0x5F, 0x60); 262 | 263 | info.images.push({ 264 | title: 'Startup screen', 265 | data: imgContent, 266 | offset: 0x5F000, 267 | capacity: 0x60000 - 0x5F000, 268 | }); 269 | } 270 | 271 | if (blocks[0x60] !== undefined) { 272 | const imgContent = util.blocksToImage(blocks, 0x60, 0x68); 273 | 274 | info.images.push({ 275 | title: 'Presets', 276 | data: imgContent, 277 | offset: 0x60000, 278 | capacity: 0x68000 - 0x60000, 279 | }); 280 | } 281 | 282 | if (blocks[0x68] !== undefined) { 283 | const imgContent = util.blocksToImage(blocks, 0x68, 0x6A); 284 | 285 | info.images.push({ 286 | title: 'Scratch space', 287 | data: imgContent, 288 | offset: 0x68000, 289 | capacity: 0x6A000 - 0x68000, 290 | }); 291 | } 292 | 293 | if (blocks[0x6A] !== undefined) { 294 | const imgContent = util.blocksToImage(blocks, 0x6A, 0x80); 295 | 296 | info.images.push({ 297 | title: 'Hardware data', 298 | data: imgContent, 299 | offset: 0x6A000, 300 | capacity: 0x80000 - 0x6A000, 301 | }); 302 | } 303 | 304 | return info; 305 | } 306 | }; 307 | 308 | DEQ2496v1.modelId = util.models.deq2496; 309 | 310 | module.exports = DEQ2496v1; 311 | -------------------------------------------------------------------------------- /device/deq2496v2.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DEQ2496v2 specific code. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const debug = require('debug')('behringerctl:device:deq2496v2'); 21 | const PNG = require('pngjs').PNG; 22 | 23 | const checksumTZ = require('../algo/checksumTZ.js'); 24 | const midiFirmwareCoder = require('../algo/midiFirmwareCoder.js'); 25 | const sevenEightCoder = require('../algo/sevenEightCoder.js'); 26 | const util = require('../util.js'); 27 | const xor = require('../algo/xor.js'); 28 | 29 | // Key used to encrypt MIDI flash writes. 30 | const KEY_FW_BLOCK = "TZ'04"; 31 | 32 | // This key is used to encrypt the firmware stored in flash. The key is 33 | // obtained from the bootloader if it's available (e.g. from a full flash dump) 34 | // but since it's missing from the official firmware releases we fall back to 35 | // this key. 36 | const KEY_FW_APP = "- ORIGINAL BEHRINGER CODE - COPYRIGHT 2004 - BGER/TZ - \u0000"; 37 | 38 | class DEQ2496v2FirmwareDecoder 39 | { 40 | constructor() 41 | { 42 | this.subblocks = []; 43 | } 44 | 45 | /// Decode the content of a MIDI sysex firmware write message. 46 | addMIDIWrite(eventInfo) 47 | { 48 | if (eventInfo.command != 0x34) { 49 | debug(`Ignoring SysEx command ${eventInfo.command.toString(16)}`); 50 | return null; 51 | } 52 | 53 | // Remove the 7/8 coding, restoring the full 8-bit bytes. 54 | const data8bit = sevenEightCoder.decode(eventInfo.binData); 55 | 56 | // Decrypt the data with a simple XOR cipher. 57 | const data = xor(KEY_FW_BLOCK, data8bit); 58 | 59 | const blockNumber = (data[0] << 8) | data[1]; 60 | const flashContent = data.slice(3); 61 | 62 | if (blockNumber === 0xFF00) { 63 | const trimmed = flashContent.slice(0, flashContent.indexOf(0)); 64 | const message = trimmed.toString('utf-8'); 65 | return { 66 | message: message, 67 | }; 68 | } 69 | 70 | this.subblocks[blockNumber] = flashContent; 71 | 72 | return { 73 | blockNumber: blockNumber, 74 | crc: data[2], 75 | binData: flashContent, 76 | }; 77 | } 78 | 79 | getBlocks() 80 | { 81 | let blocks = []; 82 | for (let i = 0; i < 0x80; i++) { 83 | let nextBlock = []; 84 | for (let s = 0; s < 16; s++) { 85 | let subblock = this.subblocks[(i << 4) + s]; 86 | if (!subblock) { 87 | nextBlock = null; 88 | break; 89 | } 90 | nextBlock.push(subblock); 91 | } 92 | if (!nextBlock) continue; 93 | blocks[i] = this.decodeBlock( 94 | i, 95 | Buffer.concat(nextBlock) 96 | ); 97 | } 98 | return blocks; 99 | } 100 | 101 | decodeBlock(blockNum, blockData) 102 | { 103 | // Another layer of encryption to remove. This seems to apply to all 104 | // blocks, but so far only app and bootlogo have been confirmed. 105 | return midiFirmwareCoder(blockNum, blockData); 106 | } 107 | }; 108 | 109 | class DEQ2496v2 110 | { 111 | static identifyFirmware(blocks) 112 | { 113 | if (blocks[2]) { 114 | const sig = blocks[2].slice(0xC94, 0xC94 + 25).toString('utf8'); 115 | if (sig === 'DEQ2496V2 BOOTLOADER V2.2') return true; 116 | 117 | } else if (blocks[4]) { 118 | const sig = blocks[4].slice(0x01C, 0x01C + 4).toString('utf8'); 119 | if (sig === 'COPY') return true; 120 | } 121 | 122 | return false; 123 | } 124 | 125 | static getFirmwareDecoder() 126 | { 127 | return new DEQ2496v2FirmwareDecoder(); 128 | } 129 | 130 | static encodeFirmware(address, binData, messages, fnCallback) 131 | { 132 | // XOR encrypt just the application blocks 133 | if (address == 0x4000) { 134 | binData = xor(KEY_FW_APP, binData); 135 | } 136 | // XOR encrypt just the application blocks 137 | // If flashing boot logo, check for .png 138 | if (address == 0x7E000) { 139 | try { 140 | const png = PNG.sync.read(binData); 141 | debug('Input is in .png format, address is boot logo, converting'); 142 | if ((png.width != 320) || (png.height != 80)) { 143 | throw new Error('Input .png must be 320x80 pixels'); 144 | } 145 | let outData = Buffer.alloc(png.height * png.width / 8); 146 | for (let y = 0; y < png.height; y++) { 147 | for (let x = 0; x < png.width; x++) { 148 | let idx = (png.width * y + x) << 2; 149 | // We're only looking at the red channel but since it's mono it 150 | // probably doesn't matter. 151 | if (png.data[idx] == 0xFF) { 152 | outData[y * 40 + (x / 8) >>> 0] |= 0x80 >> (x % 8); 153 | } 154 | } 155 | } 156 | binData = outData; 157 | } catch (e) { 158 | debug('Not .png, continuing with original data:', e.message); 159 | } 160 | } 161 | 162 | // Pad the data up to 4 kB with 0xFF bytes (unflashed data) 163 | const padding = 4096 - (binData.length % 4096); 164 | const binPad = Buffer.alloc(padding, 0xFF); 165 | binData = Buffer.concat([binData, binPad]); 166 | 167 | function packSubblock(binData, midiBlockNum) 168 | { 169 | let checksum = checksumTZ(binData); 170 | 171 | let header = Buffer.from([ 172 | midiBlockNum >> 8, 173 | midiBlockNum & 0xFF, 174 | checksum, 175 | ]); 176 | 177 | let binBlock = Buffer.concat([header, binData]); 178 | 179 | // Encrypt the data with a simple XOR cipher. 180 | binBlock = xor(KEY_FW_BLOCK, binBlock); 181 | 182 | // Add the 7/8 coding, turning the 8-bit data into 7-bit clean. 183 | binBlock = sevenEightCoder.encode(binBlock); 184 | 185 | return Buffer.from(binBlock); 186 | } 187 | 188 | function insertMessage(strContent) 189 | { 190 | if (strContent) { 191 | // We have a message for this spot 192 | let msgblock = Buffer.concat([ 193 | Buffer.from(strContent), 194 | Buffer.alloc(256 - strContent.length, 0x00), 195 | ]); 196 | msgblock = packSubblock(msgblock, 0xFF00); 197 | fnCallback(msgblock, 1); 198 | } 199 | } 200 | 201 | let subblockCount = 0; 202 | 203 | // blockCount is the number of 4 kB blocks in the input data. 204 | const blockCount = binData.length >> 12; // ÷ 0x1000 205 | for (let i = 0; i < blockCount; i++) { 206 | const blockNum = (address >> 12) + i; 207 | 208 | const offset = i << 12; 209 | let blockContent = binData.slice(offset, offset + 0x1000); 210 | 211 | // Apply the block-level encryption. 212 | blockContent = midiFirmwareCoder(blockNum, blockContent); 213 | 214 | // Split the 4 kB block up into 256 byte chunks. 215 | for (let sub = 0; sub < 16; sub++) { 216 | 217 | // If there should be a message before this block, generate it. 218 | insertMessage(messages[subblockCount++]); 219 | 220 | // Generate the actual block. 221 | const offset = sub << 8; 222 | let subblock = blockContent.slice(offset, offset + 256); 223 | let midiBlockNum = (blockNum << 4) | sub; 224 | 225 | subblock = packSubblock(subblock, midiBlockNum); 226 | 227 | fnCallback(subblock, 0); 228 | } 229 | } 230 | 231 | // If there's a final message at one block larger than the number we have, 232 | // insert it too. 233 | insertMessage(messages[subblockCount]); 234 | } 235 | 236 | static examineFirmware(blocks) 237 | { 238 | let info = { 239 | id: 'DEQ2496v2', 240 | detail: [], 241 | images: [], 242 | }; 243 | 244 | // Add a special image for a full firmware dump 245 | info.images.push({ 246 | offset: 0, 247 | capacity: 0x80000, 248 | data: util.blocksToImage(blocks, 0, 0x80, true), 249 | title: '(raw dump of flash chip content, see docs)', 250 | }); 251 | 252 | let appKeyDec; 253 | 254 | // Combine the blocks into images 255 | if (blocks[0] !== undefined) { 256 | const imgContent = util.blocksToImage(blocks, 0, 4); 257 | 258 | info.images.push({ 259 | title: 'Bootloader', 260 | data: imgContent, 261 | offset: 0, 262 | capacity: 0x4000, 263 | }); 264 | 265 | function cut(offset, length) { 266 | return imgContent.slice(offset, offset + length); 267 | } 268 | 269 | info.id = cut(0x2C94, 25); 270 | 271 | const bootKey = cut(0x3002, 0x38); 272 | info.detail.push({ 273 | title: 'Bootloader encryption key', 274 | value: bootKey.toString('utf8'), 275 | preserveTrailing: true, 276 | }); 277 | 278 | const appKeyEnc = cut(0x303A, 0x38); 279 | appKeyDec = xor(bootKey, appKeyEnc); 280 | 281 | info.detail.push({ 282 | title: 'Application encryption key', 283 | value: appKeyDec.toString('utf8'), 284 | preserveTrailing: true, 285 | }); 286 | 287 | info.detail.push({ 288 | title: 'MIDI firmware update encryption key', 289 | value: cut(0x2C84, 5).toString('utf8'), 290 | preserveTrailing: true, 291 | }); 292 | 293 | info.detail.push({ 294 | title: 'Bootloader LCD banner', 295 | value: cut(0x308A, 0x19).toString('utf8'), 296 | }); 297 | } 298 | 299 | if (blocks[4] !== undefined) { 300 | const imgContent = util.blocksToImage(blocks, 4, 0x5B); 301 | 302 | info.images.push({ 303 | title: 'Application (raw)', 304 | data: imgContent, 305 | offset: 0x4000, 306 | capacity: 0x74000 - 0x4000, 307 | }); 308 | 309 | // Use the default known one if we can't get it from the bootloader. 310 | if (!appKeyDec) appKeyDec = KEY_FW_APP; 311 | 312 | const imgAppDec = xor(appKeyDec, imgContent); 313 | info.images.push({ 314 | title: 'Application (decrypted)', 315 | data: imgAppDec, 316 | offset: 0x4000, 317 | capacity: 0x74000 - 0x4000, 318 | }); 319 | } 320 | 321 | if (blocks[0x5B] !== undefined) { 322 | const imgContent = util.blocksToImage(blocks, 0x5B, 0x74); 323 | 324 | info.images.push({ 325 | title: 'Unused', 326 | data: imgContent, 327 | offset: 0x5B000, 328 | capacity: 0x74000 - 0x5B000, 329 | }); 330 | } 331 | 332 | if (blocks[0x74] !== undefined) { 333 | const imgContent = util.blocksToImage(blocks, 0x74, 0x7C); 334 | 335 | info.images.push({ 336 | title: 'Presets', 337 | data: imgContent, 338 | offset: 0x74000, 339 | capacity: 0x7C000 - 0x74000, 340 | }); 341 | } 342 | 343 | if (blocks[0x7C] !== undefined) { 344 | const imgContent = util.blocksToImage(blocks, 0x7C, 0x7E); 345 | 346 | info.images.push({ 347 | title: 'Scratch space', 348 | data: imgContent, 349 | offset: 0x7C000, 350 | capacity: 0x7E000 - 0x7C000, 351 | }); 352 | } 353 | 354 | if (blocks[0x7E] !== undefined) { 355 | const imgContent = util.blocksToImage(blocks, 0x7E, 0x80); 356 | 357 | info.images.push({ 358 | title: 'Boot screen', 359 | data: imgContent, 360 | offset: 0x7E000, 361 | capacity: 0x80000 - 0x7E000, 362 | }); 363 | 364 | let pngBoot = new PNG({ 365 | width: 320, 366 | height: 80, 367 | inputColorType: 0, // greyscale 368 | colorType: 0, // greyscale 369 | }); 370 | 371 | for (let p = 0; p < imgContent.length; p++) { 372 | const pixelByte = imgContent[p]; 373 | for (let bit = 0; bit < 8; bit++) { 374 | const pixel = (pixelByte & (0x80 >> bit)) ? 0xFF : 0x00; 375 | const y = (p / 40) >>> 0, x = (p % 40) * 8 + bit; 376 | let offset = 4 * (320 * y + x); 377 | pngBoot.data[offset] = pixel; 378 | pngBoot.data[offset+1] = pixel; 379 | pngBoot.data[offset+2] = pixel; 380 | pngBoot.data[offset+3] = 0xFF; 381 | } 382 | } 383 | 384 | info.images.push({ 385 | title: 'Boot screen (converted to .png)', 386 | data: PNG.sync.write(pngBoot), 387 | offset: 0x7E000, 388 | }); 389 | } 390 | 391 | return info; 392 | } 393 | }; 394 | 395 | DEQ2496v2.modelId = util.models.deq2496; 396 | 397 | module.exports = DEQ2496v2; 398 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Behringer device control library. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const debug = require('debug')('behringerctl:behringer'); 21 | const g_debug = debug; 22 | 23 | const sevenEightCoder = require('./algo/sevenEightCoder.js'); 24 | const checksumTZ = require('./algo/checksumTZ.js'); 25 | const util = require('./util.js'); 26 | 27 | const DEVICE_ID_ANY = 0x7F; 28 | const SYSEX_COMPANY_ID_BEHRINGER = 0x002032; 29 | 30 | class Behringer 31 | { 32 | constructor(midiOutputStream) 33 | { 34 | // How many milliseconds to wait before giving up on a SysEx that we never 35 | // received a response to. 36 | this.defaultTimeout = 2000; 37 | 38 | this.midiOut = midiOutputStream; 39 | this.listeners = {}; 40 | this.nextListenerId = 1; 41 | this.modelId = util.models.ANY; 42 | this.deviceId = null; 43 | } 44 | 45 | /// Handle an incoming MIDI message. 46 | /** 47 | * @param Array message 48 | * Raw MIDI message as an array of bytes. 49 | */ 50 | onMessage(message) 51 | { 52 | const debug = g_debug.extend('receive'); 53 | 54 | if (message[0] !== 0xF0) { 55 | debug('Ignoring non SysEx message'); 56 | return; 57 | } 58 | 59 | const companyId = (message[1] << 16) | (message[2] << 8) | message[3]; 60 | if (companyId !== SYSEX_COMPANY_ID_BEHRINGER) { 61 | debug(`Ignoring message for unsupported company: ${companyId}`); 62 | return; 63 | } 64 | 65 | let response = { 66 | deviceId: message[4], 67 | modelId: message[5], 68 | command: message[6], 69 | data: message.slice(7, message.length - 8), 70 | }; 71 | 72 | debug(`${util.getModelName(response.modelId)}@${response.deviceId}: ${util.getCommandName(response.command)}`); 73 | debug.extend('trace')(response.data); 74 | 75 | this.callListeners(response); 76 | } 77 | 78 | /// Find all connected devices. 79 | /** 80 | * @param Number waitTime 81 | * Number of milliseconds to wait until returning responses. Should be 82 | * large enough to give all connected devices time to respond. 83 | * 84 | * @param Number modelId 85 | * Restrict the query to devices of specific models only. Defaults to any. 86 | * 87 | * @param Number deviceId 88 | * Restrict the query to devices with a specific ID only. Defaults to any. 89 | * 90 | * @return Array of discovered devices, e.g. `[ {modelId: 18, deviceId: 0, modelName: 'DEQ2496'} ]`. 91 | */ 92 | find(waitTime = 5000, modelId = util.models.ANY, deviceId = DEVICE_ID_ANY) 93 | { 94 | return new Promise((resolve, reject) => { 95 | let deviceList = [ 96 | ]; 97 | 98 | const listenerId = this.addListener( 99 | modelId, 100 | deviceId, 101 | util.commands.identifyResponse, 102 | msg => { 103 | deviceList.push({ 104 | modelId: msg.modelId, 105 | deviceId: msg.deviceId, 106 | modelName: Buffer.from(msg.data).toString('ascii'), 107 | }); 108 | } 109 | ); 110 | this.sendMessage(modelId, deviceId, util.commands.identify, []); 111 | 112 | const timerHandle = setTimeout(() => { 113 | this.removeListener(listenerId); 114 | resolve(deviceList); 115 | }, waitTime); 116 | }); 117 | } 118 | 119 | /// Select a device for the other functions to communicate with. 120 | /** 121 | * @param Number modelId 122 | * Model ID of the device to send to, returned by find(). 123 | * 124 | * @param Number deviceId 125 | * Device ID of the device to send to, returned by find(). Specify 126 | * `undefined` to set to any/all devices. 127 | * 128 | * @return None. 129 | */ 130 | selectDevice(modelId = util.models.ANY, deviceId) 131 | { 132 | debug(`Selected model ${modelId}, device ${deviceId}`); 133 | this.modelId = modelId; 134 | if (deviceId === undefined) { 135 | this.deviceId = DEVICE_ID_ANY; 136 | } else { 137 | this.deviceId = deviceId; 138 | } 139 | } 140 | 141 | sanityCheck() 142 | { 143 | if (this.deviceId === null) { 144 | throw new Error('A device ID was not specified.'); 145 | } 146 | } 147 | 148 | /// Query the selected device. 149 | /** 150 | * @pre Device has been chosen by selectDevice(). 151 | * 152 | * @return Object Device info, e.g. `{modelName: 'DEQ2496'}`. 153 | */ 154 | async identify() 155 | { 156 | this.sanityCheck(); 157 | 158 | const response = await this.sendMessageAsync( 159 | this.modelId, 160 | this.deviceId, 161 | util.commands.identify, 162 | [], 163 | util.commands.identifyResponse, 164 | ); 165 | 166 | return { 167 | modelId: response.modelId, 168 | deviceId: response.deviceId, 169 | modelName: Buffer.from(response.data).toString('ascii'), 170 | }; 171 | } 172 | 173 | /// Read a preset. 174 | /** 175 | * @pre Device has been chosen by selectDevice(). 176 | * 177 | * @return Object Preset details. 178 | */ 179 | async readPreset(index) 180 | { 181 | this.sanityCheck(); 182 | 183 | const response = await this.sendMessageAsync( 184 | this.modelId, 185 | this.deviceId, 186 | util.commands.readSinglePreset, 187 | [index], 188 | util.commands.writeSinglePreset, 189 | ); 190 | 191 | const length = (response.data[1] << 7) | response.data[2]; 192 | 193 | debug('TODO: Preset data is cut off (preset title truncated at 10 chars)'); 194 | return { 195 | modelId: response.modelId, 196 | deviceId: response.deviceId, 197 | presetIndex: response.data[0], 198 | presetLength: length, 199 | presetContent: response.data.slice(3, length), 200 | // Title is whatever is following on from the data 201 | title: Buffer.from(response.data.slice(length + 3)).toString('ascii'), 202 | // Omit the index but keep the length field 203 | presetRaw: response.data.slice(1), 204 | }; 205 | } 206 | 207 | /// Read a preset. 208 | /** 209 | * @pre Device has been chosen by selectDevice(). 210 | * 211 | * @param Buffer content 212 | * Raw data to write. Must not contain any bytes >= 0x80. 213 | * 214 | * @return None. 215 | */ 216 | async writePreset(index, content) 217 | { 218 | this.sanityCheck(); 219 | 220 | const data = [ 221 | index, 222 | ...content, 223 | ]; 224 | 225 | this.sendMessage( 226 | this.modelId, 227 | this.deviceId, 228 | util.commands.writeSinglePreset, 229 | data, 230 | ); 231 | } 232 | 233 | /// Retrieve a copy of the device's LCD display. 234 | /** 235 | * @pre Device has been chosen by selectDevice(). 236 | * 237 | * @return Array of rows, with each row an array of pixels. Pixel values are 238 | * 0 for off or 255 for on. 239 | */ 240 | async getScreenshot() 241 | { 242 | this.sanityCheck(); 243 | 244 | const response = await this.sendMessageAsync( 245 | this.modelId, 246 | this.deviceId, 247 | util.commands.getScreenshot, 248 | [], 249 | util.commands.screenshotResponse, 250 | ); 251 | 252 | let width, height, pixels = [], row = []; 253 | switch (response.modelId) { 254 | case util.models.deq2496: 255 | width = 46 * 7; 256 | height = 80; 257 | for (let d of response.data) { 258 | for (let i = 0; i < 7; i++) { 259 | const p = (d << i) & 0x40; 260 | row.push(p ? 255 : 0); 261 | } 262 | if (row.length === width) { 263 | pixels.push(row); 264 | row = []; 265 | } 266 | } 267 | if (row.length) { 268 | debug('WARNING: Device returned incomplete final row'); 269 | pixels.push(row); 270 | } 271 | break; 272 | } 273 | 274 | return { 275 | modelId: response.modelId, 276 | deviceId: response.deviceId, 277 | width: width, 278 | height: height, 279 | raw: response.data, 280 | pixels: pixels, 281 | }; 282 | } 283 | 284 | /// Change the MIDI channel the device will listen on. 285 | /** 286 | * @pre Device has been chosen by selectDevice(). 287 | * 288 | * @param Number channel 289 | * New channel between 0 and 15 inclusive. 290 | * 291 | * @return None 292 | * 293 | * @note The device will no longer respond on the original ID, as the MIDI 294 | * channel is the same as the device ID. Use selectDevice() to continue 295 | * communication with the device after the MIDI channel has been changed. 296 | */ 297 | setMIDIChannel(channel) 298 | { 299 | this.sanityCheck(); 300 | 301 | this.sendMessage( 302 | this.modelId, 303 | this.deviceId, 304 | 0x24, 305 | [channel], 306 | ); 307 | } 308 | 309 | async setLCDMessage(text) 310 | { 311 | this.sanityCheck(); 312 | 313 | const textBytes = text.split('').map(c => c.charCodeAt(0)); 314 | const dataBlock = [ 315 | ...textBytes, 316 | ...new Array(256 - textBytes.length).fill(0), 317 | ]; 318 | 319 | return await this.writeBlock(0xFF00, dataBlock); 320 | } 321 | 322 | packBlock(offset, content) 323 | { 324 | return [ 325 | offset >> 8, 326 | offset & 0xFF, 327 | checksumTZ(content), 328 | ...content, 329 | ]; 330 | } 331 | 332 | encodeBlock(offset, content) 333 | { 334 | if (!content || (content.length != 256)) { 335 | throw new Error('Can only write blocks of 256 bytes'); 336 | } 337 | 338 | let packedData = this.packBlock(offset, content); 339 | 340 | // Encrypt the data with a simple XOR cipher. 341 | const key = "TZ'04"; 342 | for (let i = 0; i < packedData.length; i++) { 343 | packedData[i] ^= key[i % key.length].charCodeAt(0); 344 | } 345 | 346 | // Encode the 8-bit data into MIDI SysEx-safe 7-bit bytes. 347 | const encodedData = sevenEightCoder.encode(packedData); 348 | 349 | return encodedData; 350 | } 351 | 352 | async writeBlock(offset, content) 353 | { 354 | const debug = g_debug.extend('writeBlock'); 355 | 356 | this.sendMessage( 357 | this.modelId, 358 | this.deviceId, 359 | util.commands.writeFlash, 360 | this.encodeBlock(offset, content), 361 | ); 362 | 363 | return; 364 | } 365 | 366 | /// Add a callback to receive incoming MIDI messages. 367 | addListener(modelId, deviceId, command, callback) 368 | { 369 | const listenerId = this.nextListenerId++; 370 | this.listeners[listenerId] = { 371 | modelId: modelId, 372 | deviceId: deviceId, 373 | command: command, 374 | callback: callback, 375 | }; 376 | } 377 | 378 | /// Stop a callback from receiving further incoming MIDI messages. 379 | removeListener(listenerId) 380 | { 381 | delete this.listeners[listenerId]; 382 | } 383 | 384 | /// Send a message with zero or more expected responses. 385 | /** 386 | * @param Number modelId 387 | * Model ID of the device to send to, returned by find(), or `models.ANY`. 388 | * 389 | * @param Number deviceId 390 | * Device ID of the device to send to, returned by find(), or `DEVICE_ID_ANY`. 391 | * 392 | * @param Number command 393 | * Command to send, see `commands` global variable. 394 | * 395 | * @return Nothing. 396 | * 397 | * @note Add a listener with addListener() if any responses to this message 398 | * are expected. 399 | */ 400 | sendMessage(modelId, deviceId, command, data) 401 | { 402 | const debug = g_debug.extend('send'); 403 | let content = [ 404 | 0xF0, // SysEx start 405 | (SYSEX_COMPANY_ID_BEHRINGER >> 16) & 0x7F, 406 | (SYSEX_COMPANY_ID_BEHRINGER >> 8) & 0x7F, 407 | (SYSEX_COMPANY_ID_BEHRINGER >> 0) & 0x7F, 408 | deviceId & 0x7F, 409 | modelId & 0x7F, 410 | command & 0x7F, 411 | ...data, 412 | 0xF7, 413 | ]; 414 | debug(`${util.getModelName(modelId)}@${deviceId}: ${util.getCommandName(command)}`); 415 | debug.extend('trace')(content); 416 | 417 | this.midiOut.write(content); 418 | } 419 | 420 | /// Send a message with exactly one expected response. 421 | /** 422 | * @param Number modelId 423 | * Model ID of the device to send to, returned by find(). 424 | * 425 | * @param Number deviceId 426 | * Device ID of the device to send to, returned by find(). 427 | * 428 | * @param Number command 429 | * Command to send, see `util.commands` list. 430 | * 431 | * @param Number responseCommand 432 | * Only resolve the returned promise when this command is received from 433 | * the device. Can be `util.commands.ANY` to resolve on the next command 434 | * received. 435 | * 436 | * @param Number timeout 437 | * Optional, defaults to `defaultTimeout`. Rejects the promise if no 438 | * response is received within this many milliseconds. 439 | * 440 | * @return Promise, resolving to the received message on success. 441 | */ 442 | sendMessageAsync(modelId, deviceId, command, data, responseCommand, timeout) 443 | { 444 | return new Promise((resolve, reject) => { 445 | const timerHandle = setTimeout(() => { 446 | this.removeListener(listenerId); 447 | reject(new Error('Timed out waiting for response')); 448 | }, timeout || this.defaultTimeout); 449 | 450 | const listenerId = this.addListener(modelId, deviceId, responseCommand, msg => { 451 | clearTimeout(timerHandle); 452 | this.removeListener(listenerId); 453 | resolve(msg); 454 | }); 455 | 456 | this.sendMessage(modelId, deviceId, command, data); 457 | }); 458 | } 459 | 460 | callListeners(message) 461 | { 462 | for (const l of Object.values(this.listeners)) { 463 | if ( 464 | ( 465 | (message.deviceId !== DEVICE_ID_ANY) 466 | && (l.deviceId !== DEVICE_ID_ANY) 467 | && (message.deviceId !== l.deviceId) 468 | ) || ( 469 | (message.modelId !== util.models.ANY) 470 | && (l.modelId !== util.models.ANY) 471 | && (message.modelId !== l.modelId) 472 | ) || ( 473 | (l.command !== util.commands.ANY) 474 | && (message.command !== l.command) 475 | ) 476 | ) { 477 | // Doesn't match this device, try the next one. 478 | continue; 479 | } 480 | l.callback(message); 481 | } 482 | } 483 | }; 484 | 485 | Behringer.firmware = require('./firmware.js'); 486 | Behringer.util = util; 487 | 488 | module.exports = Behringer; 489 | -------------------------------------------------------------------------------- /cli/commands/firmware.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Command line interface implementation for `firmware` function. 3 | * 4 | * Copyright (C) 2020 Adam Nielsen 5 | * 6 | * This program is free software: you can redistribute it and/or modify 7 | * it under the terms of the GNU General Public License as published by 8 | * the Free Software Foundation, either version 3 of the License, or 9 | * (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program. If not, see . 18 | */ 19 | 20 | const chalk = require('chalk'); 21 | const commandLineArgs = require('command-line-args'); 22 | const debug = require('debug')('behringerctl:cli:firmware'); 23 | const fs = require('fs'); 24 | 25 | const Behringer = require('../../index.js'); 26 | const { OperationsError } = require('../error.js'); 27 | const output = require('../output.js'); 28 | 29 | const midiData = require('../../midiData.js'); 30 | const sevenEightCoder = require('../../algo/sevenEightCoder.js'); 31 | const xor = require('../../algo/xor.js'); 32 | 33 | class Operations 34 | { 35 | constructor() 36 | { 37 | } 38 | 39 | async destructor() 40 | { 41 | } 42 | 43 | examine(params) 44 | { 45 | if (!params['read']) { 46 | throw new OperationsError('Missing filename to --read.'); 47 | } 48 | 49 | const dataIn = fs.readFileSync(params['read']); 50 | 51 | let firmware; 52 | try { 53 | firmware = Behringer.firmware.decode(dataIn, params['model']); 54 | } catch (e) { 55 | if (debug.enabled) throw e; // don't catch if we're debugging 56 | throw new OperationsError(`Error decoding firmware: ${e.message}`); 57 | } 58 | 59 | if (!firmware.device) { 60 | throw new OperationsError('Unable to autodetect the device model this ' 61 | + 'firmware is for. Please specify --device-model.'); 62 | } 63 | 64 | const dumpFilename = params['debug-dump']; 65 | if (dumpFilename) { 66 | const rawData = Behringer.util.blocksToImage(firmware.blocks, 0, 0xFFFF, false); 67 | fs.writeFileSync(dumpFilename, rawData); 68 | 69 | output( 70 | 'Raw dump to', 71 | chalk.greenBright(dumpFilename), 72 | ); 73 | return; 74 | } 75 | 76 | let info = firmware.device.examineFirmware(firmware.blocks); 77 | 78 | if (!info) { 79 | throw new OperationsError('Unrecognised firmware image'); 80 | } 81 | 82 | for (let i of Object.keys(firmware.detail)) { 83 | let value = firmware.detail[i]; 84 | if (typeof(value) == 'object') { 85 | // Value is an object, so convert it into a string of key=value pairs. 86 | const list = Object.keys(value).reduce((out, key) => { 87 | out.push(key + '="' + value[key] + '"'); 88 | return out; 89 | }, []); 90 | value = list.join(', '); 91 | } 92 | output(chalk.whiteBright(i + ':'), chalk.greenBright(value)); 93 | } 94 | output(chalk.whiteBright('Device:'), chalk.greenBright(info.id)); 95 | for (let i of info.detail) { 96 | const valColour = i.preserveTrailing ? chalk.black.bgGreen : chalk.greenBright; 97 | output( 98 | chalk.whiteBright(i.title + ':'), 99 | valColour(i.value) 100 | ); 101 | } 102 | output(); 103 | 104 | output( 105 | chalk.white.inverse('Index'.padStart(5)), 106 | chalk.white.inverse('Offset'.padStart(10)), 107 | chalk.white.inverse('Available'.padStart(10)), 108 | chalk.white.inverse('Used'.padStart(10)), 109 | chalk.white.inverse('%'.padStart(3)), 110 | chalk.white.inverse('Image name'.padEnd(24)), 111 | ); 112 | for (let i in info.images) { 113 | const img = info.images[i]; 114 | output( 115 | output.padLeft(i - 1, 5, chalk.whiteBright), 116 | output.padLeft('0x' + img.offset.toString(16), 10, chalk.magentaBright), 117 | output.padLeft(img.capacity || 'N/A', 10, chalk.cyanBright), 118 | output.padLeft(img.data.length, 10, chalk.greenBright), 119 | output.padLeft(img.capacity ? Math.round((img.data.length / img.capacity) * 100) : '-', 3, chalk.blueBright), 120 | chalk.yellowBright(img.title), 121 | ); 122 | } 123 | 124 | if (params['extract-index'] !== undefined) { 125 | if (!params['write']) { 126 | throw new OperationsError('Missing filename to --write.'); 127 | } 128 | const index = parseInt(params['extract-index']); 129 | 130 | const img = info.images[index + 1]; 131 | if (!img) { 132 | throw new OperationsError('Invalid --extract-index.'); 133 | } 134 | 135 | const writeFilename = params['write']; 136 | fs.writeFileSync(writeFilename, img.data); 137 | 138 | output( 139 | 'Wrote image', 140 | chalk.yellowBright(index), 141 | 'to', 142 | chalk.greenBright(writeFilename), 143 | ); 144 | } 145 | } 146 | 147 | examineBootloader(params) 148 | { 149 | if (!params['read']) { 150 | throw new OperationsError('Missing filename to --read.'); 151 | } 152 | 153 | let dataIn = fs.readFileSync(params['read']); 154 | 155 | let blocks = []; 156 | while (dataIn.length > 10) { 157 | let block = { 158 | address: 159 | ( 160 | (dataIn[0] << 0) | 161 | (dataIn[1] << 8) | 162 | (dataIn[2] << 16) | 163 | (dataIn[3] << 24) 164 | ) >>> 0, // make unsigned 165 | length: 166 | ( 167 | (dataIn[4] << 0) | 168 | (dataIn[5] << 8) | 169 | (dataIn[6] << 16) | 170 | (dataIn[7] << 24) 171 | ) >>> 0, // make unsigned 172 | flags: 173 | (dataIn[8] << 0) | 174 | (dataIn[9] << 8), 175 | flagText: [], 176 | }; 177 | block.content = dataIn.slice(10, 10 + block.length); 178 | dataIn = dataIn.slice(10 + block.length); 179 | 180 | // Taken from description above FGRAB_HEADER in ADSP-BF533-ROM-V03.asm 181 | // (BF533 internal boot ROM source code) available at: 182 | // https://sourceforge.net/projects/adiopshw/files/BootROM%20Source/ 183 | if (block.flags & (1 << 0)) block.flagText.push('ZEROFILL'); 184 | if (block.flags & (1 << 1)) block.flagText.push('RESVECT'); 185 | if (block.flags & (1 << 2)) block.flagText.push('(reserved2)'); 186 | if (block.flags & (1 << 3)) block.flagText.push('INIT'); 187 | if (block.flags & (1 << 4)) block.flagText.push('IGNORE'); 188 | if (block.flags & (0xF << 5)) block.flagText.push('PFLAG=' + ((block.flags >> 5) & 0xF)); 189 | if (block.flags & (1 << 9)) block.flagText.push('(reserved9)'); 190 | if (block.flags & (1 << 10)) block.flagText.push('(reserved10)'); 191 | if (block.flags & (1 << 11)) block.flagText.push('(reserved11)'); 192 | if (block.flags & (1 << 12)) block.flagText.push('(reserved12)'); 193 | if (block.flags & (1 << 13)) block.flagText.push('(reserved13)'); 194 | if (block.flags & (1 << 14)) block.flagText.push('(reserved14)'); 195 | if (block.flags & (1 << 15)) block.flagText.push('FINAL'); 196 | 197 | if (block.length > (1 << 21)) { // 2MB 198 | throw new OperationsError(`Corrupted bootloader - block length (${block.length}) is larger than total memory size.`); 199 | } 200 | blocks.push(block); 201 | } 202 | if (dataIn.length > 0) { 203 | blocks.push({ 204 | address: 'Leftover', 205 | content: dataIn, 206 | }); 207 | } 208 | 209 | const flashFlags = blocks[0].address & 0xFF; 210 | const flashWidth = (flashFlags & 0xF0 === 0x60) ? '16' : '8'; 211 | blocks[0].flagText.push(`flash=${flashWidth}-bit`); 212 | 213 | // From GRAB_HEADER in internal boot rom source, see above. 214 | output('Entrypoint:', chalk.magentaBright('0xffa08000')); 215 | 216 | output( 217 | chalk.white.inverse('Index'.padStart(5)), 218 | chalk.white.inverse('Address'.padStart(10)), 219 | chalk.white.inverse('Size'.padStart(10)), 220 | chalk.white.inverse('Flags'.padEnd(24)), 221 | ); 222 | for (let i in blocks) { 223 | const img = blocks[i]; 224 | output( 225 | output.padLeft(i, 5, chalk.whiteBright), 226 | output.padLeft('0x' + img.address.toString(16).padStart(8, '0'), 10, chalk.magentaBright), 227 | output.padLeft(img.length, 10, chalk.greenBright), 228 | output.pad(img.flagText.join(' '), 17, chalk.yellowBright), 229 | ); 230 | } 231 | 232 | if (params['extract-index'] !== undefined) { 233 | if (!params['write']) { 234 | throw new OperationsError('Missing filename to --write.'); 235 | } 236 | const index = parseInt(params['extract-index']); 237 | 238 | const img = info.images[index + 1]; 239 | if (!img) { 240 | throw new OperationsError('Invalid --extract-index.'); 241 | } 242 | 243 | const writeFilename = params['write']; 244 | fs.writeFileSync(writeFilename, img.data); 245 | 246 | output( 247 | 'Wrote image', 248 | chalk.yellowBright(index), 249 | 'to', 250 | chalk.greenBright(writeFilename), 251 | ); 252 | } 253 | } 254 | 255 | syx2bin(params) 256 | { 257 | if (!params['read']) { 258 | throw new OperationsError('Missing filename to --read.'); 259 | } 260 | if (!params['write']) { 261 | throw new OperationsError('Missing filename to --write.'); 262 | } 263 | const binMIDI = fs.readFileSync(params['read']); 264 | 265 | let chunks = []; 266 | midiData.processMIDI(binMIDI, event => { 267 | const eventInfo = midiData.parseSysEx(event); 268 | 269 | chunks.push(Buffer.from([eventInfo.command])); 270 | 271 | let data8bit = sevenEightCoder.decode(eventInfo.binData); 272 | debug(`SysEx command: ${eventInfo.command.toString(16)}, ` 273 | + `length: ${eventInfo.binData.length}, ` 274 | + `8-bit-length: ${data8bit.length}`); 275 | 276 | if (params['xor']) { 277 | data8bit = xor(params['xor'], data8bit); 278 | } 279 | chunks.push(Buffer.from(data8bit)); 280 | }); 281 | 282 | const dataOut = Buffer.concat(chunks); 283 | const writeFilename = params['write']; 284 | fs.writeFileSync(writeFilename, dataOut); 285 | 286 | output( 287 | 'Extracted raw SysEx data to', 288 | chalk.greenBright(writeFilename), 289 | ); 290 | } 291 | 292 | generate(params) 293 | { 294 | if (!params['model']) { 295 | throw new OperationsError('Missing --model.'); 296 | } 297 | if (!params['read']) { 298 | throw new OperationsError('Missing filename to --read.'); 299 | } 300 | if (!params['write']) { 301 | throw new OperationsError('Missing filename to --write.'); 302 | } 303 | if (params['address'] === undefined) { 304 | throw new OperationsError('Missing --address.'); 305 | } 306 | 307 | let messages = {}; 308 | if (params['messages']) { 309 | const pairs = params['messages'].split(','); 310 | for (let p of pairs) { 311 | const [strIndex, msg] = p.split('='); 312 | const index = parseInt(strIndex); 313 | messages[index] = msg; 314 | output( 315 | 'At block', 316 | chalk.yellowBright(index), 317 | 'writing message:', 318 | chalk.bgGreen.black(msg) 319 | ); 320 | } 321 | } 322 | debug('Messages:', messages); 323 | 324 | const dataIn = fs.readFileSync(params['read']); 325 | 326 | const fwOut = Behringer.firmware.encode( 327 | params['model'], 328 | parseInt(params['address']), 329 | dataIn, 330 | messages, 331 | ); 332 | 333 | const writeFilename = params['write']; 334 | fs.writeFileSync(writeFilename, fwOut.binFirmware); 335 | 336 | output( 337 | 'Wrote sysex firmware image with', 338 | chalk.greenBright(fwOut.blockCount), 339 | 'data blocks +', 340 | chalk.greenBright(fwOut.messageCount), 341 | 'message blocks to', 342 | chalk.greenBright(writeFilename), 343 | ); 344 | } 345 | 346 | static async exec(createInstance, args) 347 | { 348 | let cmdDefinitions = [ 349 | { name: 'name', defaultOption: true }, 350 | ]; 351 | const cmd = commandLineArgs(cmdDefinitions, { argv: args, stopAtFirstUnknown: true }); 352 | 353 | if (!cmd.name) { 354 | throw new OperationsError(`subcommand required.`); 355 | } 356 | 357 | let proc = new Operations(); 358 | 359 | try { 360 | const def = Operations.names[cmd.name] && Operations.names[cmd.name].optionList; 361 | if (def) { 362 | const runOptions = commandLineArgs(def, { argv: cmd._unknown || [] }); 363 | await proc[cmd.name](runOptions); 364 | } else { 365 | throw new OperationsError(`unknown command: ${cmd.name}`); 366 | } 367 | 368 | } finally { 369 | if (proc.destructor) await proc.destructor(); 370 | proc = undefined; 371 | } 372 | } 373 | } 374 | 375 | Operations.names = { 376 | examine: { 377 | summary: 'Print details about a raw (fully decoded) firmware binary', 378 | optionList: [ 379 | { 380 | name: 'model', 381 | type: String, 382 | description: 'Device model, can be autodetected for some *.syx files', 383 | }, 384 | { 385 | name: 'read', 386 | type: String, 387 | description: '*.bin or *.syx firmware file to read', 388 | }, 389 | { 390 | name: 'extract-index', 391 | type: Number, 392 | description: 'Optional image index to extract from firmware blob', 393 | }, 394 | { 395 | name: 'write', 396 | type: String, 397 | description: 'Filename to save --extract-index to', 398 | }, 399 | { 400 | name: 'debug-dump', 401 | type: String, 402 | description: 'Dump SysEx decoded data for identifying new firmware images', 403 | }, 404 | ], 405 | }, 406 | examineBootloader: { 407 | summary: 'Print details about a raw (fully decoded) bootloader binary', 408 | optionList: [ 409 | { 410 | name: 'model', 411 | type: String, 412 | description: 'Device model', 413 | }, 414 | { 415 | name: 'read', 416 | type: String, 417 | description: '*.bin firmware file to read', 418 | }, 419 | { 420 | name: 'extract-index', 421 | type: Number, 422 | description: 'Optional image index to extract from firmware blob', 423 | }, 424 | { 425 | name: 'write', 426 | type: String, 427 | description: 'Filename to save --extract-index to', 428 | }, 429 | ], 430 | }, 431 | syx2bin: { 432 | summary: 'Convert SysEx events in *.syx raw MIDI data into 8-bit binary, ' 433 | + 'for examining unsupported firmware images', 434 | optionList: [ 435 | { 436 | name: 'read', 437 | type: String, 438 | description: '*.bin firmware file to read', 439 | }, 440 | { 441 | name: 'write', 442 | type: String, 443 | description: 'Filename to create (*.syx)', 444 | }, 445 | { 446 | name: 'xor', 447 | type: String, 448 | description: 'Optional XOR key to apply over content', 449 | }, 450 | ], 451 | }, 452 | generate: { 453 | summary: 'Create a *.syx firmware image', 454 | optionList: [ 455 | { 456 | name: 'model', 457 | type: String, 458 | description: 'Device model being flashed', 459 | }, 460 | { 461 | name: 'read', 462 | type: String, 463 | description: '*.bin firmware file to read', 464 | }, 465 | { 466 | name: 'address', 467 | type: Number, 468 | description: 'Address in flash memory chip to write to', 469 | }, 470 | { 471 | name: 'write', 472 | type: String, 473 | description: 'Filename to create (*.syx)', 474 | }, 475 | { 476 | name: 'messages', 477 | type: String, 478 | description: 'Messages to display of the form "0=Starting flash,10=Up to block 10"', 479 | }, 480 | ], 481 | }, 482 | }; 483 | 484 | module.exports = Operations; 485 | -------------------------------------------------------------------------------- /doc/behringer-deq2496.md: -------------------------------------------------------------------------------- 1 | # Behringer Ultracurve Pro DEQ2496 2 | 3 | ## Hardware 4 | 5 | * 2x ADSP-21065L "SHARC" - DSPs ([PDF](https://www.analog.com/media/en/technical-documentation/data-sheets/ADSP-21065L.pdf)) 6 | * ADSP-BF531 "Blackfin" - microcontroller ([PDF](https://www.analog.com/media/en/technical-documentation/data-sheets/ADSP-BF531_BF532_BF533.pdf)) 7 | * ISSI IC42S16100E-7TL - 2 MB SDRAM (512K * 16-bit * 2-bank = 16Mbit) 8 | * SST39SF040 - 512 kB flash 9 | * AK5393VS - ADC, 24 bit 1 kHz to 108 kHz I²S 10 | * AK4393VF - DAC, 24-bit 108 kHz I²S 11 | * AK4524 - 24-bit 96 kHz audio codec with differential outputs 12 | * AK4114 - AES3/SPDIF transceiver 13 | 14 | ## Additional functions 15 | 16 | * Holding the MEMORY button while powering on the unit will reset most of the 17 | settings back to the default, but it will keep the presets untouched. 18 | 19 | * Holding the COMPARE and MEMORY buttons while powering on the device will ask 20 | whether you want to wipe all the presets. You must press a button to confirm 21 | before the presets are wiped. 22 | 23 | * Holding the UTILITY button while powering on the device will enter the 24 | bootloader (see below). Power cycle it again to return to normal operation. 25 | 26 | ### Bugs 27 | 28 | #### Firmware 2.5 29 | 30 | * You can use the `setMIDIChannel` command to set a channel up to 128, even 31 | though channels above 16 are invalid. When a channel above 16 is set, the 32 | device only responds to device ID 1. 33 | 34 | * The `getScreenshot` command returns 7 bytes short, so the last 49 pixels of 35 | the screenshot are missing. Most screens don't use the last row of pixels 36 | so it's not noticeable. You can however see it on RTA page 3. 37 | 38 | * The `getSinglePreset` command seems to return incomplete data, as the preset 39 | title is truncated to 10 characters. Writing a preset can set a title longer 40 | than 10 characters however. 41 | 42 | ## Bootloader 43 | 44 | The bootloader can be used to reflash the firmware, even if a previous flash 45 | was unsuccessful (as long as the part of the flash holding the bootloader was 46 | untouched). The official firmware images never appear to touch the boot loader 47 | so these should never brick the device. 48 | 49 | In the bootloader, the device responds over MIDI to a subset of the normal 50 | functions. It also identifies itself with the model `DEQ2496V2 BOOTLOAD` 51 | rather than the usual `DEQ2496`. 52 | 53 | ## Flash layout 54 | 55 | This differs from the official SysEx doc. Perhaps the doc is for hardware V1. 56 | 57 | * 0x00000 - 0x03FFF (16 kB): Bootloader 58 | * 0x04000 - 0x73FFF (448 kB): Application, only 0x4000-0x5B000 (348 kB) is used 59 | * 0x74000 - 0x7BFFF (32 kB): User presets 60 | * 0x7C000 - 0x7DFFF (8 kB): Scratch space (contains old presets) 61 | * 0x7E000 - 0x7FFFF (8 kB): Boot logo (320×80 1bpp bitmap, 3200 bytes) 62 | 63 | The application segment is the only part reflashed by the official firmware 64 | releases, which means if the process fails, the bootloader will be left intact 65 | so further attempts at reflashing are possible. 66 | 67 | ## Encryption 68 | 69 | ### Run time 70 | 71 | There is a lot of obfuscation of the firmware, however it is all done with 72 | simple XOR ciphers so it is for the most part trivial to undo. 73 | 74 | The application segment in the firmware is XOR encrypted, and this is decrypted 75 | by the bootloader during startup. The decryption key is itself encrypted within 76 | the bootloader code: 77 | 78 | * Offset 0x3002, length 0x38: Bootloader key 79 | * Offset 0x303A, length 0x38: Application key, encrypted with bootloader key 80 | 81 | The application key can be recovered by XORing it with the bootloader key, which 82 | reveals `- ORIGINAL BEHRINGER CODE - COPYRIGHT 2004 - BGER/TZ - \x00` (note 83 | trailing space followed by a null byte). 84 | 85 | This cleartext application key must then be XOR'd over the data in the 86 | application segment to reveal the cleartext application code. 87 | 88 | ### Reflash 89 | 90 | When the firmware is reflashed via the MIDI interface, multiple levels of 91 | encryption are again used. The process happens as follows: 92 | 93 | 1. The device receives a MIDI SysEx event number 0x34 ("write firmware block"). 94 | 95 | 2. The 7-bit SysEx data is converted back to 8-bit data, by taking every eighth 96 | byte and using its bits as the MSB bits of the preceeding seven bytes. Thus 97 | every eight bytes in the SysEx event produce seven usable bytes. See 98 | [sevenEightCoder.js](https://github.com/Malvineous/behringerctl/blob/master/algo/sevenEightCoder.js) 99 | for example code. 100 | 101 | 3. An XOR cipher with the key `TZ'04` is applied to the data block to decode it. 102 | 103 | 4. The block structure is now a UINT16BE block number, then a UINT8 crc, 104 | followed by 256 bytes of data. 105 | 106 | 5. The checksum byte is checked. The Behringer docs call this a CRC but in 107 | reality it's a homebrew checksum. See [checksumTZ.js](https://github.com/Malvineous/behringerctl/blob/master/algo/checksumTZ.js) 108 | for how to calculate it. Contrary to the Behringer docs, the block number 109 | is not included in the checksum and only the 256 data bytes are used. 110 | 111 | 6. The devices stores the 256 bytes into memory (not flash) and does not 112 | respond yet. 113 | 114 | 7. After the 16th block has been received (4 kB) another XOR cipher is applied 115 | to the 4 kB block as a whole. This algorithm uses the block's destination 116 | flash address as the key, rotating the key and flipping some of its bits as 117 | it goes. Unlike the earlier ciphers this one works at the 16-bit level 118 | rather than at the byte level. See 119 | [midiFirmwareCoder.js](https://github.com/Malvineous/behringerctl/blob/master/algo/midiFirmwareCoder.js) 120 | for the implementation. 121 | 122 | 8. The 4 kB block is then written to the flash chip and an acknowledgement is 123 | sent back as a SysEx event, and a message is shown on the LCD. 124 | 125 | 9. Note that the 4 kB block actually written to flash is still encrypted, as the 126 | bootloader decrypts it when copying the data from flash into RAM at boot up. 127 | 128 | Writing to the special block number 0xFF00 causes the device to display the 129 | ASCII content on the LCD screen. The stock firmware image contains extra blocks 130 | at the beginning and end of the firmware data to write messages indicating the 131 | process is beginning and has completed. When flashing from within the 132 | application, the message gets overwritten during the flash process as each 4 kB 133 | block written to flash causes a message with the block number to be displayed 134 | on the LCD, overwriting the previous message. When flashing from the bootloader 135 | however, the message remains visible as a graphical representation of the blocks 136 | being flashed is shown instead, which does not overwrite the last message. Thus 137 | with some creativity, one could write progress messages throughout the firmware 138 | image such that a progress meter or "percentage flashed so far" information is 139 | shown throughout the procedure. 140 | 141 | ## Firmware dumps 142 | 143 | ### Images 144 | 145 | When extracting firmware images, the official firmware releases typically only 146 | update the 'application' portion of the flash chip, leaving the areas in the 147 | flash storing the bootloader and user presets unchanged. 148 | 149 | When dumping one of these images through the CLI with the `firmware examine` 150 | command, there are a few options: 151 | 152 | ``` 153 | Index Offset Available Used % Image name 154 | -1 0x0 524288 524288 100 (raw dump of flash chip content, see docs) 155 | 0 0x4000 458752 356352 78 Application (raw) 156 | 1 0x4000 458752 356352 78 Application (decrypted) 157 | ``` 158 | 159 | Extracting image 0 will write the same data to a file that would be written to 160 | the flash chip, except that the data will be written to the start of the file 161 | (offset 0) but it would go into the flash chip beginning at offset 0x4000, just 162 | after the bootloader code. 163 | 164 | Extracting image 1 will do the same, however a decryption will be performed to 165 | reveal the cleartext code. This data is not written to the flash, but at power 166 | on, the bootloader performs this decryption when it copies the application code 167 | into RAM. So the data written to the file in this case is what ends up in the 168 | device's memory, being executed by the processor. If you intend to disassemble 169 | the code, this is the image to use. 170 | 171 | Image -1 will provide a full dump the size of the flash chip, with any missing 172 | blocks filled with `0xFF` bytes, to simulate empty flash blocks. This will 173 | result in the application data at offset 0x4000 being written to offset 0x4000 174 | in the file (and it will be encrypted, just as it is in the real flash chip), 175 | however any data missing from the source file (e.g. the bootloader code itself) 176 | will be replaced with 0xFF bytes in the output file. Although the output file 177 | will be the same size as the flash chip, be careful not to flash this if there 178 | are missing sections, otherwise you will erase the bootloader and the device 179 | will no longer function, until an EEPROM programmer is used to reflash the 180 | missing bootloader. 181 | 182 | If you have a full firmware dump of the flash chip taken with an EEPROM reader, 183 | then image -1 will just give you the same file back again unchanged. 184 | 185 | ### Disassembly 186 | 187 | To disassemble the code, a Blackfin disassembler is needed. The GNU GCC project 188 | used to have support for the Blackfin ISA, however this is now discontinued. 189 | An older version of GCC can still be used to disassemble the code however, and 190 | a Docker container exists to make this process very painless. 191 | 192 | Once you have Docker installed and the ability to run containers, load the 193 | `pf0camino/cross-bfin-elf` container: 194 | 195 | docker run -p 1222:22 -v /home/user/blackfin-projects/:/projects/ --rm=true pf0camino/cross-bfin-elf 196 | 197 | Replace `/home/user/blackfin-projects/` with a path on the host machine where 198 | data will be shared with the Docker container. The firmware files can be put 199 | in this folder on the host, where the disassember in the Docker container can 200 | read them. The disassembly output inside Docker will also be written here, 201 | where it can be accessed on the host machine as well, even after the Docker 202 | container has been terminated. 203 | 204 | In another shell, connect to the container via SSH: 205 | 206 | ssh user@localhost -p 1222 207 | cd /projects 208 | 209 | The default password is `secret`. 210 | 211 | To disassemble a raw firmware dump, copy the file into the project folder and 212 | then inside Docker, use the `objdump` command: 213 | 214 | bfin-elf-objdump -D -b binary -mbfin bootloader.bin > bootloader.disasm 215 | 216 | To compile your own code (untested) you should be able to do something like 217 | this: 218 | 219 | bfin-elf-gcc -mcpu=bf531 -o example.elf example.cpp 220 | bfin-elf-objcopy -I elf32-bfin -O binary example.elf example.bin 221 | 222 | You will need to encrypt the binary before flashing it to the chip. 223 | 224 | ### Bootloader 225 | 226 | The bootloader isn't a raw image file but a kind of container holding multiple 227 | images (like a .zip file but without any compression). The Blackfin boot 228 | sequence reads the headers in this data that dictate which blocks of data are 229 | written to which memory address at power up. 230 | 231 | The CLI can display these headers but not yet build a bootloader image from raw 232 | files: 233 | 234 | behringerctl firmware examineBootloader --read bootloader.bin 235 | 236 | Entrypoint: 0xffa08000 237 | Index Address Size Flags 238 | 0 0xff800000 4 IGNORE flash=8-bit 239 | 1 0xffa08000 278 240 | 2 0xffa08000 2 INIT 241 | 3 0xff800000 4 IGNORE 242 | 4 0x00408000 11024 243 | 5 0x0040ab10 26492 ZEROFILL 244 | 245 | For more information on the addresses and flags, refer to the section on booting 246 | in the Blackfin architecture manual. 247 | 248 | ### LCD messages 249 | 250 | A special SysEx message can be sent to the device to write a message to the LCD 251 | screen. This is used to write a message at the start and end of the firmware 252 | update process (although strangely it is not used to give progress updates). 253 | 254 | When examining a SysEx firmware image (*.syx), these messages are displayed of 255 | the form `0="example", 10="test"` which means `example` is written to the LCD 256 | screen before any firmware blocks are sent, and `test` is written to the screen 257 | after the 10th block has been sent. Here, a block is defined as a single SysEx 258 | message writing a 256-byte block of data. 259 | 260 | ## MIDI interface 261 | 262 | ### SysEx 263 | 264 | The official Behringer document is mostly correct, although it's for the 265 | DEQ2496v1 rather than v2. 266 | 267 | #### 0x26 Unknown 268 | 269 | The device sends a response to this message. Its purpose is currently unknown. 270 | 271 | #### 0x27 Unknown 272 | 273 | The device sends a response to this message. Its purpose is currently unknown. 274 | 275 | #### 0x28 Unknown 276 | 277 | The device sends a response to this message. Its purpose is currently unknown. 278 | 279 | #### 0x2a Unknown 280 | 281 | The device sends a response to this message. Its purpose is currently unknown. 282 | 283 | #### 0x36 Unknown 284 | 285 | The device sends a response to this message. Its purpose is currently unknown. 286 | 287 | #### 0x35 writeFlashResponse 288 | 289 | No parameters. 290 | 291 | After the 16th 256-byte subblock has been written with `writeFlashBlock(0x34)`, 292 | the 4 kB block is flashed to the flash chip and `writeFlashResponse(0x35)` is 293 | returned on success. 294 | 295 | #### 0x62 Unknown 296 | 297 | This event's purpose is currently unknown. 298 | 299 | ## Reflashing safely 300 | 301 | Flash addresses 0 to 0x4000 are used to store the bootloader. As long as you 302 | never write to these addresses, the chances of bricking the device are tiny. 303 | Providing the bootloader remains intact, you will always be able to use it to 304 | reflash the rest of the chip by following the official firmware flashing 305 | procedure, which steps you through accessing the bootloader and performing the 306 | flash. This will allow you to return the device to a working state. 307 | 308 | Things like the boot logo (and the bootloader itself) are not part of the 309 | official firmware releases, so if you change these you will need to keep a copy 310 | of the original in order to restore it should you wish. 311 | 312 | If you flash something to the device that does not work, then providing the 313 | bootloader is left intact, you can always boot into it and reflash again. The 314 | device will never be bricked permanently so long as the bootloader is left 315 | alone. 316 | 317 | If you do accidentally overwrite the bootloader, then the only way to unbrick 318 | the device is to open it up, remove the SST39-series flash chip, and use an 319 | external EEPROM programmer to reflash it. These are available cheaply online. 320 | 321 | However you will need to reflash the corrupted bootloader at address 0, which 322 | means you will need a copy of the bootloader ROM. This is not part of any 323 | official firmware release, because the official images do not touch the 324 | bootloader. This is to ensure there is no way to brick the device from a failed 325 | flash, as a working bootloader means the unit can always be recovered without 326 | opening it. 327 | 328 | Other options are available if you have the bootloader data but cannot get an 329 | EEPROM programmer. Programs like [Flashrom](https://www.flashrom.org/Flashrom) 330 | support many unconventional devices, and you can always hot swap the flash chip 331 | with another DEQ2496 if you can get access to a second one. This is done by 332 | putting the good flash chip gently into the socket of the failed unit, powering 333 | it up into the bootloader, then carefully with the power on, removing the good 334 | chip and putting the original failed chip back in again. Then the bootloader 335 | can be reflashed, returning the device to working order. 336 | 337 | ## Examples 338 | 339 | ### Changing the boot logo 340 | 341 | This relatively harmless change only reflashes the blocks used for the boot 342 | splash screen. If corrupt data is flashed it will not cause problems, just 343 | display a corrupted splash screen during boot. 344 | 345 | First, extract the existing boot logo from the firmware image. You will need a 346 | full dump of the flash chip for this, as the boot logo is not included in any 347 | of the official firmware images. This is also a good idea anyway, as having a 348 | full firmware dump (and an EEPROM programmer to write it) means you can unbrick 349 | the device if any mistakes are made during the reflashing procedure. 350 | 351 | behringerctl firmware examine \ 352 | --model DEQ2496v2 \ 353 | --read firmware.deq2496.v2.5.rawdump.bin \ 354 | --extract-index 7 \ 355 | --write bootlogo.png 356 | 357 | Modify `bootlogo.png` with an editor like GIMP, ensuring it remains in the same 358 | subformat (colour depth). 359 | 360 | Next, convert the modified image into a firmware package. Here we are writing 361 | the logo to the address shown by `firmware examine`, so that we won't be 362 | touching any other part of the firmware. This does not flash anything to the 363 | device yet, it just produces a `.syx` file containing the instructions for 364 | updating the flash chip. 365 | 366 | behringerctl firmware generate \ 367 | --model DEQ2496v2 \ 368 | --read bootlogo.png \ 369 | --address 0x7E000 \ 370 | --messages "0=CHANGING BOOT LOGO, 8=ALMOST THERE..., 16=NEW BOOT LOGO FLASHED" \ 371 | --write pngboot.syx 372 | 373 | Next to be safe we should dump this new firmware image to ensure that it looks 374 | as expected. 375 | 376 | behringerctl firmware examine \ 377 | --model DEQ2496v2 \ 378 | --read pngboot.syx \ 379 | --extract-index 1 \ 380 | --write pngboot-extracted.png 381 | 382 | Ensure the address matches the address the original boot logo was extracted 383 | from, and that the resulting `pngboot-extracted.png` contains the expected new 384 | picture to be flashed. 385 | 386 | If everything looks good, the file can be immediately flashed with your 387 | preferred SysEx tool as per the official flashing procedure, or you can continue 388 | to use the CLI: 389 | 390 | behringerctl devices sendsyx --read pngboot.syx 391 | 392 | There is no need to indicate the model number as this is encoded into the `.syx` 393 | file. 394 | --------------------------------------------------------------------------------