Jonathan
Jonathan Author of Robopenguins

Reverse Engineering the Dash Learning Robot

I brushed up on my Ghidra to add to the open source interface for a Dash robot I found at Goodwill. Finally, another thrift store project!

Since Making a Turtle Bot 1: Hacking a Mint Cleaner, I’ve been keeping an eye out for other cheap robotic vacuums to play with. On a recent trip, I noticed a decent looking robot in the toy section for $15 https://store.makewonder.com/products/dash. A quick search showed the company was still in business and there was a repo that at least claimed to offer Python controls. I figured worse case, I could replace the controller and use it for its motors.

Dash Robot Used as Intended

First, I gave it a spin using it as intended. It is meant to be controlled through an app and has a pretty nice remote control and visual programming language. Everything seemed to be working and already had a pretty neat toy out of my purchase.

Here’s an Overview of its features:

Commercial Educational Robot Toys

I can’t really say where the Dash falls in the ranking of learning robot toys. Its ecosystem seems fine, and the bot is cute while still having a bunch of features. On the other hand, I have a strong bias toward wanting something that’s an open system.

  • They are more likely to teach generalizable skills
  • They can more easily outlive their parent company
  • Their incentives are better aligned to be good rather than sticky
  • They’re more likely to be interoperable with other systems widening the play space

That is how I justify it anyway.

The Official Python Interface

Much to their credit, the MakeWonder company released an official Python library.

https://github.com/playi/WonderPy

Rather than being cutesy, or a stripped down basic interface, it breaks out all the controls and sensors in a reasonable way. It even covers overlapping functionality to expose high level features like getting a position estimate and moving to a certain pose as well as low level features like motor control and accelerometer readings.

Here’s the documentation of the features it covers: https://github.com/playi/WonderPy/blob/master/doc/WonderPy.md

They even have some handy diagrams for the coordinate system: https://github.com/playi/WonderPy/blob/master/doc/WonderPy.md#coordinate-systems

This would more than cover all the features I wanted for my turtle bot. The catch is that it only works in Python 2 on x86 OSX.

Porting the Library

I found a few efforts to port the library to Python 3 and other OSs.

  • https://github.com/IlyaSukhanov/morseapi - As far as I can tell, this was the only real reverse engineering effort. I hadn’t looked at it in detail until writing this article, since I figured the forks would have been mostly additive. As it turned out, this project has more functionality then its “children” and even had a partial implementation of the command I was interested in.
  • https://github.com/havnfun/python-dash-robot - Is built on the previous project. It adds Python 3 compatibility along with dropping a bunch of the previously supported commands and sensors
  • https://github.com/mewmix/bleak-dash - Is built on the previous projects. Switches the BlueTooth support library and adds back the some of the sensor decoding

Since I assumed the latest project would be the most complete, I used it as my starting point. In testing, the movement commands worked, but even doing something as simple as turning 90 degrees wasn’t easy to achieve. Instead of using the high level pose command like the original library, it used low level motor control in a way that didn’t seem well tuned.

To illustrate here’s the evolution of the turning command:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# https://github.com/playi/WonderPy
# This uses the robot's pose command to turn the robot in place.
def do_turn(self, deg, speed_deg_s):
    """
    This is a somewhat naive drive command because it moves relative to the robot's measured position,
    which means error can accumulate.
    """
    self.do_pose(0, 0, deg, abs(float(deg)) / float(speed_deg_s), True,
                  WWRobotConstants.WWPoseMode.WW_POSE_MODE_RELATIVE_MEASURED,
                  WWRobotConstants.WWPoseDirection.WW_POSE_DIRECTION_INFERRED,
                  False)


# https://github.com/IlyaSukhanov/morseapi
# This still uses the pose command, but the command no longer actually maps to the full pose.
# It appears that only relative movements are supported and some of the other flags are missing.
def turn(self, degrees, speed_dps=(360/2.094)):
    """
    Turn Dash specified distance.

    This is a blocking call.

    :param degrees: How many degrees to turn.
    Positive values spin clockwise and negative counter-clockwise.
    :param speed: Speed to turn at, in degrees/second
    """
    if abs(degrees) > 360:
        raise NotImplementedError("Cannot turn more than one rotation per move")
    if degrees:
        seconds = abs(degrees/speed_dps)

        # def _get_move_byte_array(distance_mm=0, degrees=0, seconds=1.0, eight_byte=0x80):
        byte_array = _get_move_byte_array(degrees=degrees, seconds=seconds)
        self.command("move", byte_array)
        # self.sleep does not work and api says not to use time.sleep...
        time.sleep(seconds)


# https://github.com/mewmix/bleak-dash
# This is the worst of all since it uses the drive command to try to spin the
# wheels open-loop, and doesn't appear to make logical sense since the speed_dps
# used to compute duration isn't related to the actual speed applied to the motors.
async def turn(self, degrees, speed_dps=360/2.094):
    """
    Turns the robot a specific number of degrees at a certain speed.
    This method simplifies the operation to a 'spin' command for a calculated duration.
    Adjust this method based on your robot's capabilities.
    """
    if abs(degrees) > 360:
        print("Cannot turn more than one rotation per move")
        return
    
    # Assuming positive degrees for clockwise, negative for counter-clockwise
    speed = 200 if degrees > 0 else -200
    # Calculate duration based on speed and degrees to turn
    duration = abs(degrees / speed_dps)
    speed = max(-2048, speed)
    speed = min(2048, speed)
    if speed < 0:
        speed = 0x8000 + abs(speed)  # Adjust for negative speeds if necessary
    await self.command("drive", bytearray([
        0x00,  # Placeholder for potential additional parameters
        speed & 0xff,
        (speed >> 8) & 0xff
    ]))
    await asyncio.sleep(duration)
    await self.stop()

I originally assumed that these ports were rewriting the original Python implementation. However the original Python code doesn’t generate the commands to send to the robot directly. It builds a JSON string that it passes to a native OSX binary library that generates the binary data to send to the robot over Bluetooth.

This explains why the original library was so limited and why the ports mostly just remix each other. If I wanted to get robust controls, I’d need to do some reverse engineering myself.

Reverse Engineering

The authors of the previous ports don’t explain how they reverse engineered the existing features. Only https://github.com/IlyaSukhanov/morseapi seems to have actually done original work and he describes it as a process of trial and error, looking at the binary data sent when doing certain behaviors.

While they don’t go into details as far as I can tell, the two ways I’d guess they might have tried would be:

Since I didn’t have a suitable OSX machine that option was out.

While capturing the Android BlueTooth packets sounded interesting (and might be the only way for some other devices), it would be a lot of tedious trial and error.

Rather than take either of these approaches, I decided to go back to the original program. Since the JSON commands were already very comprehensive, I predicted that reverse engineering the OSX dylib wouldn’t be too hard. This is mostly due to the JSON being all string based. Somewhere the key value pairs being sent would be mapped to the command data.

The file I needed to decompile is: https://github.com/playi/WonderPy/blob/master/WonderPy/lib/WonderWorkshop/osx/libWWHAL.dylib

While I haven’t done any serious decompiling in the past, I had used the NSA open source decompiler Ghidra for some capture the flag reverse engineering games. Decompiling dylibs is supported out of the box, so I imported the library and gave it a go. Fortunately, the function names were compiled into the library, so I had a decent amount of context to understand the call trees.

The first thing I did was find where the strings for the keys related to the commands were being used. I started with a command that was fully supported in the unofficial ports to check that the JSON was turning into the expected binary packets.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void __thiscall
APICore::BotMessengerBuffer::parseBodyWheels
          (BotMessengerBuffer *this,nx_json *param_1,HALMotorWheel_t *param_2,HALMotorWheel_t *param_3)

{
  int iVar1;
  cnx_json har *lVar2;
  int local_2c;
  
  if ((param_2 != (HALMotorWheel_t *)0x0) && (param_3 != (HALMotorWheel_t *)0x0)) {
    for (local_2c = 0; local_2c < *(int *)(param_1 + 0x28); local_2c = local_2c + 1) {
      lVar2 = _nx_json_item(param_1,local_2c);
      iVar1 = _strcmp((char *)(lVar2 + 8),"left_cm_s");
      if (iVar1 == 0) {
        *(undefined8 *)(param_2 + 8) = *(undefined8 *)(lVar2 + 0x20);
      }
      iVar1 = _strcmp((char *)(lVar2 + 8),"right_cm_s");
      if (iVar1 == 0) {
        *(undefined8 *)(param_3 + 8) = *(undefined8 *)(lVar2 + 0x20);
      }
    }
  }
  return;
}

A quick Google found that the nx_json refers to the https://github.com/thestr4ng3r/nxjson C JSON library which helpfully lists its structure definition in the README:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef enum nx_json_type {
  NX_JSON_NULL,    // this is null value
  NX_JSON_OBJECT,  // this is an object; properties can be found in child nodes
  NX_JSON_ARRAY,   // this is an array; items can be found in child nodes
  NX_JSON_STRING,  // this is a string; value can be found in text_value field
  NX_JSON_INTEGER, // this is an integer; value can be found in int_value field
  NX_JSON_DOUBLE,  // this is a double; value can be found in dbl_value field
  NX_JSON_BOOL     // this is a boolean; value can be found in int_value field
} nx_json_type;

typedef struct nx_json {
  nx_json_type type;       // type of json node, see above
  const char* key;         // key of the property; for object's children only
  const char* text_value;  // text value of STRING node
  long int_value;          // the value of INTEGER or BOOL node
  double dbl_value;        // the value of DOUBLE node
  int length;              // number of children of OBJECT or ARRAY
  nx_json* child;          // points to first child
  nx_json* next;           // points to next child
} nx_json;

With this I could define the structure of nx_json in Ghidra

and simplify the function to:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void __thiscall
APICore::BotMessengerBuffer::parseBodyWheels
          (BotMessengerBuffer *this,nx_json *param_1,HALMotorWheel_t *param_2,
          HALMotorWheel_t *param_3)
{
  int iVar1;
  nx_json *lVar2;
  int local_2c;
  
  if ((param_2 != (HALMotorWheel_t *)0x0) && (param_3 != (HALMotorWheel_t *)0x0)) {
    for (local_2c = 0; local_2c < param_1->length; local_2c = local_2c + 1) {
      lVar2 = (nx_json *)_nx_json_item(param_1,local_2c);
      iVar1 = _strcmp(lVar2->key,"left_cm_s");
      if (iVar1 == 0) {
        *(double *)(param_2 + 8) = lVar2->dbl_value;
      }
      iVar1 = _strcmp(lVar2->key,"right_cm_s");
      if (iVar1 == 0) {
        *(double *)(param_3 + 8) = lVar2->dbl_value;
      }
    }
  }
  return;
}

This is pretty readable without doing the extra steps to guess the structure of HALMotorWheel_t and give the variables nicer names. This already was fairly different from the Python code. These values are being stored as doubles, and they don’t appear to be directly being used to build the packet structure that gets sent to the Robot. That means that the parsing process is more complicated.

Looking at the references to parseBodyWheels I found :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
// A bunch of intermediate variables are removed for brevity.
nx_json * APICore::BotMessengerBuffer::convertCtlrMessageToPackets(nx_json *param_1)
{
  RobotHW *this;
  Ctlr2BotMsg *this_00;
  ...
  HALPamplemousse_t *local_1a8;
  HALAudioSynth_t *local_1a0;
  HALPing_t *local_198;
  HALPower_t *local_190;
  HALBodyPose_t *local_188;
  HALBodyMotionLinearAngular_t *local_180;
  HALUserSetting_t *local_178;
  HALAnim_t *local_170;
  HALSpeaker_t *local_168;
  HALEyeRing_t *local_160;
  HALRGB_t *local_158;
  HALLED3_t *local_150;
  HALLED_t *local_148;
  HALMotorWheel_t *local_140;
  HALMotorWheel_t *local_138;
  HALMotorWheel_t *local_130;
  HALComponentScalar_t *local_128;
  HALMotorServo_t *local_120;
  HALMotorServo_t *local_118;
  HALLedMsg_t *local_108;
  HALLauncher_t *local_100;

  HAL::Ctlr2BotMsg::Ctlr2BotMsg(this_00);
  HAL::RobotHW::initComponentsForCtlr2BotMsg((RobotHW *)in_RSI->dbl_value,this_00);

  do {
    if (local_d0[10] <= local_e8) {
      local_1b1 = 0;
      HAL::RobotHW::packetizeCtlr2BotMsg((Ctlr2BotMsg *)param_1);
      std::shared_ptr<>::shared_ptr<>((shared_ptr<> *)&local_1c8,local_d8,0);
      
      return param_1;
    }
    local_f0 = (nx_json *)_nx_json_item(local_d0,local_e8);
    local_f4 = _atoi(local_f0->key);
    if (local_f4 == 1) {
      local_190 = (HALPower_t *)HAL::Ctlr2BotMsg::getPowerStorage(local_d8,1);
      parsePower((BotMessengerBuffer *)in_RSI,local_f0,local_190);
    }
    else if (local_f4 == 100) {
      local_160 = (HALEyeRing_t *)HAL::Ctlr2BotMsg::getEyeRingStorage(local_d8,100);
      parseEyeRing((BotMessengerBuffer *)in_RSI,local_f0,local_160);
    }
    else if (local_f4 - 0x65 < 4) {
LAB_000662f5:
      local_158 = (HALRGB_t *)HAL::Ctlr2BotMsg::getRGBStorage(local_d8,local_f4);
      parseLightRGB((BotMessengerBuffer *)in_RSI,local_f0,local_158);
    }
    else if (local_f4 - 0x69 < 2) {
LAB_00066285:
      local_148 = (HALLED_t *)HAL::Ctlr2BotMsg::getLEDStorage(local_d8,local_f4);
      parseLightLED((BotMessengerBuffer *)in_RSI,local_f0,local_148);
    }
    else {
      if (local_f4 == 0x6b) goto LAB_000662f5;
      if (local_f4 == 0x6c) {
        local_150 = (HALLED3_t *)HAL::Ctlr2BotMsg::getLED3Storage(local_d8,0x6c);
        parseLightLED3((BotMessengerBuffer *)in_RSI,local_f0,local_150);
      }
      else {
        if (local_f4 - 0x6d < 3) goto LAB_00066285;
        if (local_f4 - 200 < 2) {
          local_130 = (HALMotorWheel_t *)HAL::Ctlr2BotMsg::getMotorWheelStorage(local_d8,local_f4);
          parseMotorWheel((BotMessengerBuffer *)in_RSI,local_f0,local_130);
        }
        else {
          if (local_f4 == 0xca) goto LAB_00066187;
          if (local_f4 == 0xcb) goto LAB_0006614f;
          if (local_f4 == 0xcc) {
            local_180 = (HALBodyMotionLinearAngular_t *)
                        HAL::Ctlr2BotMsg::getBodyMotionLinearAngularStorage(local_d8,0xcc);
            parseBodyMotionLinearAngular((BotMessengerBuffer *)in_RSI,local_f0,local_180);
          }
          else if (local_f4 == 0xcd) {
            local_188 = (HALBodyPose_t *)HAL::Ctlr2BotMsg::getBodyPoseStorage(local_d8,0xcd);
            parseBodyPose((BotMessengerBuffer *)in_RSI,local_f0,local_188);
          }
          else if (local_f4 == 0xce) {
LAB_00066187:
            local_120 = (HALMotorServo_t *)HAL::Ctlr2BotMsg::getMotorServoStorage(local_d8,local_f4)
            ;
            parseMotorHeadTiltServo((BotMessengerBuffer *)in_RSI,local_f0,local_120);
          }
          else {
            if (local_f4 != 0xcf) {
              if (local_f4 == 0xd0) goto LAB_00066187;
              if (local_f4 != 0xd1) {
                if (local_f4 == 0xd2) {
LAB_00066117:
                  local_110 = HAL::Ctlr2BotMsg::getNoParamsStorage(local_d8,local_f4);
                  parseNoParams(in_RSI,(HALNoParams_t *)local_f0);
                }
                else if (local_f4 == 0xd3) {
                  local_138 = (HALMotorWheel_t *)
                              HAL::Ctlr2BotMsg::getMotorWheelStorage(local_d8,200);
                  local_140 = (HALMotorWheel_t *)
                              HAL::Ctlr2BotMsg::getMotorWheelStorage(local_d8,0xc9);
                  parseBodyWheels((BotMessengerBuffer *)in_RSI,local_f0,local_138,local_140);
                }
                else {
                  if (local_f4 == 0xd4) goto LAB_00066117;
                  if (local_f4 - 0xd5 < 2) {
                    local_128 = (HALComponentScalar_t *)
                                HAL::Ctlr2BotMsg::getScalarStorage(local_d8,local_f4);
                    parseScalarPercentage((BotMessengerBuffer *)in_RSI,local_f0,local_128);
                  }
                  else if (local_f4 == 300) {
                    local_168 = (HALSpeaker_t *)HAL::Ctlr2BotMsg::getSpeakerStorage(local_d8,300);
                    parseSpeaker((BotMessengerBuffer *)in_RSI,local_f0,local_168);
                  }
                  else if (local_f4 == 0x12d) {
                    local_170 = (HALAnim_t *)HAL::Ctlr2BotMsg::getAnimStorage(local_d8,0x12d);
                    parseOnRobotAnim((BotMessengerBuffer *)in_RSI,local_f0,local_170);
                  }
                  else if (local_f4 == 0x130) {
                    local_1a0 = (HALAudioSynth_t *)
                                HAL::Ctlr2BotMsg::getAudioSynthStorage(local_d8,0x130);
                    parseAudioSynth((BotMessengerBuffer *)in_RSI,local_f0,local_1a0);
                  }
                  else if (local_f4 - 400 < 2) {
                    local_100 = (HALLauncher_t *)
                                HAL::Ctlr2BotMsg::getLauncherStorage(local_d8,local_f4);
                    parseLauncher((BotMessengerBuffer *)in_RSI,local_f0,local_100);
                  }
                  else if (local_f4 == 0x19a) {
                    local_108 = (HALLedMsg_t *)HAL::Ctlr2BotMsg::getLedMsgStorage(local_d8,0x19a);
                    parseLedMsg((BotMessengerBuffer *)in_RSI,local_f0,local_108);
                  }
                  else if (local_f4 == 0x1c2) {
                    local_1a8 = (HALPamplemousse_t *)
                                HAL::Ctlr2BotMsg::getPamplemousseStorage(local_d8,0x1c2);
                    parsePamplemousseStart((BotMessengerBuffer *)in_RSI,local_f0,local_1a8);
                  }
                  else if (local_f4 == 0x1c3) {
                    local_1b0 = (HALPamplemousse_t *)
                                HAL::Ctlr2BotMsg::getPamplemousseStorage(local_d8,0x1c3);
                    parsePamplemousseStop((BotMessengerBuffer *)in_RSI,local_f0,local_1b0);
                  }
                  else if (local_f4 == 5000) {
                    local_178 = (HALUserSetting_t *)
                                HAL::Ctlr2BotMsg::getUserSettingsStorage(local_d8,5000);
                    parseUserSettings((BotMessengerBuffer *)in_RSI,local_f0,local_178);
                  }
                  else if (local_f4 == 9000) {
                    local_198 = (HALPing_t *)HAL::Ctlr2BotMsg::getPingStorage(local_d8,9000);
                    parsePing((BotMessengerBuffer *)in_RSI,local_f0,local_198);
                  }
                  else {
                    _printf("unknown component id: %d",(ulong)local_f4);
                    _printf("\n");
                  }
                }
                goto LAB_000665c3;
              }
            }
LAB_0006614f:
            local_118 = (HALMotorServo_t *)HAL::Ctlr2BotMsg::getMotorServoStorage(local_d8,local_f4)
            ;
            parseMotorHeadPanServo((BotMessengerBuffer *)in_RSI,local_f0,local_118);
          }
        }
      }
    }
LAB_000665c3:
    this = (RobotHW *)in_RSI->dbl_value;
    pvVar1 = (void *)HAL::Ctlr2BotMsg::GetHwData(local_d8);
    HAL::RobotHW::setCommand(this,pvVar1,local_f4);
    local_e8 = local_e8 + 1;
  } while( true );
}

For my purposes this is the top level function. It does the following:

  1. Initialize a Ctlr2BotMsg. This has the storage for all the messages that might be sent.
  2. Loop through the JSON and set the values in the corresponding HAL structure in Ctlr2BotMsg.
  3. After setting the data for the each command call setCommand.
  4. Once all the commands have been setup, call packetizeCtlr2BotMsg.

The first thing I looked for, was where the commands were being mapped to command IDs that we see in the Python port:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
COMMANDS = {
    "neck_color":0x03,
    "tail_brightness":0x04,
    "eye_brightness":0x08,
    "eye":0x09,
    "left_ear_color":0x0b,
    "right_ear_color":0x0c,
    "head_color":0x0d,
    "head_pitch":0x07,
    "head_yaw":0x06,
    "pose":0x23,
    "say":0x18,
    "beep":0x19,
    "drive":0x02,
    "move":0x23,
    "reset":0xc8,
}

The JSON uses different integers to identify the commands:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class RobotComponent(object):
        WW_COMMAND_POWER                      =    '1'
        WW_COMMAND_EYE_RING                   =  '100'
        WW_COMMAND_LIGHT_RGB_EYE              =  '101'
        WW_COMMAND_LIGHT_RGB_LEFT_EAR         =  '102'
        WW_COMMAND_LIGHT_RGB_RIGHT_EAR        =  '103'
        WW_COMMAND_LIGHT_RGB_CHEST            =  '104'
        WW_COMMAND_LIGHT_MONO_TAIL            =  '105'
        WW_COMMAND_LIGHT_MONO_BUTTON_MAIN     =  '106'
        WW_COMMAND_LIGHT_RGB_BUTTON_MAIN      =  '107'
        WW_COMMAND_LIGHT_MONO_BUTTONS         =  '108'
        WW_COMMAND_LIGHT_MONO_BUTTON_1        =  '109'  # cue button light - circle
        WW_COMMAND_LIGHT_MONO_BUTTON_2        =  '110'  # cue button light - square
        WW_COMMAND_LIGHT_MONO_BUTTON_3        =  '111'  # cue button light - triangle
        WW_COMMAND_HEAD_POSITION_TILT         =  '202'
        WW_COMMAND_HEAD_POSITION_PAN          =  '203'
        WW_COMMAND_BODY_LINEAR_ANGULAR        =  '204'
        WW_COMMAND_BODY_POSE                  =  '205'
        WW_COMMAND_MOTOR_HEAD_BANG            =  '210'
        WW_COMMAND_BODY_WHEELS                =  '211'
        WW_COMMAND_BODY_COAST                 =  '212'
        WW_COMMAND_HEAD_PAN_VOLTAGE           =  '213'
        WW_COMMAND_HEAD_TILT_VOLTAGE          =  '214'
        WW_COMMAND_SPEAKER                    =  '300'
        WW_COMMAND_ON_ROBOT_ANIM              =  '301'
        WW_COMMAND_LAUNCHER_FLING             =  '400'
        WW_COMMAND_LAUNCHER_RELOAD            =  '401'
        WW_COMMAND_LED_MESSAGE                =  '410'
        WW_COMMAND_SET_PING                   = '9000'

The JSON integers can be seen (as hex) in convertCtlrMessageToPackets which makes sense. They are also referenced in setCommand:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void HAL::RobotHW_rev0::setCommand(void *cmd_data,uint cmd_id)
{
  size_t sVar1;
  
  if (cmd_data != (void *)0x0) {
    if (cmd_id == 1) {
      *(ulong *)cmd_data = *(ulong *)cmd_data | 0x8000;
      *(undefined1 *)((long)cmd_data + 0x28c) = 1;
    }
    else if (cmd_id == 100) {
      if (*(int *)((long)cmd_data + 0x70) == 0) {
        *(ulong *)cmd_data = *(ulong *)cmd_data | 0x100;
      }
      else if (*(int *)((long)cmd_data + 0x70) == 1) {
        *(ulong *)cmd_data = *(ulong *)cmd_data | 0x80000000000;
      }
      else {
        _printf("error: unsupported brightnessMode: %d\n",(ulong)*(uint *)((long)cmd_data + 0x70));
        *(ulong *)cmd_data = *(ulong *)cmd_data | 0x100;
      }
      if (*(int *)((long)cmd_data + 0x58) == 0xffff) {
        *(ulong *)cmd_data = *(ulong *)cmd_data | 0x200;
        *(ulong *)cmd_data = *(ulong *)cmd_data & 0xfffffffffffffbff;
      }
      else {
        *(ulong *)cmd_data = *(ulong *)cmd_data | 0x400;
        *(ulong *)cmd_data = *(ulong *)cmd_data & 0xfffffffffffffdff;
      }
      *(undefined1 *)((long)cmd_data + 0x54) = 1;
    }
    else {
      if (cmd_id != 0x65) {
        if (cmd_id == 0x66) {
          *(ulong *)cmd_data = *(ulong *)cmd_data | 0x800;
          *(undefined1 *)((long)cmd_data + 0x84) = 1;
          return;
        }
...

Here the JSON commands are mapped to the one or more bit mask flags.

These flags are then checked in packetizeCtlr2BotMsg. The pose command I’m interested in has the JSON ID of 0xCD, which sets the bit flag 0x20000. In packetizeCtlr2BotMsg this is handled in this snippet.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
uVar19 = testAndClearCommandMaskBit(prVar25,0x20000);
if ((uVar19 & 1) != 0) {
  uVar17 = *(int *)(msg_hw_ptr + 0x2f8);
  if ((uVar17 == 0) || (local_688 = uVar17 + -6, uVar17 - 2U < 4)) {
    *(double *)local_200 = 0.0;
  }
  for (i = 0; i < 3; i = i + 1) {
    if ((*(int *)(msg_hw_ptr + 0x2f8) == 3) || (*(int *)(msg_hw_ptr + 0x2f8) == 4)) {
      local_3e0 = 9;
      if (8 < 0x14U - *(int *)(msg_hw_ptr + (long)i * 0x18 + 8)) {
        if (*(int *)(msg_hw_ptr + 0x2f8) == 3) {
          local_3e1 = (robot_hw0_cmd_data)0x17;
        }
        else {
          local_3e1 = (robot_hw0_cmd_data)0x29;
        }
        local_690 = *(double *)(msg_hw_ptr + 0x2d8) * 10.0;
        if (32767.0 <= local_690) {
          local_690 = 32767.0;
        }
        if (local_690 <= -32768.0) {
          local_698 = -32768.0;
        }
        else {
          local_698 = local_690;
        }
        ...
        msg_hw_ptr[(ulong)*(uint *)(msg_hw_ptr + (long)i * 0x18 + 8) + (long)i * 0x18 + 0xc] =
              local_3e1;
        msg_hw_ptr
        [(ulong)(*(int *)(msg_hw_ptr + (long)i * 0x18 + 8) + 1) + (long)i * 0x18 + 0xc] =
              SUB21((ushort)local_40a >> 8,0);
        ...

Based on parseBodyPose, I’d already created a data type for the pose data. It appeared that the data parsed from the JSON was being accessed here and serialized into the packet to send to the robot. I made a new data type to map the pose data to the right offset

and made this block much clearer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
uVar20 = testAndClearCommandMaskBit(prVar12,0x20000);
if ((uVar20 & 1) != 0) {
  mode = (msg_hw_ptr->pose).mode;
  if ((mode == 0) || (local_688 = mode - 6, mode - 2 < 4)) {
    *(double *)local_200 = 0.0;
  }
  for (i = 0; i < 3; i = i + 1) {
    if (((msg_hw_ptr->pose).mode == 3) || ((msg_hw_ptr->pose).mode == 4)) {
      // Logic for generating command to change the global coordinate origin
      ...
    }
    else {
      local_411 = 0x23;
      local_420 = 9;
      if (8 < 0x14U - *(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8)) {
        x_enc = (msg_hw_ptr->pose).x * 10.0;
        local_430 = (msg_hw_ptr->pose).y * 10.0;
        local_438 = (msg_hw_ptr->pose).theta * 100.0;
        local_440 = local_438 - *(double *)local_200;
        local_6d8 = (msg_hw_ptr->pose).time * 1000.0;
        if (65535.0 <= local_6d8) {
          local_6d8 = 65535.0;
        }
        if (local_6d8 <= 0.0) {
          local_6e0 = 0.0;
        }
        else {
          local_6e0 = local_6d8;
        }
        local_448 = local_6e0;
        local_428 = x_enc;
        *(undefined8 *)((long)pppHVar27 + -8) = 0x7e580;
        dVar29 = (double)_round(x_enc);
        x_enc = local_430;
        local_44a = (undefined2)(int)dVar29;
        *(undefined8 *)((long)pppHVar27 + -8) = 0x7e59b;
        dVar29 = (double)_round(x_enc);
        x_enc = local_440;
        local_44c = (undefined2)(int)dVar29;
        *(undefined8 *)((long)pppHVar27 + -8) = 0x7e5b6;
        x_enc = (double)_round(x_enc);
        local_6e2 = (short)(int)x_enc;
        iVar18 = (int)local_448;
        local_450 = (undefined2)iVar18;
        *(double *)local_200 = (double)(int)local_6e2 - local_440;
        msg_hw_ptr->field0_0x0
        [(ulong)*(uint *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + (long)i * 0x18 + 0xc] =
              local_411;
        msg_hw_ptr->field0_0x0
        [(ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 1) +
          (long)i * 0x18 + 0xc] = (char)local_44a;
        msg_hw_ptr->field0_0x0
        [(ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 2) +
          (long)i * 0x18 + 0xc] = (char)local_44c;
        msg_hw_ptr->field0_0x0
        [(ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 3) +
          (long)i * 0x18 + 0xc] = (char)(int)x_enc;
        msg_hw_ptr->field0_0x0
        [(ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 4) +
          (long)i * 0x18 + 0xc] = (char)((uint)iVar18 >> 8);
        msg_hw_ptr->field0_0x0
        [(ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 5) +
          (long)i * 0x18 + 0xc] = (char)iVar18;
        msg_hw_ptr->field0_0x0
        [(ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 6) +
          (long)i * 0x18 + 0xc] = (byte)((ushort)local_44a >> 8) & 0x3f;
        msg_hw_ptr->field0_0x0
        [(ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 7) +
          (long)i * 0x18 + 0xc] = (byte)((ushort)local_44c >> 8) & 0x3f;
        uVar20 = (ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 6);
        msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] =
              msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] |
              (byte)(local_6e2 >> 2) & 0xc0;
        uVar20 = (ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 7);
        msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] =
              msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] |
              (byte)(local_6e2 >> 4) & 0xc0;
        msg_hw_ptr->field0_0x0
        [(ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 8) +
          (long)i * 0x18 + 0xc] = 0;
        if ((msg_hw_ptr->pose).mode == 5) {
          local_6e8 = 3;
        }
        else {
          local_6e8 = (msg_hw_ptr->pose).mode;
        }
        uVar20 = (ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 8);
        msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] =
              msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] | (byte)(local_6e8 << 6);
        uVar20 = (ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 8);
        msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] =
              msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] |
              ((msg_hw_ptr->pose).ease & 1) << 5;
        uVar20 = (ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 8);
        msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] =
              msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] |
              (byte)((msg_hw_ptr->pose).wrap_theta << 4);
        uVar20 = (ulong)(*(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + 8);
        msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] =
              msg_hw_ptr->field0_0x0[uVar20 + (long)i * 0x18 + 0xc] |
              (byte)(msg_hw_ptr->pose).dir;
        *(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) =
              *(int *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + (int)local_420;
        local_44e = local_6e2;
        break;
      }
    }
  }
}

The only complicated thing left is where these values are being written to. For some reason, this code loops through the data making three copies. The line:

1
2
3
msg_hw_ptr->field0_0x0
        [(ulong)*(uint *)(msg_hw_ptr->field0_0x0 + (long)i * 0x18 + 8) + (long)i * 0x18 + 0xc] =
              local_411;

Writes the command ID byte 0x23 (which matches the value in the Python port) to an offset in memory. This value appears to be the result of following a pointer to a pointer of the structure that’s building the packets to send. Still, we get the clear sequence of data which can be cleaned up to:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// Get base pointer to the buffer
uint8_t *buffer_base = msg_hw_ptr->field0_0x0 + (long)local_3d8 * 0x18 + 0xc;
int *write_offset_ptr = (int *)(msg_hw_ptr->field0_0x0 + (long)local_3d8 * 0x18 + 8);
int write_offset = *write_offset_ptr;

// Initialize tracking variable
*(double *)local_200 = 0.0;

// Packet type/ID
uint8_t packet_id = 0x23;
uint8_t packet_size = 9;

// Scale and prepare pose data
double x_scaled = (msg_hw_ptr->pose).x * 10.0;
double y_scaled = (msg_hw_ptr->pose).y * 10.0;
double theta_scaled = (msg_hw_ptr->pose).theta * 100.0;
double theta_delta = theta_scaled - *(double *)local_200;
double time_ms = (msg_hw_ptr->pose).time * 1000.0;

// Clamp time to uint16 range
if (time_ms >= 65535.0) {
    time_ms = 65535.0;
}
if (time_ms < 0.0) {
    time_ms = 0.0;
}

// Round values for encoding
int16_t x_encoded = (int16_t)round(x_scaled);
int16_t y_encoded = (int16_t)round(y_scaled);
int16_t theta_encoded = (int16_t)round(theta_delta);
uint16_t time_encoded = (uint16_t)time_ms;

// Update tracking variable with rounding error
*(double *)local_200 = (double)theta_encoded - theta_delta;

// Serialize to buffer
buffer_base[write_offset + 0] = packet_id;                              // Byte 0: Packet ID (0x23)
buffer_base[write_offset + 1] = (uint8_t)x_encoded;                     // Byte 1: X low byte
buffer_base[write_offset + 2] = (uint8_t)y_encoded;                     // Byte 2: Y low byte
buffer_base[write_offset + 3] = (uint8_t)theta_encoded;                 // Byte 3: Theta low byte
buffer_base[write_offset + 4] = (uint8_t)(time_encoded >> 8);          // Byte 4: Time high byte
buffer_base[write_offset + 5] = (uint8_t)time_encoded;                  // Byte 5: Time low byte
buffer_base[write_offset + 6] = ((uint8_t)(x_encoded >> 8)) & 0x3F;    // Byte 6: X high 6 bits
buffer_base[write_offset + 7] = ((uint8_t)(y_encoded >> 8)) & 0x3F;    // Byte 7: Y high 6 bits

// Pack theta high bits into bytes 6 and 7
buffer_base[write_offset + 6] |= ((uint8_t)(theta_encoded >> 2)) & 0xC0;  // Theta bits [9:8]
buffer_base[write_offset + 7] |= ((uint8_t)(theta_encoded >> 4)) & 0xC0;  // Theta bits [11:10]

// Initialize control byte
buffer_base[write_offset + 8] = 0;                                      // Byte 8: Control flags

// Handle mode (convert mode 5 to mode 3)
uint8_t mode = ((msg_hw_ptr->pose).mode == 5) ? 3 : (msg_hw_ptr->pose).mode;

// Pack control flags into byte 8
buffer_base[write_offset + 8] |= (mode & 0x03) << 6;                   // Bits [7:6]: mode
buffer_base[write_offset + 8] |= ((msg_hw_ptr->pose).ease & 0x01) << 5;     // Bit 5: ease
buffer_base[write_offset + 8] |= ((msg_hw_ptr->pose).wrap_theta & 0x01) << 4; // Bit 4: wrap_theta
buffer_base[write_offset + 8] |= ((msg_hw_ptr->pose).dir & 0x0F);      // Bits [3:0]: direction

// Update write offset
*write_offset_ptr += packet_size;

This is trivial to port to the Python library to add a more complete version of the missing functionality.

Conclusion

While I present a pretty clear progression to understanding this library, the actual process was much messier. I didn’t know which leads would be the most fruitful, so sometimes I missed important details as I jumped back and forth, spending time analyzing parts of the library that didn’t turn out to be relevant.

While this turned out to be more complicated than I expected, it was still a relatively straightforward library that I had a lot of external context for.

This function should be all that I need for my project, but maybe I’ll take the time at some point to clean up these libraries a bit more.

The last mystery is why was the original project designed this way? It’s possible hiding the actual packet specification in a binary library was intentional obfuscation, but my guess is that it was a side effect of minimizing effort.

It’s possible that the Python library was initially an internal testing tool. Someone realized that making it public open source would get people like me to consider the dash. If development was standardized on OSX and Python 2.7, that might have been all they supported internally. Once it was released, if it didn’t generate a ton of buzz, there may not have been much will to keep updating it.

I plan to fork the original https://github.com/playi/WonderPy and make it suitable for my use by:

  • Making it Python 3 compatible
  • Switching from its deprecated BLE library to https://github.com/hbldh/bleak
  • Removing the OSX library and replacing it with Python conversions for the commands/sensors I want to support.

See https://github.com/axlan/WonderPy