A few years ago, a group of UX designers gathered together on Reddit to find the worst ways to implement volume control on a website. Their efforts - while hilarious - were tinged with a kind of fatalism; These affronts to usability and good taste were things that may not exist, but could, if we aren't disciplined.
Last year, I discovered something that still makes me laugh. This is the story of the most amazing SDK I have ever encountered.
Background - Continuous Measurement
I was working as the primary app developer for SalonScale. The SalonScale app connects to a number of different bluetooth scales, in order to show a hair stylist exactly how much color they're putting in their bowl at any given moment.
When a salon or stylist signs up with SalonScale, they're usually given a free scale to get them started. The free scale we shipped them wasn't really serving the customers' needs. When a stylist is creating a new hair formula (or replicating an existing one), they are usually squeezing tubes of colour paste or pouring developer into a bowl, that sits on the scale. The weight on the scale is constantly changing as they pour. Unfortunately, the original SalonScale scale couldn't keep up with the constant flow of data. To record the weight of the bowl, the stylist would have to pause their pour, and wait for the weight on the scale to settle. The scale would make a litle beeping noise, and the stylist could resume pouring.
This was a real pain point for the customers, and the company had decided it was time to fix it.
Speaking with our OEM scale manufacturer, we requested a new scale. We wanted a scale that would be more responsive; that could update itself multiple times per second. The scale had to be fast not only in measuring, but in sending that data to the app via bluetooth. Otherwise, it should function the same way.
Our manufacturing partner responded with confidence, that they had just the scale for our needs. They sent us a few demo units, as well as a new SDK that was now mandatory to use for accessing the scale functionality. Additionally, the demo units seemed to turn themselves off within just one minute of inactivity. These were clues that something was awry, but we bravely pushed forward.
Integration Woes
It's worth noting here that the SalonScale app is 90% Flutter, where most of the application logic and UI reside. Many of the supported scales have iOS and Android SDKs, but nothing directly for Flutter. So to get those SDKs to work, we have to leverage iOS and Android code directly, interfacing with those systems via Flutter's Event/Method Channels. So, there was the Flutter app, and it was connected to Swift on the iOS side, and Java on the Android side. (The app was never changed to Kotlin)
Now, the manufacturer makes a large number of scale SKUs, with widely varying levels of functionality. They are used in everything from coffee making to calculating body fat percentage, and the SDK is meant to be a one-stop-shop for integrating with them all.
The first issue was that iOS SDK for the scales was now locked down tightly. The SDK looked for an encrypted configuration file, located in the root folder of the project. Without that file - and as I would discover later, without the correct VERSION of that file - the SDK would silently fail to find scales. Asking the company for support was a slow process, which involved: Messaging our Sales rep at the manufacturer with a detailed list of what we were trying to do and what we had already tried, waiting ~16 hours for this to filter through to the development team and be returned to us, only to find that they needed more information, then repeating the whole process again until we got some useful suggestions.
After a long while, I found a version of their SDK up on a Chinese Github clone, where I found a set of documentation in English. It didn't give me exactly what I needed, but enough to get SOMETHING out of the SDK. I created a sample project to play with the SDK, until I could get it working.
Is This A Pigeon? (Core Bluetooth Version)
Once I finally got the iOS SDK to compile and run with a demo configuration file, I turned to the business of scanning and connecting to the scale.
First, have a look at some of the callback methods in the SDK guide, that you have to implement on the scale:
@protocol PPBluetoothUpdateStateDelegate <NSObject>
// Bluetooth status
- (void)centralManagerDidUpdateState:(PPBluetoothState)state;
@end
@protocol PPBluetoothSurroundDeviceDelegate <NSObject>
// Search for supported devices
- (void)centralManagerDidFoundSurroundDevice:(PPBluetoothAdvDeviceModel *)device andPeripheral:(CBPeripheral *)peripheral;
@end
extension DeviceIceViewController:PPBluetoothConnectDelegate {
// The device is connected
func centralManagerDidConnect() {
}
// Device disconnects
func centralManagerDidDisconnect() {
}
// Device connection failed
func centralManagerDidFail(toConnect error: Error!) {
}
}
Notice anything familiar? That's right, that method name centralManagerDidUpdate: state is almost exactly like the ones Apple provides in CoreBluetooth.
Same thing in the PPBluetoothConnectDelegate. centralManagerDidConnect, centralManagerDidDisconnect and centralManagerDidFail(toConnect: error: Error!) are all loosely similar to Apple Equivalents in Core Bluetooth.
But do they work the same way? Dear reader, they do not. What's happening here is that the SDK writers have decided to trace over Apple's own Core Bluetooth methods, but colour in the picture in any way they see fit.
So you might thing that if you worked up a CoreBluetooth stack, and implemented the CBCentralManagerDelegate methods, you might get those callbacks at the same time as getting the ones in the SDK. You'd be reasonable to think that. And you'd also be incorrect. These methods get fired when the SDK is good and ready, and have no resemblance in their behaviour to the Apple versions. It's confusing, yes.
And then, what about the other one? What is centralManagerDidFoundSurroundDevice? (by the way, what it really means is 'didFindSurroundingDevice', but making APIs in other languages is hard 🤷♂️). It definitely doesn't have an equivalent in Apple's Bluetooth Stack. It turns out that this is simply a callback you get when the SDK has discovered a compatible BLE device in its bluetooth search. Why it's written to look like it IS an Apple API is beyond me. I assume it's just to match the shape with all the other methods like this.
So, since they're going for making things look similar to Apple developers, the rest of the SDK should work similarly, I'm certain...
And Now For Something Completely Different
Next, things took a turn. Brimming with confidence, I clicked to the next page in the implementation guide, and was confronted with this:
PPBluetoothPeripheralApple
PPBluetoothPeripheralBanana
PPBluetoothPeripheralCoconut
PPBluetoothPeripheralDurian
PPBluetoothPeripheralTorre
PPBluetoothPeripheralIce
PPBluetoothPeripheralJambul
What... what am I looking at here? These are devices types that the SDK can see. Each of them brings some different combination of functionality (eg. Wifi support, Taring, remote configuration, various automatic conversion types, et cetera).
I'll leave it as an exercise to the reader to figure out which food does what. As a hint; Our demo scale wasn't on this list, and was a PPBluetoothPeripheralHamburger. Of course it was.
These Are Not The Scales You're Looking For
At this point, we had progressed from 'quirky' to 'unhinged' somewhat quickly. After figuring out that I need to instantiate a Hamburger to connect to the device, I was feeling more confident. However, there were more suprises in store.
Here is the signature of the Hamburger class:
@interface PPBluetoothPeripheralHamburger : NSObject
@property (nonatomic, weak) id<PPBluetoothUpdateStateDelegate> updateStateDelegate;
@property (nonatomic, weak) id<PPBluetoothFoodScaleDataDelegate> scaleDataDelegate;
- (void)receivedDeviceData;
- (void)stopSearch;
- (instancetype)initWithDevice:(PPBluetoothAdvDeviceModel *)device;
@end
That's interesting. We again had the PPBluetoothUpdateState delegate, the same as in the SDK's controller class. After we instantiated our hamburger, we had to wait for the delegate's centralManagerDidUpdateState method again, to ensure that the SDK knew about the scale. At this point I was fairly sure that these Apple-'shaped' methods didn't have anything to do with the actual callbacks they resembled.
What about the PPBluetoothFoodScaleDataDelegate? Inside, there is the following:
@protocol PPBluetoothScaleDataDelegate <NSObject>
- (void)monitorProcessData:(PPBluetoothScaleBaseModel *)model advModel:(PPBluetoothAdvDeviceModel*)advModel;
- (void)monitorLockData:(PPBluetoothScaleBaseModel*)model advModel:(PPBluetoothAdvDeviceModel*)advModel;
Also some WAT here. I must point out at this stage that there were NO documents explaining how to make this work; It was all figured out by blindly poking at various parts of the API.
The sequence of events that had to be followed, is thus:
- On your instance of
PPBluetoothPeripheralHamburger, set the two delegates. - Wait until you get the
centralManagerDidUpdateStatedelegate callback (which we don't think is related to the Apple method of the same name) - Within the callback, call
receivedDeviceData - Listen for either
monitorProcessDataormonitorLockDatato be called. (The former is called when the weight on the scale is changing, the latter when the weight on the scale is stable). stopSearchwhen you want to stop listening to the scale
Eagle-eyed readers will be wondering; Why hasn't Francis mentioned any connection/disconnection callbacks or methods? The answer Dear Reader, is because the Hamburger does not have them! It is not a connectable Bluetooth device!
The demo scale that the manufacturer had shipped us was a Bluetooth Low Energy Broadcast device, otherwise known as a BLE Beacon.
A Beacon Of Hope? Or Abandon All Hope?
Essentially, the scale did nothing but spew out bluetooth advertising packets constantly. An advertising packet generally has a strictly defined set of data that can be sent out, but there is one place that has fewer rules: The Manufacturer Data. Within the manufacturer data of each advertising packet sent out by the new scale was contained:
- A unique ID of the scale
- The current weight in grams on the scale
- The sign of the weight (whether negative or positive)
- Whether the weight was changing or stable
The SDK I was using essentially pretended to be connecting to the scale, and receiving data from it. But in reality, anyone could simply open their bluetooth ears, and immediately read all data off the scale is it came in.
This is NOT how our app's UI was engineered to work. We had assumed all scales would have a 1:1 connection to one device, and once the scale was no longer in use, the connection would be closed and the scale would go dormant.
But no, this scale was working overtime, ALL the time. Suddenly, the automatic shut-off made much more sense. If the scale is constantly (multiple times per second) sending out data, then OF COURSE we'd want a quick auto-shut off.
Faking It
Not being able to connect to the scale was bizarre, but not being able to know when the scale was disconnected was downright frustrating. Our app had plenty of UI to show scale connection state, but when the scale turned off, there was no indication that anything had changed; we simply stopped receiving advertising packets.
My solution was eventually to re-engineer the app to fake disconnection; As soon as we saw the first advertising packet, we started up a "dead man's switch" that fired a Timer every second. The Timer was restarted every time a packet came in from that scale, and if the advertising packets stopped coming in, and the switch fired, we knew that the scale had turned itself off, or had been turned off.
This ultimately worked well enough that we were able to ship the scale to customers, and they didn't have any idea that the scale worked differently to our previous scale.
One More Thing...
Once I had a working implementation, I thought: "And now to get Android working, shouldn't take longer than a couple of days."
But that's another story, involving decompiling an Android SDK, reverse-engineering an encryption protocol, and eventually yelling "Oh to hell with it, I'll just do it myself!" Look out for Part 2!