NOTE The following post talks about the use of adult toys, and though the post only pertains to code, I figured I'd give you a heads up anyway.
The idea
Recently I was asked to add a feature to our site which integrates with the Lovense (adult-toy) API.
How this integrates with the site is through a live chat with a streamer. You as the viewer have the ability to send token tips. When you tip tokens, this makes the streamer's toy activate. It can be activated by strength of vibrations, length of time to vibrate, or in some cases, even control things like rotation. For example, 1 token may vibrate the toy at a low strength for 2 seconds, but 100 tokens may vibrate a high strength for 30 seconds.
A streamer asked if anytime a token was tipped, if the chat could display the strength and length of time. We were already displaying when a tip was made, and we knew what settings the toy had because the API sends this to us. It should be easy!
The setup
Testing the toy isn't that easy. There's quite a bit of setup that goes in to getting this working. You have two primary modes of setup:
- The "dongle"
- Through mobile
The dongle method requires an additional purchase of a USB-A key. Once you have this, you have to download and install an entire app pack from Lovense. This includes several apps and extensions:
- The Lovense Browser. It's a Chromium browser with their extension already installed
- Lovense connect. A desktop app for Windows and macOS to connect to the toy through bluetooth.
- OBS extensions for displaying integrated animations on your live streams (optional extras)
With the Lovense connect open, you plug in the USB key, and it finds the toy and connects to it.
When using the mobile setup, you open up the browser extension (Chrome, Firefox, or Chromium based), then scan a QR code through the mobile app, and it sets everything up for you.
As a developer, when a streamer says "My toy won't connect", there's a lot that has to be considered.
- Are you connected through dongle, or mobile?
- If mobile, iOS or Android, and what version?
- If dongle, what version of Lovense Connect desktop?
- Is the toy charged?
- What OS are you on?
- What browser are you using?
- What version is the browser extension?
- Could this be an issue on our side from a recent update?
As you can see, it can be quite a complicated setup making it somewhat hard to test.
Some code
I'm using Crystal Lang on the back-end. One thing about Crystal is that it's statically typed, even for the JSON. (This is important to the story).
Setting up the front-end is fairly straight forward. Here's some javascript:
const camScript = document.createElement("script");
camScript.setAttribute(
"src",
"https://api.lovense.com/cam-extension/static/js-sdk/broadcast.js"
);
camScript.onload = () => {
this.camExtension = new CamExtension("My Site", this.streamID);
this.camExtension.on("ready", async (ce) => {
this.updateDeviceSettings(await ce.getSettings());
});
this.camExtension.on("postMessage", (message) => {
// ....
});
this.camExtension.on("toyStatusChange", (toys) => {
if (toys && toys.length) {
let mainToy = toys.filter((t) => t.status === "on")[0];
let status = mainToy ? mainToy.status : "off";
if (status != this.deviceStatus) {
this.deviceStatus = status;
this.updateDeviceStatus(status);
}
}
});
this.camExtension.on("settingsChange", (data) => {
this.updateDeviceSettings(data);
});
NOTE: I'm using VueJS, and there's a lot of stuff going on, so this isn't a copy/paste example.
Basically how this works is, it adds a script that connects to the Lovense API. This assumes you have the Lovense browser extension installed on the browser running this code (i.e. the streamer). When the streamer updates their toy, a callback comes in and fires off an update to store those settings in the DB.
Here's where it gets tricky... The settings are a JSON object that has a levels
key, and a special
key. Here's an example of a default level:
{"levels":{
{"level1":{"max":9,"min":1,"time":5,"rLevel":0,"vLevel":3}
...
}}
Now, when parsing this with Crystal, it's really easy!
# parse the JSON
device_settings = JSON.parse(lovense_settings)
# iterate over the levels
device_settings["levels"].as_h.each do |name, info|
name #=> "level1"
info["min"].as_i #=> 1
info["max"].as_i #=> 9
end
With access to the data, I just needed a bit more info. Reviewing the original request, what I want is:
userXYZ tipped 1 token. Low vibrations for 5 seconds.
I can see from the JSON that I have a time
key which gives me the number of seconds. I can also see the vLevel
(vibrate level) is set to 3 which is a "low" number. I was going to need a helper method for that:
private def toy_strength_level_type(level : Int32) : String
case level
when .<= 5
"Low"
when .<= 10
"Medium"
when .<= 15
"High"
when .<= 20
"Ultra"
else
"Special"
end
end
Finding the specific level looked a bit like this:
selected_level = device_settings["levels"].as_h.find do |name, info|
info["min"].as_i <= token_count && info["max"].as_i >= token_count
end
Putting this together, I got:
"#{context.username} tipped #{pluralize(token_amount, "token")}. #{toy_strength_level_type(selected_level["vLevel"].as_i)} vibrations for #{pluralize(selected_level["time"].as_i, "second")}."
Problem 1
It turns out that when a streamer changes a setting, their extension saves the value as a String
. This isn't an issue for dynamic languages like javascript, but with static languages (especially Crystal), this doesn't quite work. Basically, the settings now looked like this:
{"levels":{
{"level1":{"max":9,"min":"3","time":5,"rLevel":0,"vLevel":"5"}
...
}}
Notice min
is "3"
instead of 3
, and vLevel
is "5"
instead of 5
. How this affects Crystal is like this:
# runtime exception can't cast String to Int32
selected_level["min"].as_i
# we can't cast right to integer if it's a string
min = selected_level["min"].as_i? || selected_level["min"].as_s?.try(&.to_i)
but now min is nilable... we can't assume it'll always be an integer, and we can't assume that casting it to a string and converting to an integer will always work. So for now, I just default to 0
. This also means we need to do it for max
, and vLevel
, and time
, and rLevel
...
min = selected_level["min"].as_i? || selected_level["min"].as_s?.try(&.to_i) || 0
max = selected_level["max"].as_i? || selected_level["max"].as_s?.try(&.to_i) || 0
time = selected_level["time"].as_i? || selected_level["time"].as_s?.try(&.to_i) || 0
yeah, you see where I'm going. It's not pretty. (and yes, I did abstract to a helper method... but still...)
Problem 2
I didn't notice this the first time (or second time) through the API, but max
on the last level actually returns the value "infinity"
lol. It took deploying to production before I realized that one.. oops!
# it's a string, so it casts, but it's not
# a number, so it doesn't convert.
# runtime exception
selected_level["max"].as_s?.try(&.to_i)
max = selected_level["max"].as_i?
if !max
if str_val = selected_level["max"].as_s?
# downcase just in case it comes through as Infinity
if str_val.downcase == "infinity"
# if I'm expecting a number, what do I default to?
else
str_val.to_i
end
end
end
Problem 3
Remember I mentioned there's 2 main keys, levels
, and special
? The levels are standard for the toy; buzz this hard for this long. The specials, however, are more like "Earthquake" which will buzz in a square wave or sine wave pattern, or "RandomTime" which could be between 1 and 40 seconds. There's several others including "Clear" which will clear out the entire queue blowing out any tips previous users have sent. Devious.... But here's the kicker... None of the specials have min
or max
. Their structure looks like this:
{"wave":{"time":30,"token":"160"},
"clear":{"time":0,"token":1000},
"twowaves":{"time":"0","token":""}
Now we have a whole new structure. I have to find the special that matches the token count exactly. Also notice that the token
key can be a String, Integer, or an empty String. time
can also be 0 which doesn't necessarily mean "0 seconds" because randomTime
comes through as a time of 0. This also means I need to change how the message displays.
It'll look more like:
userXYZ tipped 160 tokens. Wave vibration for 30 seconds.
or
userXYZ tipped 1000 tokens. Clear vibration.
Problem 4
At this point, like most devs, we see a time to refactor. This code has become way too messy (and has tanked several production deploys due to not catching runtime edgecases...), so we can make it a lot cleaner.
Crystal comes with a Serializable module that allows you to create an object that receives some JSON, and normalizes all of the data in to easily callable objects.
I took the time, refactored, wrote specs, and smiled at how much nicer all of the code looked. My model had this change:
- column device_settings : JSON::Any = JSON::Any.new({} of String => JSON::Any)
+ column device_settings : LovenseSettingsSerializer = LovenseSettingsSerializer.new, serialize: true
Now with that field being an object I can throw methods on to, it looked more like:
# so clean!
selected_action = device_settings.match?(token_count)
selected_action.name #=> "Wave"
selected_action.time #=> 30
But why is this a problem? Well.... We're using Apollo GraphQL on the front-end. Apollo likes to fire off the mutations/queries (updateDeviceSettings
in this case) when it sees that some value/variable has changed. We were checking to see if the settings we got back from Lovense were different from the settings we stored. If they were, then fire off this mutation to store the updated settings.
Guess what? Since we normalized the data, we stored all Integer values, but the data coming from Lovense was a mix.... This meant the values were always different. I'm sure you can see what this problem was. We were running this update basically as fast as the machine could process it in an infinite loop.
Problem 5
As it turns out, if the streamer goes in to their Lovense toy settings, there's a section called "Chat notifications". They have an option to turn on and customize. In the javascript API, there's a postMessage
callback which receives a text message from Lovense with these events. When you receive these, you can send them in to your websocket channel or whatever you need.
Once that was figured out, we noticed we had 2 toy notifications. The one solved by all of this complicated code, and the one that Lovense actually does automatically for you with all of (and more) the same information....
The solution
Rip out all the code, undo all the commits, and tell the streamers to just enable it if they want through their settings.
The lesson
Read docs, explore, test test test, and remember that programming is hard.
Top comments (0)