buttplugio / docs.buttplug.io

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Document MuSe/Love Spouse Protocol

denialtek opened this issue · comments

commented

The toy is controlled by creating a BLE advertiser and setting specific manufacturer data.

Properties: Scannable, Connectable, Legacy
Company ID: 0xFFF0 (though other IDs seem to also work)
Manufacturer Data: 11 bytes in the format of 0x6DB643CE97FE427Cxxxxxx

Valid values for the last 3 bytes:
0xE5157D - stop all channels
0xE49C6C - set all channels to speed 1
0xE7075E - set all channels to speed 2
0xE68E4F - set all channels to speed 3

Only for use by toys with 2 channels:
0xD5964C- stop 1st channel
0xD41F5D - set 1st channel to speed 1
0xD7846F - set 1st channel to speed 2
0xD60D7E - set 1st channel to speed 3

0xA5113F - stop 2nd channel
0xA4982E - set 2nd channel to speed 1
0xA7031C - set 2nd channel to speed 2
0xA68A0D - set 2nd channel to speed 3

Some toys with both stroke and vibrate functionality have the vibrate on channel 1, and some have it on channel 2. There doesn't appear to be a way of knowing which functionality is on which channel.

The commands listed above correlate with the first 3 "modes" in the app, which are low/medium/high and are the only non-pattern modes.

Some commands trigger the toy to change patterns when the advertisement stops (ex. 0xE0B82A triggers a fast pulse pattern, but then a slower pulse pattern once you stop sending that data). To prevent the toy from thinking the advertisement data has stopped the advertisement interval needs to be at least 250ms.

Taking the above documentation, here's some ESP32 code that implements the above and is able to control these toys:

#include <Arduino.h>
#include <NimBLEDevice.h>

static uint16_t companyId = 0xFFF0;

#define MANUFACTURER_DATA_PREFIX 0x6D, 0xB6, 0x43, 0xCE, 0x97, 0xFE, 0x42, 0x7C

uint8_t manufacturerDataList[][11] = {
    // Stop all channels
    {MANUFACTURER_DATA_PREFIX, 0xE5, 0x15, 0x7D},
    // Set all channels to speed 1
    {MANUFACTURER_DATA_PREFIX, 0xE4, 0x9C, 0x6C},
    // Set all channels to speed 2
    {MANUFACTURER_DATA_PREFIX, 0xE7, 0x07, 0x5E},
    // Set all channels to speed 3
    {MANUFACTURER_DATA_PREFIX, 0xE6, 0x8E, 0x4F},
    // Stop 1st channel (only for toys with 2 channels)
    {MANUFACTURER_DATA_PREFIX, 0xD5, 0x96, 0x4C},
    // Set 1st channel to speed 1 (only for toys with 2 channels)
    {MANUFACTURER_DATA_PREFIX, 0xD4, 0x1F, 0x5D},
    // Set 1st channel to speed 2 (only for toys with 2 channels)
    {MANUFACTURER_DATA_PREFIX, 0xD7, 0x84, 0x6F},
    // Set 1st channel to speed 3 (only for toys with 2 channels)
    {MANUFACTURER_DATA_PREFIX, 0xD6, 0x0D, 0x7E},
    // Stop 2nd channel (only for toys with 2 channels)
    {MANUFACTURER_DATA_PREFIX, 0xA5, 0x11, 0x3F},
    // Set 2nd channel to speed 1 (only for toys with 2 channels)
    {MANUFACTURER_DATA_PREFIX, 0xA4, 0x98, 0x2E},
    // Set 2nd channel to speed 2 (only for toys with 2 channels)
    {MANUFACTURER_DATA_PREFIX, 0xA7, 0x03, 0x1C},
    // Set 2nd channel to speed 3 (only for toys with 2 channels)
    {MANUFACTURER_DATA_PREFIX, 0xA6, 0x8A, 0x0D},
};

const char *deviceName = "MuSE_Advertiser";

void setup() {
  Serial.begin(115200);
  Serial.println("Starting BLE...");
  NimBLEDevice::init(deviceName);
}

void advertiseManufacturerData(uint8_t index) {
  NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();

  pAdvertising->stop();

  uint8_t *manufacturerData = manufacturerDataList[index];

  Serial.print("Advertising index: ");
  Serial.print(index);
  Serial.print(", data: ");
  for (int i = 0; i < 11; i++) {
    Serial.print(manufacturerDataList[index][i], HEX);
    if (i < 10) {
      Serial.print(", ");
    }
  }
  Serial.println();
  Serial.flush(); // Flush to ensure data is sent before delay

  pAdvertising->setManufacturerData(std::string((char *)&companyId, 2) + std::string((char *)manufacturerData, 11));

  // Set properties: scannable, connectable, and use legacy advertising
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x12);
  pAdvertising->setMinPreferred(0x02);

  // Start advertising
  pAdvertising->start();
}

void loop() {
  for (uint8_t i = 0; i < sizeof(manufacturerDataList) / sizeof(manufacturerDataList[0]); i++) {

    // set advertisement for 2 seconds
    for (uint8_t j = 0; j < 10; j++) {
      advertiseManufacturerData(i);
      delay(200);
    }
    // set stop devices for 1 second
    for (uint8_t k = 0; k < 5; k++) {
      advertiseManufacturerData(0);
      delay(200);
    }
  }
}

I have done some more sniffing, thanks to this info, and I was able to capture the values for both the Classic Mode as well as Independent Mode (Mode 1) (which I believe is in reference to motor 1). Modes 1, 2 and 3 of classic mode correspond with the previously-mentioned values. I do not have any dual-motor devices to test, but can capture the second motor advertisement values from the app if they prove useful to the development of xtoys.app. I think it may be viable to create an ESP32 gateway firmware that could replicate a buttplug.io compatible protocol over wifi or serial, and extend functionality.

Here are the values I've captured:

// Classic Mode
uint8_t manufacturerDataList[][11] = {
    // 0 Stop all channels 
    {MANUFACTURER_DATA_PREFIX, 0xE5, 0x15, 0x7D},
    // 1 Set all channels to speed 1 (Mode 1)
    {MANUFACTURER_DATA_PREFIX, 0xE4, 0x9C, 0x6C},
    // 2 Set all channels to speed 2 (Mode 2)
    {MANUFACTURER_DATA_PREFIX, 0xE7, 0x07, 0x5E},
    // 3 Set all channels to speed 3 (Mode 3)
    {MANUFACTURER_DATA_PREFIX, 0xE6, 0x8E, 0x4F},
    // 4 (Mode 4)
    {MANUFACTURER_DATA_PREFIX, 0xE1, 0x31, 0x3B},
    // 5 (Mode 5)
    {MANUFACTURER_DATA_PREFIX, 0xE0, 0xB8, 0x2A},
    // 6 (Mode 6)
    {MANUFACTURER_DATA_PREFIX, 0xE3, 0x23, 0x18},
    // 7 (Mode 7)
    {MANUFACTURER_DATA_PREFIX, 0xE2, 0xAA, 0x09},
    // 8 (Mode 8)
    {MANUFACTURER_DATA_PREFIX, 0xED, 0x5D, 0xF1},
    // 9 (Mode 9)
    {MANUFACTURER_DATA_PREFIX, 0xEC, 0xD4, 0xE0}
};
// Independent mode, vibe 1
uint8_t manufacturerDataList[][11] = {
    // 0 Stop all channels 
    {MANUFACTURER_DATA_PREFIX, 0xE5, 0x15, 0x7D},
    // 1 (Mode 1)
    {MANUFACTURER_DATA_PREFIX, 0xD4, 0x1F, 0x5D},
    // 2 (Mode 2)
    {MANUFACTURER_DATA_PREFIX, 0xD7, 0x84, 0x6F},
    // 3 (Mode 3)
    {MANUFACTURER_DATA_PREFIX, 0xD6, 0x0D, 0x7E},
    // 4 (Mode 4)
    {MANUFACTURER_DATA_PREFIX, 0xD1, 0xB2, 0x0A},
    // 5 (Mode 5)
    {MANUFACTURER_DATA_PREFIX, 0xD0, 0x3B, 0x1B},
    // 6 (Mode 6)
    {MANUFACTURER_DATA_PREFIX, 0xD3, 0xA0, 0x29},
    // 7 (Mode 7)
    {MANUFACTURER_DATA_PREFIX, 0xD2, 0x29, 0x38},
    // 8 (Mode 8)
    {MANUFACTURER_DATA_PREFIX, 0xDD, 0xDE, 0xC0},
    // 9 (Mode 9)
    {MANUFACTURER_DATA_PREFIX, 0xDC, 0x57, 0xD1}
};
commented

@jptrsn I just made a project with that idea. https://github.com/Paxy/xtoys_LS_GW/

create an ESP32 gateway firmware

I did just that: https://github.com/IngeniousKink/LVS-Gateway:

ESP32 firmware that poses as a Lovense toy and broadcasts the manufacturer data to MuSE/Love Spouse devices.

commented

Hi, I'm researching the MuSe protocol in app Leten. And "decrypted" it.
In the process of research, I managed to download the entire database and there are more than 1000 toys in it.
And also i made an application to send packages
https://github.com/arz321/MuSe-Protocol

For toy 8131 (a buttplug with an LED), the LED is controlled via the channel 2 commands, and each of the commands maps to a color:

0xA5113F - Off (it doesn't turn fully off, instead it's a dim blinking blue light)
0xA4982E - Sky Blue
0xA7031C - Deep Blue
0xA68A0D - Dark Green
0xA13579 - Light Purple
0xA0BC68 - Light Blue
0xA3275A - Orange
0xA2AE4B - Red
0xAD59B3 - Rose Red
0xACD0A2 - Light Green

Hi! Did someone try to make an iOS app for such devices?

commented

Hi! Did someone try to make an iOS app for such devices?

Not possible. iOS does not expose ble peripheral capabilities.

I was able to decode the MuSe protocol using Ghidra and AI. And later I found out that this is the Fastcon BLE implementation from BroadLink.
Example of use, we substitute the numbers from the barcode into the link
https://lovespouse.zlmicro.com/index.php?g=App&m=Diyapp&a=getproductdetail&barcode=8131&userid=-1
We take, from json, BroadcastPrefix "77 62 4d 53 45" and the stop command "30"
And we get 0x6D, 0xB6, 0x43, 0xCE, 0x97, 0xFE, 0x42, 0x7C, 0xE5, 0x15, 0x7D
p.s. Later I will do it for esp32

//Java
public class Main {
    public static void main(String[] args) {
        byte[] broadcastPrefix = {0x77, 0x62, 0x4d, 0x53, 0x45};
        byte[] command = {0x30};

        int length = broadcastPrefix.length;
        int length2 = command.length;
        int length3 = broadcastPrefix.length + command.length + 0x05;

        byte[] data = new byte[length3];
    
        get_rf_payload(broadcastPrefix, length, command, length2, data);

        for (int i = 0; i < length3; i++) {
            System.out.print("0x");
            System.out.print(Integer.toHexString((int)data[i] & 0xff).toUpperCase());
            System.out.print(", ");
        } 
    }

    public static void get_rf_payload(byte[] bArr, int length, byte[] bArr2, int length2, byte[] bArr3) {

        byte[] ctx_25 = new byte[7];
        byte[] ctx_3F = new byte[7];

        whitening_init(0x25, ctx_25); //1100101
        whitening_init(0x3f, ctx_3F); //1111111

        int length_24 = 0x12 + length + length2;
        int length_26 = length_24 + 0x02;

        byte[] result_25 = new byte[length_26];
        byte[] result_3f = new byte[length_26];
        byte[] resultbuf = new byte[length_26];

        resultbuf[0x0f] = 0x71;//const buf[0x0f-0x11]
        resultbuf[0x10] = 0x0f;
        resultbuf[0x11] = 0x55;

        if (length > 0) {
            for (byte j = 0; j < length; j++) { //flip bArr[] and write to buf[0x12-0x16]
                resultbuf[0x12 + length - j - 0x01] = bArr[j];
            }
        }

        if (length2 > 0) {
            for (byte j = 0; j < length2; j++) { //flip bArr2[] and write to buf[0x17]
                resultbuf[length_24 - j - 0x01] = bArr2[j];
            }
        }

        for (byte i = 0; i < 0x03 + length; i++) { //invert_8 byte buf[0x0f-0x16]
            resultbuf[0x0f + i] = invert_8(resultbuf[0x0f + i]);
        }

        int crc16 = check_crc16(bArr, length, bArr2, length2); //write crc16 to buf[0x18-0x19]
        resultbuf[length_24] = (byte)crc16;
        resultbuf[length_24 + 1] = (byte)(crc16 >> 8);

        whitenging_encode(resultbuf, 0x2 + length + length2, ctx_3F, 0x12, result_3f);
        whitenging_encode(resultbuf, length_26, ctx_25, 0x00, result_25);

        for (byte i = 0; i < length_26; i++) { //XOR result_25[] and result_3f[]
            result_25[i] ^= result_3f[i];
        }

        System.arraycopy(result_25, 0x0f, bArr3, 0, 0x0b); //copy result_25[0x0f-0x19] to bArr3
    }

    public static void whitening_init(int val, byte[] ctx) {
        ctx[0] = 1;
        ctx[1] = (byte) ((val >> 5) & 1);
        ctx[2] = (byte) ((val >> 4) & 1);
        ctx[3] = (byte) ((val >> 3) & 1);
        ctx[4] = (byte) ((val >> 2) & 1);
        ctx[5] = (byte) ((val >> 1) & 1);
        ctx[6] = (byte) (val & 1);
    }

    public static int check_crc16(byte[] addr, int addrLength, byte[] data, int dataLength) {
        int crc = 0xffff;

        for (int i = addrLength - 1; i >= 0; i--) {
            crc ^= addr[i] << 8;
            for (int ii = 0; ii < 8; ii++) {
                if ((crc & 0x8000) != 0) {
                    crc = (crc << 1) ^ 0x1021;
                } else {
                    crc <<= 1;
                }
            }
        }

        for (int i = 0; i < dataLength; i++) {
            crc ^= invert_8(data[i]) << 8;
            for (int ii = 0; ii < 8; ii++) {
                if ((crc & 0x8000) != 0) {
                    crc = (crc << 1) ^ 0x1021;
                } else {
                    crc <<= 1;
                }
            }
        }
        crc = ~invert_16(crc) & 0xffff;
        return crc;
    }

    public static byte invert_8(byte value) {
        byte result = 0;
        for (byte i = 0; i < 8; i++) {
            result <<= 1;
            result |= (value & 1);
            value >>= 1;
        }
        return result;
    }

    public static int invert_16(int value) {
        int result = 0;
        for (int i = 0; i < 16; i++) {
            result <<= 1;
            result |= (value & 1);
            value >>= 1;
        }
        return result;
    }

    public static void whitenging_encode(byte[] data, int len, byte[] ctx, int offset, byte[] result) {
        System.arraycopy(data, 0, result, 0, len);
        for (int i = 0; i < len; i++) {
            int var6 = ctx[6];
            int var5 = ctx[5];
            int var4 = ctx[4];
            int var3 = ctx[3];
            int var52 = var5 ^ ctx[2];
            int var41 = var4 ^ ctx[1];
            int var63 = var6 ^ ctx[3];
            int var630 = var63 ^ ctx[0];

            ctx[0] = (byte)(var52 ^ var6);
            ctx[1] = (byte)var630;
            ctx[2] = (byte)var41;
            ctx[3] = (byte)var52;
            ctx[4] = (byte)(var52 ^ var3);
            ctx[5] = (byte)(var630 ^ var4);
            ctx[6] = (byte)(var41 ^ var5);

            int c = result[i + offset];
            result[i + offset] = (byte)(((c & 0x80) ^ ((var52 ^ var6) << 7)) +
                                        ((c & 0x40) ^ (var630 << 6)) +
                                        ((c & 0x20) ^ (var41 << 5)) +
                                        ((c & 0x10) ^ (var52 << 4)) +
                                        ((c & 0x08) ^ (var63 << 3)) +
                                        ((c & 0x04) ^ (var4 << 2)) +
                                        ((c & 0x02) ^ (var5 << 1)) +
                                        ((c & 0x01) ^ (var6)));
        }
    }
}

@denialtek I was able to control the toy from Windows with a little cheating. In a bluetooth packet a flag is transmitted that is 3 bytes long. in Windows it is not possible to transmit flags, but it seems that it is enough to compensate for the length of the packet by 3 bytes.
origin windows
image