DEV Community

Cover image for Reverse Engineering Sphero R2D2 - I like to move it!
Andrea Stagi
Andrea Stagi

Posted on

Reverse Engineering Sphero R2D2 - I like to move it!

In the first part of Reverse Engineering Sphero R2D2 I made a deep look inside Sphero documentation and used Wireshark to catch all the BLE messages between the phone and the droid, replicating them using Node.js. At the end of the first part we were able to animate the droid and rotate the top, now it's time to make our droid move in any direction and play with the accelerometer!

The final result is in this video 📺 Check the final code in this repository

Watch the video

Moving Sphero R2D2 droid

R2D2 Movement

Using the Official Sphero App in "driving mode" you can find a big circle on the left with a small lighting blue point in its center.

R2D2 Sphero App

Moving the blue point inside the big circle allows you to move R2D2 around, at a certain speed. R2D2 is also able to move forward and backward. During BLE packets analysis I expect to find packets with these information:

  • The heading (from 0° to 360°)
  • Direction (forward or backward)
  • Speed

That's my scanning result after driving my droid around the room

...| 0x0A | 0x16 | 0x07 | 0xB0 | 0x00 | 0xB4 | 0x00 |...
...| 0x0A | 0x16 | 0x07 | 0xC2 | 0x00 | 0xB4 | 0x00 |...
...| 0x0A | 0x16 | 0x07 | 0xFF | 0x00 | 0xB4 | 0x00 |...

...

...| 0x0A | 0x16 | 0x07 | 0x32 | 0x01 | 0x0E | 0x01 |...
...| 0x0A | 0x16 | 0x07 | 0x6A | 0x01 | 0x0E | 0x01 |...
...| 0x0A | 0x16 | 0x07 | 0xA1 | 0x01 | 0x0E | 0x01 |...
Enter fullscreen mode Exit fullscreen mode

As you can see, the common part of these messages is 0x0A, 0x16, 0x07 so we can define the const value

const MSG_MOVE = [0x0A, 0x16, 0x07]
Enter fullscreen mode Exit fullscreen mode

The next byte contains a value between 0x00 and 0xFF, it must be the speed.

The following 2 bytes look to be the heading. I expect to find a value in degrees, so I try to convert these bytes using the IEEE-754 Floating Point Converter as we did in the previous article to move the top

0x00B4 => 2.52233723578e-43
Enter fullscreen mode Exit fullscreen mode

As you can see, this is not a valid value for the heading. Let's try to convert it to a decimal value

0x00B4 => 180
Enter fullscreen mode Exit fullscreen mode

Yay, 180 degrees! ✌🏻

As we can easily imagine, the last byte is the direction (0x00 => forward, 0x01 => backward).

Now before start trying to move our droid programmatically, we need a function to convert a degree value to hex. We can modify the existing convertDegreeToHex adding integer support.

const CONVERSIONS = {
  INTEGER: 'i',
  FLOAT: 'f',
};


let convertDegreeToHex = (degree, format = CONVERSIONS.INTEGER) => {
  var view = new DataView(new ArrayBuffer(4));
  format === CONVERSIONS.FLOAT ? view.setFloat32(0, degree) : view.setUint16(0, degree)
  return Array
    .apply(null, {
      length: format === CONVERSIONS.FLOAT ? 4 : 2
    })
    .map((_, i) => view.getUint8(i))
}
Enter fullscreen mode Exit fullscreen mode

Give it a try!

convertDegreeToHex(0)
// => [0x00, 0x00]
convertDegreeToHex(180)
// => [0x00, 0xB4]
convertDegreeToHex(270)
// => [0x01, 0x0E]
convertDegreeToHex(270, CONVERSIONS.FLOAT)
// => [0x43, 0x87, 0x00, 0x00]
Enter fullscreen mode Exit fullscreen mode

Using the writePacket function we can now move our droid with our code 🎉 Let's try to draw a square!

for (let i = 0 ; i < 4 ; i++) {
  await writePacket(
    characteristic,
    buildPacket(
      MSG_MOVE, 
      [0xFF, ...convertDegreeToHex(i * 90), 0x00]
    )
  );
  await new Promise(resolve => setTimeout(resolve, 2000));
}
Enter fullscreen mode Exit fullscreen mode

Remember to set a timeout after sending a MSG_MOVE, these message are executed instantly! Also keep in mind that heading takes some time to execute (~450ms for 180° rotation).

Accelerometer inspection

Accelerometer inspection is the hardest part I found during reverse engineering. Using the official app to move the droid I didn't find anything related to the accelerometer (e.g. collision detection), so I tried to use another app [Sphero Edu] where events like collision detection are supported (https://play.google.com/store/apps/details?id=com.sphero.sprk&hl=en). Using this app we can create simple block scripts to play with our droid!

Let's make a simple script with collision detection enabled and log BLE communication during its execution

Sphero Edu App

Inspecting Wireshark log you can see that there's a special message sent by Sphero Edu App to our droid

| 0x0A | 0x18 | 0x00 | 0x00 | 0x96 | 0x00 | 0x00 | 0x07 | 0xe0 | 0x78 |
Enter fullscreen mode Exit fullscreen mode

This message activates an infinite stream of messages like these

| 0x8D | 0x00 | 0x18 | 0x02 | 0xFF | 0x41 | 0xE8 | 0xBA | 0x70 | 0x41 | 0x35 | 0xB6 | 0x97 | 0xC1 | 0xAB | 0x50 | 0xDB | ... | 0xD8 |

| 0x8D | 0x00 | 0x18 | 0x02 | 0xFF | 0x42 | 0xE2 | 0xAA | 0x60 | 0x41 | 0x35 | 0xB2 | 0x67 | 0xC1 | 0xBB | 0x20 | 0xAB | ... | 0xD8 |
Enter fullscreen mode Exit fullscreen mode

The common part of these messages is

| 0x8D | 0x00 | 0x18 | 0x02 | 0xFF |
Enter fullscreen mode Exit fullscreen mode

I expect to find there X, Y and Z values. At a first glance, the 12 bytes following the common part, look to be 3 IEEE754 numbers

Common part: | 0x8D | 0x00 | 0x18 | 0x02 | 0xFF |
X axis:      | 0x41 | 0xE8 | 0xBA | 0x70 |
Y axis:      | 0x41 | 0x35 | 0xB6 | 0x97 |
Z axis:      | 0xC1 | 0xAB | 0x50 | 0xDB |
Enter fullscreen mode Exit fullscreen mode

XYZ

We need to modify our code before receiving these data because they may interfere with other data read operations. To avoid this problem use a function to check the "header" of the received packet (isActionResponse)

let isActionResponse = (data) => {
  let valid = false;
  valid |= data.slice(0, 2).every((v) => [0x8D, 0x09].indexOf(v) >= 0);
  valid |= data.slice(0, 2).every((v) => [0x8D, 0x08].indexOf(v) >= 0);
  valid |= data.slice(0, 3).every((v) => [0x8D, 0x00, 0x17].indexOf(v) >= 0);
  return valid;
}
Enter fullscreen mode Exit fullscreen mode

And add this code before data validation on writePacket

let listenerForRead = (data) => {

  // ...

  if (eopPosition !== -1) {
    // Check if Package is for me
    if (isActionResponse(dataToCheck)) {
      // Process data
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

It's time to create the main function to activate the accelerometer inspection, enableAccelerometerInspection. This function have to

  • Receive a characteristic and a callback function
  • Write the packet to activate accelerometer inspection
  • Read data and decode them (remember the schema?) Encode/decode
  • Convert X, Y and Z values and send them to the callback
const MSG_ACCELEROMETER = [0x0A, 0x18, 0x00];


let enableAccelerometerInspection = (characteristic, callback) => {
  let dataRead = [];
  let dataToCheck = [];
  let eopPosition = -1;
  characteristic.write(Buffer.from(buildPacket(MSG_ACCELEROMETER, [0x00, 0x96, 0x00, 0x00, 0x07, 0xe0, 0x78])));
  characteristic.on('data', (data) => {
    dataRead.push(...data);
    eopPosition = dataRead.indexOf(EOP);
    dataToCheck = dataRead.slice(0);
    if (eopPosition !== dataRead.length - 1) {
      dataRead = dataRead.slice(eopPosition + 1);
    } else {
      dataRead = [];
    }
    if (eopPosition !== -1) {
      if (dataToCheck.slice(0, 5).every((v) => [0x8D, 0x00, 0x18, 0x02, 0xFF].indexOf(v) >= 0)) {
        // Decode packet
        let packetDecoded = [];
        for (let i = 0; i < dataToCheck.length - 1; i++) {
          if (dataToCheck[i] == ESC && dataToCheck[i + 1] == ESC_ESC) {
            packetDecoded.push(ESC);
            i++;
          } else if (dataToCheck[i] == ESC && dataToCheck[i + 1] == ESC_SOP) {
            packetDecoded.push(SOP);
            i++;
          } else if (dataToCheck[i] == ESC && dataToCheck[i + 1] == ESC_EOP) {
            packetDecoded.push(EOP);
            i++;
          } else {
            packetDecoded.push(dataToCheck[i])
          }
        }
        let x = Buffer.from(packetDecoded.slice(5, 9)).readFloatBE(0);
        let y = Buffer.from(packetDecoded.slice(9, 13)).readFloatBE(0);
        let z = Buffer.from(packetDecoded.slice(13, 17)).readFloatBE(0);
        callback(x, y, z);
      }
    }
  });
}
Enter fullscreen mode Exit fullscreen mode
enableAccelerometerInspection(characteristic, (x, y, z) => {
  console.log('----------------------')
  console.log("X:" + x)
  console.log("Y:" + y)
  console.log("Z:" + z)
});
Enter fullscreen mode Exit fullscreen mode

Watch this video to see accelerometer in action 📺

Watch the video

Log accelerometer

Every second the callback gets called ~ 7 times. With these values you can program incline detection, check if your droid fall on the ground, write a simple collision detection and so on!

DYALF

It's time to wrap all that we learned during this reverse engineering process in a library to take advantage of OOP and write a better and more reusable code. For this purpose I created the library DYALF (Droids You Are Looking For) containing all the methods to play with R2D2. You can check the code on Github. With DYALF you can write code like this

const dyalf = require('./dyalf');


let main = async () => {

  let r2 = new dyalf.R2D2('4bef2b0786334e2fac126c55f7f2d057');

  await r2.connect();
  await r2.openCarriage();
  await r2.sleep(1000);
  await r2.animate(7);

  for (var i = -160; i < 180; i += 5) {
    await r2.rotateTop(i);
  }

  await r2.off();

  dyalf.shutdown();

};

main();
Enter fullscreen mode Exit fullscreen mode

And is made to support other droids extending the base class Droid (BB8 droid support will be ready soon!).

Using the movement is really simple and readable, rewriting the square drawing function with DYALF will look like

console.log('Make a square 🔳');
for (let i = 0; i < 4; i++) {
  await r2.move(0xFF, i * 90, 3000);
}

await r2.stop();
Enter fullscreen mode Exit fullscreen mode

DYALF adds the time parameter to move your droid in a specific direction for N milliseconds.

To get accelerometer values we can simply listen to an event! The base class Droid extends EventEmitter to support events

const EventEmitter = require('events');


class Droid extends EventEmitter {
Enter fullscreen mode Exit fullscreen mode

so you can receive accelerometer values listening to accelerometer event!

r2.on('accelerometer', (x, y, z) => {

});
Enter fullscreen mode Exit fullscreen mode

If you want to see other funny methods of DYALF, check the examples folder containing some useful scripts.

Cover image: artwork by Susan Murtaugh

Top comments (1)

Collapse
 
josh_rainone_537d7f1dba7e profile image
Josh Rainone

I'm trying to revive an original Sphero R2-D2 that has been unused for approximately 5+ years.

I'm looking for someone familiar with the R2-D2/Sphero V2 BLE protocol who can help determine whether this is a firmware/application problem or whether I'm missing an initialization/recovery step.

ROBOT

Name: D2-C5F1
BLE address: D7:86:AC:82:C5:F1

The robot is consistently discoverable and accepts BLE connections.

CURRENT GATT SERVICES

The robot exposes:

00001800-0000-1000-8000-00805f9b34fb
00001801-0000-1000-8000-00805f9b34fb
00020001-574f-4f20-5370-6865726f2121
0000180f-0000-1000-8000-00805f9b34fb

The 00020001 service contains:

00020002 — notify + write
00020003 — write-without-response
00020004 — read
00020005 — write + write-without-response

The following expected V2 API services are completely absent:

00010001-574f-4f20-5370-6865726f2121
00010002-574f-4f20-5370-6865726f2121
00010003-574f-4f20-5370-6865726f2121

This is the main problem.

00020004 READ

Reading 00020004 consistently returns:

12 00 01 00 04 00 02 01

Length: 8 bytes.

FIRMWARE SURVEY

I ran firmware_survey.py from the ccb/sphero-r2d2 project.

The robot is correctly identified as R2D2, but all of the following queries fail:

main_app_version: ERROR
bootloader_version: ERROR
board_revision: ERROR
processor_name: ERROR
secondary_main_app_version: ERROR
secondary_bootloader_version: ERROR
battery_voltage: ERROR
battery_state: ERROR
head_position: ERROR
current_leg_action: ERROR
leg_position: ERROR

The survey does successfully identify:

toy_type: R2D2
led_count: 8
sound_count: 29
animation_count: 18

ANTI-DOS HANDSHAKE

I sent the documented R2-D2 handshake:

usetheforce...band

to:

00020005-574f-4f20-5370-6865726f2121

The write succeeds.

I then disconnected completely, waited, reconnected, and performed a fresh GATT discovery.

The 000100xx services STILL did not appear:

00010001: FALSE
00010002: FALSE
00010003: FALSE

I also performed a handshake-only test with no other commands whatsoever. Same result.

So the handshake does not appear to be the missing step.

NORDIC DFU CHECK

I performed a complete GATT enumeration specifically looking for standard Nordic DFU services.

Legacy Nordic DFU:

00001530-1212-efde-1523-785feabcd123
NOT PRESENT

Secure DFU:

0000fe59-0000-1000-8000-00805f9b34fb
NOT PRESENT

The corresponding legacy DFU characteristics are also absent.

Therefore the robot does not appear to be advertising a standard Nordic DFU service.

FIRMWARE COMMAND TEST

I attempted the R2 firmware-management queries from the ccb/sphero-r2d2 implementation.

DID 29:

CID 13 — get_pending_update_flags
CID 21 — get_current_application_id
CID 22 — get_all_updatable_processors
CID 24 — get_version_for_updatable_processors
CID 27 — get_pending_update_for_processors

Initially there was a packet-framing error in my test: the EOP byte was missing.

Those tests were repeated correctly with the Sphero V2 EOP byte 0xD8.

The packets were:

8D 0A 1D 0D 00 CB D8
8D 0A 1D 15 01 C2 D8
8D 0A 1D 16 02 C0 D8
8D 0A 1D 18 03 BD D8
8D 0A 1D 1B 04 B9 D8

Before sending them, I explicitly verified the CCCD on 00020002.

CCCD:
00002902-0000-1000-8000-00805f9b34fb

Before enabling notifications:
00 00

After start_notify():
01 00

So notifications were definitely enabled.

All five correctly framed packets were successfully written to 00020003.

No notification/response was received.

After the fifth query, the BLE connection was lost.

IMPORTANT CAVEAT:
I don't know whether 00020003 is actually the correct transport for DID-29 commands when the normal 00010002 API is absent. The upstream ccb/sphero-r2d2 implementation normally uses 00010002 for V2 commands, and my R2 does not expose that characteristic.

MSG_INIT

I have NOT successfully performed the real MSG_INIT test.

The documented/captured packet is:

8D 0A 13 0D 00 D5 D8

DID 19 / CID 13.

However, this command is supposed to be sent through the normal 00010002 API characteristic.

Since my R2 does not expose 00010002, I have deliberately NOT treated sending it to 00020003 as a valid MSG_INIT test.

OTHER OBSERVATIONS

  • BLE connection is reliable.
  • The robot remains discoverable.
  • Battery reports 100%.
  • 00020004 is readable.
  • The robot identifies itself as an R2D2.
  • Sphero Edu / Legacy Bot Controller can connect but cannot control it.
  • The R2 has previously shown the green/yellow alternating LED behavior.
  • A complete GATT enumeration does not show either standard Nordic DFU service.
  • A targeted check for 00010001/2/3 also returns nothing.
  • Reconnecting after the handshake does not cause 000100xx to appear.

CURRENT THEORY

The robot appears to consistently stop at the Sphero-specific 00020001 initialization layer:

BLE
|
+-- 00020001
|
+-- 00020002
+-- 00020003
+-- 00020004
+-- 00020005

but never reaches:

00010001
|
+-- 00010002
+-- 00010003

I do NOT know whether this means:

  1. The main application is missing/corrupt.
  2. The robot is in some R2-specific initialization state.
  3. There is another undocumented initialization step.
  4. The 000200xx layer has a different purpose than I currently understand.
  5. There is some firmware/version-specific behavior involved.

I don't want to send bootloader/reflash commands blindly.

QUESTIONS

Does anyone recognize this exact GATT state on an R2-D2?

Specifically:

  • What is 12 00 01 00 04 00 02 01 from 00020004?
  • Why would an R2 expose 00020001 but not 00010001/2/3?
  • Is there another R2-specific initialization step?
  • Is there a known way to determine whether the main application is actually resident?
  • Is there an R2-D2-specific recovery/bootloader procedure?
  • Does anyone have a known-good R2-D2 BLE capture that could be compared against this one?
  • Does anyone have a working R2-D2 and could provide its complete GATT service list and connection sequence?

I also have another original R2-D2 arriving in a few days. I plan to perform a completely passive GATT comparison on that unit before doing anything to it, which should give me a known-good reference.

I'm particularly interested in hearing from anyone who has worked with the ccb/sphero-r2d2 project, reverse-engineered the R2-D2 BLE protocol, or has successfully recovered one of these after sitting unused for years.

I can provide the complete Python diagnostic scripts and raw output if useful.