├── simagic-poc ├── hardware.jpg ├── readme.md ├── simagic-poc.ino ├── initpacket.h └── simagic.h └── README.md /simagic-poc/hardware.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kegetys/simagic-arduino-poc/HEAD/simagic-poc/hardware.jpg -------------------------------------------------------------------------------- /simagic-poc/readme.md: -------------------------------------------------------------------------------- 1 | Proof-of-concept example how to send button presses to Simagic Alpha wheelbases from a Wemos D1 microcontroller with nRF24 wireless module. 2 | 3 | Should be openable with Arduino IDE 1.x. Needs RF24 library to compile. 4 | 5 | If you are using a clone chip you most likely need to uncomment SIMAGIC_NO_ACKS define from simagic.h, or try the [ACK workaround here](..) 6 | 7 | More information in the [root](..) 8 | 9 | ![](hardware.jpg) 10 | -------------------------------------------------------------------------------- /simagic-poc/simagic-poc.ino: -------------------------------------------------------------------------------- 1 | // Proof of concept example for sending button presses to Simagic wheelbases 2 | // by Keijo 'Kegetys' Ruotsalainen, http://www.kegetys.fi 3 | 4 | // Pinout for wiring to Wemos D1 Mini (hardware SPI + CE & CS): 5 | // Wemos pin - nRF24 pin 6 | // 3.3V - VCC 7 | // GND - GND 8 | // D0 - CE 9 | // D5 - SCLK 10 | // D6 - MISO 11 | // D7 - MOSI 12 | // D8 - CS 13 | 14 | // Requires RF24 library 15 | 16 | #include "simagic.h" 17 | 18 | #define PIN_SIMAGIC_CE D0 19 | #define PIN_SIMAGIC_CS D8 20 | #define SIMAGIC_CHANNEL 60 // channel configured in SimPro manager 21 | 22 | // SPI used so CSN, MOSI, MISO and SCK must be wired to HW pins (IRQ not needed) 23 | simagic base(PIN_SIMAGIC_CE, PIN_SIMAGIC_CS, SIMAGIC_CHANNEL); 24 | 25 | //#include "initpacket.h" 26 | 27 | void setup() 28 | { 29 | Serial.begin(115200); 30 | delay(100); 31 | base.begin(); 32 | 33 | pinMode(LED_BUILTIN, INPUT_PULLUP); 34 | Serial.println("simagic-poc by Kegetys"); 35 | 36 | // uncomment this and the include above to send the rim identification packet 37 | // you should see the rim appear in SimPro manager then. 38 | // The buttons and axes however work even without doing this, you just won't see a rim in the manager. 39 | //sendRimInit(); 40 | } 41 | 42 | void loop() 43 | { 44 | // invent some button presses 45 | const int buttons = 8; 46 | const bool state[buttons] = 47 | { 48 | (millis() % 1000) < 500, 49 | (millis() % 5000) < 2500, 50 | (millis() % 7000) < 2000, 51 | (millis() % 100) < 50, 52 | (millis() % 2600) < 1000, 53 | (millis() % 2600) < 1500, 54 | (millis() % 2600) < 2000, 55 | (millis() % 2600) < 2500, 56 | }; 57 | 58 | // axis data 59 | const char axis0 = (char) (millis() / 50L) % 127; 60 | const char axis1 = (char) (millis() / 200L) % 127; 61 | 62 | // convert buttons to bits 63 | unsigned long bits = 0x0; 64 | for (int i = 0; i < buttons; i++) 65 | if (state[i]) 66 | bits |= 1 << i; 67 | 68 | // send state 69 | base.setButtonBits(bits); 70 | base.setAxis(0, axis0); 71 | base.setAxis(1, axis1); 72 | base.tick(); 73 | 74 | // wait a bit and blink led 75 | delayMicroseconds(100); 76 | digitalWrite(LED_BUILTIN, (millis() % 1000) < 250 ? LOW : HIGH); 77 | } 78 | -------------------------------------------------------------------------------- /simagic-poc/initpacket.h: -------------------------------------------------------------------------------- 1 | // GTS rim initialization packets 2 | // add to end of setup() to try 3 | void sendRimInit() 4 | { 5 | // initialization ritual 6 | base.sendRaw(0x00000000, 0x3C0000); 7 | 8 | // set rim type 9 | // 0x05 = GTS rim 10 | // 0x04 = gt hub 11 | // 0x03 = fx pro rim 12 | // 0x02 = fx rim 13 | // 0x01 = doesn't work right, likely requires some different init? 14 | base.sendRaw(0x00003201, 0xBC8005); // last 0x05 = type 15 | base.sendRaw(0x00003201, 0xBC8005); // last 0x05 = type 16 | base.sendRaw(0x00003201, 0xBC8005); // last 0x05 = type 17 | 18 | // some kind of per-button data perhaps? 19 | base.sendRaw(0x03020100, 0xBC0104); 20 | base.sendRaw(0x03020100, 0xBC0104); 21 | base.sendRaw(0x03020100, 0xBC0104); 22 | base.sendRaw(0x03020100, 0xBC0104); 23 | base.sendRaw(0x03020100, 0xBC0104); 24 | base.sendRaw(0x03020100, 0xBC0104); 25 | base.sendRaw(0x08070605, 0xBC0209); 26 | base.sendRaw(0x0D0C0B0A, 0xBC030E); 27 | base.sendRaw(0x1211100F, 0xBC0413); 28 | base.sendRaw(0x17161514, 0xBC0518); 29 | base.sendRaw(0x1C1B1A19, 0xBC061D); 30 | base.sendRaw(0x21201F1E, 0xBC0722); 31 | base.sendRaw(0x26252423, 0xBC0827); 32 | base.sendRaw(0x4E4B4644, 0xBC0953); 33 | base.sendRaw(0x5D5A5855, 0xBC0A64); 34 | base.sendRaw(0x60909095, 0xBC0B93); 35 | base.sendRaw(0x60336090, 0xBC0C30); 36 | base.sendRaw(0x40206570, 0xBC0D50); 37 | base.sendRaw(0x52526070, 0xBC0E52); 38 | base.sendRaw(0x10313131, 0xBC0F10); 39 | base.sendRaw(0x10101010, 0xBC1010); 40 | base.sendRaw(0x40405858, 0xBC1140); 41 | base.sendRaw(0x40404040, 0xBC1290); 42 | base.sendRaw(0x40405858, 0xBC1340); 43 | base.sendRaw(0x40404040, 0xBC1490); 44 | base.sendRaw(0x40405858, 0xBC1540); 45 | base.sendRaw(0x90404040, 0xBC1690); 46 | base.sendRaw(0x40405858, 0xBC1740); 47 | base.sendRaw(0x90404040, 0xBC1890); 48 | base.sendRaw(0x40405858, 0xBC1940); 49 | base.sendRaw(0x90904040, 0xBC1A90); 50 | base.sendRaw(0x30305858, 0xBC1B30); 51 | base.sendRaw(0x90903030, 0xBC1C90); 52 | base.sendRaw(0x30305858, 0xBC1D30); 53 | base.sendRaw(0x90903030, 0xBC1E90); 54 | base.sendRaw(0x30305858, 0xBC1F30); 55 | base.sendRaw(0x90909030, 0xBC2090); 56 | base.sendRaw(0x10105858, 0xBC2110); 57 | base.sendRaw(0x90909010, 0xBC2290); 58 | base.sendRaw(0x00000000, 0xBC2300); 59 | base.sendRaw(0x00000000, 0xBC2400); 60 | base.sendRaw(0x00000000, 0xBC2500); 61 | base.sendRaw(0x00000000, 0xBC2600); 62 | base.sendRaw(0x00000000, 0xBC2700); 63 | base.sendRaw(0x00000000, 0xBC2800); 64 | base.sendRaw(0x00000000, 0xBC2900); 65 | base.sendRaw(0x00000000, 0xBC2A00); 66 | base.sendRaw(0x00000000, 0xBC2B00); 67 | base.sendRaw(0x00000000, 0xBC2C00); 68 | base.sendRaw(0x00000000, 0xBC2D00); 69 | base.sendRaw(0x00000000, 0xBC2E00); 70 | 71 | base.sendRaw(0x01320120, 0xBC2F30); // 0x32 = firmware version 72 | } 73 | -------------------------------------------------------------------------------- /simagic-poc/simagic.h: -------------------------------------------------------------------------------- 1 | // talk to Simagic wheelbase wirelessly using nRF24L01 2 | // by Keijo 'Kegetys' Ruotsalainen, www.kegetys.fi 3 | 4 | // requres RF24 library 5 | 6 | // uncomment this if you are usin an RF24 clone to disable ACKs and instead send multiple messages for redundancy 7 | //#define SIMAGIC_NO_ACKS 8 | 9 | //#define DUMP_SENT 10 | //#define DUMP_RECEIVED 11 | 12 | #include 13 | #include 14 | #include 15 | 16 | #define SIMAGIC_ADDRESS 0x0110104334LL // network ID used by simagic 17 | #define SIMAGIC_REPEAT 4 // how many times non-keepalive messages are repeated. hack for non-ack transmission 18 | 19 | uint8_t crc8(const uint8_t *addr, uint8_t len); 20 | 21 | class simagic 22 | { 23 | public: 24 | // init nRF24 chip, need CE and CS pins as parameters. 25 | // SPI used so CSN, MOSI, MISO and SCK must be wired to HW pins (IRQ not needed) 26 | // chan parameter is the channel number the base is set to (check SimPro manager) 27 | simagic(byte pinCe, byte pinCs, byte chan) : _pinCe(pinCe), _pinCs(pinCs), _chan(chan) {}; 28 | 29 | void begin() 30 | { 31 | _radio.begin(_pinCe, _pinCs); 32 | _radio.setPALevel(RF24_PA_MAX); // consider lowering this, maximum can affect WiFi nearby 33 | _radio.setDataRate(RF24_250KBPS); 34 | _radio.setCRCLength(RF24_CRC_8); 35 | _radio.setPayloadSize(sizeof(packetTx)); // redundant? 36 | _radio.setChannel(_chan); 37 | #ifdef SIMAGIC_NO_ACKS 38 | _radio.setAutoAck(0, false); 39 | #else 40 | _radio.setAutoAck(0, true); 41 | _radio.setRetries(5, 5); 42 | #endif 43 | _radio.setRetries(0, 0); 44 | _radio.enableAckPayload(); 45 | _radio.enableDynamicPayloads(); 46 | _radio.openReadingPipe(0, SIMAGIC_ADDRESS); 47 | _radio.openWritingPipe(SIMAGIC_ADDRESS); 48 | 49 | // allow the radio to settle 50 | delay(50); 51 | _radio.printDetails(); 52 | 53 | if (!_radio.isChipConnected()) 54 | Serial.println("No connection!"); 55 | else 56 | Serial.println("nRF24 connected"); 57 | 58 | _lastKeepalive = 0; 59 | _updateNeeded = true; 60 | memset(_axes, 0, numAxes); 61 | } 62 | 63 | // sends button states to the base 64 | // must be called periodically or the rim is considered disconnected from the base 65 | void tick() 66 | { 67 | if (!_updateNeeded) 68 | { 69 | // no update, send keepalive if needed 70 | if (millis() - _lastKeepalive > keepaliveRate) 71 | { 72 | // send button state as keepalive 73 | // seems type 0x00 might be some kind of dedicated keepalive message? 74 | sendType(0x3C, _buttons, _axes[0], 0x00); 75 | _lastKeepalive = millis(); 76 | } 77 | } 78 | else 79 | { 80 | // button or axis state has changed, send new state 81 | sendType(0x3C, _buttons, _axes[0], 0x00, SIMAGIC_REPEAT); 82 | // send second axis with bit set, contains same buttons data 83 | sendType(0x3C, _buttons, _axes[1] | 0x80, 0x00, SIMAGIC_REPEAT); 84 | 85 | _updateNeeded = false; 86 | _lastKeepalive = millis(); 87 | } 88 | } 89 | 90 | // set button state as bits 91 | // TODO: probably can have more than 32 buttons too 92 | void setButtonBits(unsigned long buttons) 93 | { 94 | if (buttons == _buttons) 95 | return; 96 | _buttons = buttons; 97 | _updateNeeded = true; 98 | } 99 | 100 | // set axis state, value range is 0 - 127 101 | void setAxis(int axis, char value) 102 | { 103 | if (_axes[axis] == value) 104 | return; 105 | _axes[axis] = value; 106 | _updateNeeded = true; 107 | } 108 | 109 | // send specific type of message and its data bytes 110 | void sendType(byte command, unsigned long D1D2D3D4, byte D5, byte D6, int repeat = 1) 111 | { 112 | // TODO: D6 might be some kind of command subtype? 113 | sendRaw(D1D2D3D4, command << 16 | D5 << 8 | D6); 114 | } 115 | 116 | // send raw data. last byte of b is overriden with CRC 117 | void sendRaw(unsigned long a, unsigned long b, int repeat = 1) 118 | { 119 | packetTx m; 120 | m.a = a; 121 | m.b = b; 122 | 123 | // calculate crc, ignoring last byte 124 | const uint8_t crc = crc8((const uint8_t*) &m, sizeof(packetTx) - 1); 125 | // replace last byte with crc 126 | m.b = (m.b & 0x00FFFFFF) | ((unsigned long) crc << 24); 127 | 128 | #ifdef SIMAGIC_NO_ACKS 129 | // if no ACKs then repeat important messages multiple times 130 | for (int i = 0; i < repeat; i++) 131 | _radio.write(&m, sizeof(packetTx)); 132 | #else 133 | // real nRF24 chip can receive ACKs 134 | const bool ok = _radio.write(&m, sizeof(packetTx)); 135 | if (!ok) 136 | Serial.println("transmit failed!"); 137 | else 138 | { 139 | // read ACK payload 140 | if (_radio.available()) 141 | { 142 | const int packetLen = _radio.getPayloadSize(); 143 | uint8_t buf[8]; 144 | _radio.read(buf, min(8, packetLen)); 145 | #ifdef DUMP_RECEIVED 146 | for (int i = 0; i < packetLen; i++) 147 | printf_P(PSTR("%02x"), buf[i]); 148 | printf_P(PSTR(" RECV\n")); 149 | #endif 150 | } 151 | } 152 | #endif // SIMAGIC_NO_ACKS 153 | 154 | #ifdef DUMP_SENT 155 | for (int i = 0; i < 8; i++) 156 | printf_P(PSTR("%02x"), ((const uint8_t*)&m)[i]); 157 | printf_P(PSTR(" SENT\n")); 158 | #endif 159 | } 160 | 161 | private: 162 | const static int keepaliveRate = 2; // ms between keepalive packets. the GTS rim seems to send packets at 500Hz 163 | const static int numAxes = 2; 164 | 165 | byte _pinCe, _pinCs, _chan; 166 | RF24 _radio; 167 | 168 | unsigned long _buttons; // current button state 169 | char _axes[numAxes]; // axis data (7 bits) 170 | bool _updateNeeded; // if button/axis state changed and update is needed in tick() 171 | unsigned long _lastKeepalive; // last millis() keepalive was sent 172 | 173 | // buffer used to talk to the base 174 | struct packetTx 175 | { 176 | unsigned long a; 177 | unsigned long b; 178 | }; 179 | }; 180 | 181 | // CRC-8/MAXIM stolen from OneWire library by Jim Studt 182 | static const uint8_t PROGMEM simagic_dscrc2x16_table[] = { 183 | 0x00, 0x5E, 0xBC, 0xE2, 0x61, 0x3F, 0xDD, 0x83, 184 | 0xC2, 0x9C, 0x7E, 0x20, 0xA3, 0xFD, 0x1F, 0x41, 185 | 0x00, 0x9D, 0x23, 0xBE, 0x46, 0xDB, 0x65, 0xF8, 186 | 0x8C, 0x11, 0xAF, 0x32, 0xCA, 0x57, 0xE9, 0x74 187 | }; 188 | 189 | uint8_t crc8(const uint8_t *addr, uint8_t len) 190 | { 191 | uint8_t crc = 0; 192 | while (len--) { 193 | crc = *addr++ ^ crc; // just re-using crc as intermediate 194 | crc = pgm_read_byte(simagic_dscrc2x16_table + (crc & 0x0f)) ^ 195 | pgm_read_byte(simagic_dscrc2x16_table + 16 + ((crc >> 4) & 0x0f)); 196 | } 197 | return crc; 198 | } 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | ![](https://junk.kegetys.fi/simagic_banner2.jpg) 4 | 5 | This is a description of the wireless protocol used by Simagic direct drive wheel bases and rims. With this you can make your own wireless rims that send button presses to the Simagic base, receive data from a Simagic rim or maybe make a separate button box that activates the base buttons (for wheels without any buttons on them). 6 | 7 | I haven't figured out the entire protocol. For example the data sent back by the base to adjust the rim LEDs is still unknown. 8 | 9 | # Hardware 10 | 11 | The rim and base contain AS01-Ml01S V4.0 wireless modules. These modules use nRF24L01 chips which are commonly used in wireless keyboards, mice and many other wireless devices. These are easy to use with Arduino IDE using the RF24 library: https://www.arduino.cc/reference/en/libraries/rf24/ 12 | 13 | The Simagic base quick release provides a 5V voltage to the rim, which is perfect for Arduino and ESP32 based microcontroller dev boards. The power is received with 5pin 2.45mm pitch pogo pins on the rim with contacts on the base. Despite having 5 pins the connector only has 5V and GND with the rest of the extra pins probably for physical redundancy. 14 | 15 | For powering and attaching your custom rim you can find a complete Simagic compatible QR with the power connectors included from Aliexpress: https://www.aliexpress.com/item/1005005142013070.html 16 | 17 | Interestingly that QR also comes with the base side connector, so you could use it also to attach a simagic rim to another base and power it, then receive button data from the rim and adapt it to the other base. 18 | 19 | There is also a custom PCB you can create in printables.com that should work in a NRG QR: https://www.printables.com/model/489715-simagic-qr-quickrelease-wheelside-pcb 20 | 21 | In the bases the wireless module is located next to the green LED above the wheel axis. You can check out Barry's disassembly of the Alpha Mini base for more details about the HW setup: https://www.youtube.com/watch?v=wPe628qP5n8 22 | 23 | # nRF24L01 setup 24 | 25 | This is the most interesting part, since in order to communicate with either the rim or the base the nRF24 needs to know the network ID, channel, speed and CRC setup used by Simagic. I attached a logic analyzer to the wireless module in my rim in order to intercept the module setup and was able to capture both the module setup process and communication with the base. 26 | 27 | The settings used are as follows: 28 | - The network ID is 0x0110104334. 29 | - The channel can be configured from SimPro Manager, it seems to default to 60 and it matches the RF24 hardware channel. 30 | - Speed is 250KBps, CRC is 8 bits. 31 | - Dynamic payloads appear to be enabled, but the payload from rim to wheel is always 8 bytes. 32 | - AutoAck is enabled with 7 retires, delay setting 5. 33 | - ACK payloads are enabled and used by the base to send data back to the rim. 34 | 35 | ### Note about nRF24L01 clones 36 | 37 | The chips in the base & rim appear to be genuine Nordic Semiconductor parts while most nRF24L01 chips you find ie. from Aliexpress are clones, including my Si24R1 modules. These clone modules have a hardware bug where they have the message NO_ACK bit inverted from the genuine ones and this means they are not directly capable of communicating the ACK packets with the genuine chips. If you just want to send button and axis data from your rim to the wheel this is not a big problem, human input is so low rate that you can just send the packets multiple times and the base will amost certainly receive at least one message due to the very close proximity. I haven't noticed any missed input with this approach. 38 | 39 | But I also did find a possible workaround for this issue that works at least with the Si24R1. It involves first enabling the dynamic ACKs, writing the message to the chip buffer but with the NO_ACK bit asking to be set, then disabling dynamic ACKs again before actually sending the buffer out: 40 | 41 | ``` 42 | radio.enableDynamicAck(); // enable dynamic ACKs so se can request the NO_ACK bit 43 | radio.startFastWrite(&m, sizeof(packetTx), true); // ask for NO_ACK, but it actually ends up not set since the logic is inverted 44 | radio.disableDynamicAck(); // disable dynamic ACKs so our chip ignores the feature completely 45 | const bool ok = radio.txStandBy(); // send the buffer, returns true if ACK was received 46 | if (radio.available()) 47 | { 48 | // read ACK payload 49 | } 50 | ``` 51 | 52 | What I believe happens with this is that the NO_ACK bit gets not set in the FIFO buffer since its use is inverted from the spec, so the buffer is sent to the genuine module on the other end with the flag disabled. The genuine module then responds with ACK, but since we have disabled dynamic acks before actually sending the buffer out our chip correctly receives and processes the ACK packet. 53 | 54 | # Rim to base packet structure 55 | 56 | The rim always appears to send 8 byte packets. Interestingly despite the wireless layer having a CRC checksum enabled the last byte of the packet contains another redundant 1 byte CRC-8/MAXIM checksum of the first 7 bytes. If this is not correct the base ignores the message. 57 | 58 | The structure appears to be as follows: 59 | ``` 60 | D1 D2 D3 D4 D5 D6 CO CC 61 | || ^^ CRC checksum 62 | ^^ Command 63 | ^^ D1-D6 are dynamic data depending on the Command sent and CRC being the CRC-8/MAXIM checksum. 64 | ``` 65 | 66 | ## Button + axis packet 67 | 68 | Button and axis data are sent with command 0x3c. 69 | 70 | D1 to D4 are bit flags for buttons currently held down 71 | 72 | D6 is used for clutch paddles axis values with first bit signifiying axis to set and the rest the axis value (0 - 127). 73 | 74 | ## Rim connect packets 75 | 76 | When the rim is connected it identifies itself with the base and the rim apperas in SimPro Manager. However even without this being sent the button packet above works, so you don't actually need to send this for custom rims to work fine. 77 | 78 | The data contains at least the rim type, firmware version and what looks like some kind of per-button information (maybe the LED colors?). I haven't fully examined this data, a dump is included in [initpacket.h](simagic-poc/initpacket.h) 79 | 80 | # Unknowns 81 | - There is apparently some kind of automatic channel negotation in some rims? 82 | - Parsing the ACK reply packets (they seem to contain LED info) 83 | - The FX Pro rim display probably also uses the same communication channel 84 | - Various things in the rim initialization packet 85 | 86 | # Proof-of-concept Arduino project 87 | 88 | See [simagic-poc](simagic-poc) folder for an example proof-of-concept Arduino project for a rim that sends button presses to the base. The pinout is for a Wemos D1 Mini microcontroller but all you really need is an nRF24 module correctly wired to the board so any Arduino or ESP board should be easy to make work. 89 | 90 | The interesting bits are in the [simagic class](simagic-poc/simagic.h). 91 | 92 | My test hardware looks like this: 93 | 94 | ![](simagic-poc/hardware.jpg) 95 | 96 | 97 | --------------------------------------------------------------------------------