Cheekmate - a Wireless Haptic Communication System
2022-11-15 | By Adafruit Industries
License: See Original Project Motors Wearables
Courtesy of Adafruit
Guide by Lady Ada
Overview
Social media is abuzz lately over the prospect of cheating in tournament strategy games. Is it happening? How is that possible with officials watching? Could there be a hidden receiver somewhere?
We’ll investigate this by making a simple one-way hidden communicator using Adafruit parts and the Adafruit IO service. Not for actual cheating of course!
CONTENT WARNING: Bottom portion of this guide shows raw meat.
Parts
The project requires a soldering iron and related paraphernalia, and the following Adafruit items:
Adafruit DRV2605L Haptic Motor Controller - STEMMA QT / Qwiic
Lithium-Ion Polymer Battery Ideal for Feathers - 3.7V 400mAh
Hardware
For expediency, we’ll make an assumption that only one-way communication is needed. In tournament games like chess, the current state of the board is projected for all to see. An observer accomplice in the spectator gallery (or off-site if streamed) could do the work of feeding game state to an AI engine, then relaying moves to the player. Technically there’s nothing preventing input and two-way communication for solo use, but this muddies the waters for testing the core idea.
An Adafruit QT Py ESP32-S2 provides the brains. Inexpensive, incredibly tiny, and has built-in Wi-Fi. This can communicate with a mobile hotspot (e.g., cell phone with “Wi-Fi tethering” feature) carried by the accomplice theorized above.
How to communicate to the player? A graphical display is right out, as are visible LEDs and audible speakers. It must be silent, but deadly to one’s opponent. So, we’ll use the same sort of tiny vibration motor that’s in your mobile phone. A small driver board accompanies this, as the motor requires more current than can be driven directly from a microcontroller pin.
Such a receiver needs to be discreet…watches or jewelry are too conspicuous (and might not be allowed by tournament rules). It must be concealable, perhaps inside a shoe or under one’s armpit. These body parts are naturally prone to sweat, suggesting some kind of moisture-proof enclosure.
Social Media Internet Cops keep DEMANDING that we warn people this doesn't have a flared base. We don't know what they are imagining people are going to do with this project???
These soda bottle preforms were left over from a prior project. They’re waterproof and practically indestructible…they’ve taken a pounding and we’ve never wrecked ’em. The smooth shape glides easily into…a back pocket. Similar capsules can be found on Amazon, eBay, etc.
Circuit
Here’s a schematic view of the parts laid out for clarity. In physical reality, the microcontroller and battery charger boards are soldered back-to-back with headers to all pins. The motor controller has identical connectors on either end…it doesn’t matter which way you stick it in.
And the actual physical circuit. Battery wires are doubled back to fit all parts down the tube:
The interior of the tube is tapered slightly, and it was necessary to sand about 1/8" width from the motor driver to make it fit down in the narrow end. Best done on the edge with the motor connections, as the other edge sits close to a PCB trace.
The vibration motor is taped to the haptic controller board, and some craft foam is inserted alongside to keep these firmly pressed against the tube body to better conduct the vibration.
A 100 mm STEMMA cable gives enough slack that the motor and controller can stay put while other parts are removable to access the power switch or for charging and uploading code.
Once capped, the whole circuit is well protected from the elements!
If expanding on this project to add outside sensor or tactile inputs, one could incorporate a cable gland to maintain a tight seal.
Adafruit IO Setup
We’ll use Adafruit IO as a backend, its simplicity is a huge asset to this project. If you’ve not used the service, head to the Welcome to Adafruit IO guide for an explainer and to set up an account. The basic service is free and private!
So, let’s assume at this point you have an account set up and are at the io.adafruit.com home page…
Create a New Feed
Feeds provide the conduit for getting data to devices like our receiver unit.
From the navigation bar second to top, select “Feeds,” and then “New Feed.”
Give the feed a useful name (e.g., “Cheekmate” to match this project) and click the “Create” button. You’ll now see it in a list of feeds (or as the sole feed, if first time using the service).
Note the “Key” name assigned to the feed; typically, a lowercase version of the feed name you entered. This key is needed later when setting up the code…or return to the Feeds form later to get it when needed.
Create a New Dashboard
A dashboard provides a user interface for entering data into the above feed.
Click “Dashboards” from the navigation bar, and then “New Dashboard,” assign it a name (this can be the same as the feed if you want), and “Create.”
The dashboard now appears in a list (or as the sole dashboard to start). Click the dashboard name in the list and we’ll create a simple form for entering messages…
Add a Text Field
Our new “Cheekmate” dashboard is initially blank. Near the top right of the form, click the gear icon to pop open the Dashboard Settings menu. Select the “Create New Block” item to add a UI element…
Choose the simple Text block — it provides a single-line field for entering text, that’s all we need here.
You’ll be asked to connect this to a feed (a destination to which any text entered in the field will be sent). Select the “Cheekmate” feed created earlier (or whatever name you chose), and then the “Next step” button.
Now you can customize the look a little, like selecting the Large font so it’s easy to use the dashboard from a mobile phone. Click “Create block” when it’s all to your liking.
Optional but recommended: from the Dashboard Settings menu, select “Edit Layout” to adjust the size or position of the text field so it’s easier to tap. Click “Save Layout” when done.
Adafruit IO Username and Key
This information is needed later when setting up the code.
Click the Key icon near the top right of the main Adafruit IO page to access your Adafruit IO key.
This is a seemingly random long sequence of letters and numbers that uniquely identifies you to the system and will be inserted into the project code to grant it access.
Never share this key. If you post project code on GitHub or similar, remember to strip it out before committing.
CircuitPython Code
Code for this project is available both for CircuitPython and for Arduino; you can use one or the other, whichever is more your programming style. Arduino is on the next page, CircuitPython is below.
If you’ve not used CircuitPython before, begin with the Welcome to CircuitPython guide which will walk you through downloading and installation.
Click the “Download Project Bundle” button below to get all the library files packed in along with the project’s main code.py file. You will still need to create a secrets.py file with Wi-Fi and Adafruit IO credentials, explained later on this page.
Otherwise, if you want to assemble things manually, the project requires the following CircuitPython libraries, which can be found in the library bundle matching the version of CircuitPython you’re using:
adafruit_drv2605.mpy
adafruit_io
adafruit_minimqtt
adafruit_requests.mpy
neopixel.mpy
These go inside the lib folder on the CIRCUITPY drive. “.mpy” items are individual files, others require the full folder.
# SPDX-FileCopyrightText: Adafruit Industries # # SPDX-License-Identifier: MIT """ CHEEKMATE: secret message receiver using WiFi, Adafruit IO and a haptic buzzer. Periodically polls an Adafruit IO dashboard, converting new messages to Morse code. secrets.py file must be present and contain WiFi & Adafruit IO credentials. """ import gc import time import ssl import adafruit_drv2605 import adafruit_requests import board import busio import neopixel import socketpool import supervisor import wifi from adafruit_io.adafruit_io import IO_HTTP try: from secrets import secrets except ImportError: print("WiFi secrets are kept in secrets.py, please add them there!") raise # CONFIGURABLE GLOBALS ----------------------------------------------------- FEED_KEY = "cheekmate" # Adafruit IO feed name POLL = 10 # Feed polling interval in seconds REPS = 3 # Max number of times to repeat new message WPM = 15 # Morse code words-per-minute BUZZ = 255 # Haptic buzzer amplitude, 0-255 LED_BRIGHTNESS = 0.2 # NeoPixel brightness 0.0-1.0, or 0 to disable LED_COLOR = (255, 0, 0) # NeoPixel color (R, G, B), 0-255 ea. # These values are derived from the 'WPM' setting above and do not require # manual editing. The dot, dash and gap times are set according to accepted # Morse code procedure. DOT_LENGTH = 1.2 / WPM # Duration of one Morse dot DASH_LENGTH = DOT_LENGTH * 3.0 # Duration of one Morse dash SYMBOL_GAP = DOT_LENGTH # Duration of gap between dot or dash CHARACTER_GAP = DOT_LENGTH * 3 # Duration of gap between characters MEDIUM_GAP = DOT_LENGTH * 7 # Duraction of gap between words # Morse code symbol-to-mark conversion dictionary. This contains the # standard A-Z and 0-9, and extra symbols "+" and "=" sometimes used # in chess. If other symbols are needed for this or other games, they # can be added to the end of the list. MORSE = { "A": ".-", "B": "-...", "C": "-.-.", "D": "-..", "E": ".", "F": "..-.", "G": "--.", "H": "....", "I": "..", "J": ".---", "K": "-.-", "L": ".-..", "M": "--", "N": "-.", "O": "---", "P": ".--.", "Q": "--.-", "R": ".-.", "S": "...", "T": "-", "U": "..-", "V": "...-", "W": ".--", "X": "-..-", "Y": "-.--", "Z": "--..", "0": "-----", "1": ".----", "2": "..---", "3": "...--", "4": "....-", "5": ".....", "6": "-....", "7": "--...", "8": "---..", "9": "----.", "+": ".-.-.", "=": "-...-", } # SOME FUNCTIONS ----------------------------------------------------------- def buzz_on(): """Turn on LED and haptic motor.""" pixels[0] = LED_COLOR drv.mode = adafruit_drv2605.MODE_REALTIME def buzz_off(): """Turn off LED and haptic motor.""" pixels[0] = 0 drv.mode = adafruit_drv2605.MODE_INTTRIG def play(string): """Convert a string to Morse code, output to both the onboard LED and the haptic motor.""" gc.collect() for symbol in string.upper(): if code := MORSE.get(symbol): # find Morse code for character for mark in code: buzz_on() time.sleep(DASH_LENGTH if mark == "-" else DOT_LENGTH) buzz_off() time.sleep(SYMBOL_GAP) time.sleep(CHARACTER_GAP - SYMBOL_GAP) else: time.sleep(MEDIUM_GAP) # NEOPIXEL INITIALIZATION -------------------------------------------------- # This assumes there is a board.NEOPIXEL, which is true for QT Py ESP32-S2 # and some other boards, but not ALL CircuitPython boards. If adapting the # code to another board, you might use digitalio with board.LED or similar. pixels = neopixel.NeoPixel( board.NEOPIXEL, 1, brightness=LED_BRIGHTNESS, auto_write=True ) # HAPTIC MOTOR CONTROLLER INIT --------------------------------------------- # board.SCL1 and SDA1 are the "extra" I2C interface on the QT Py ESP32-S2's # STEMMA connector. If adapting to a different board, you might want # board.SCL and SDA as the sole or primary I2C interface. i2c = busio.I2C(board.SCL1, board.SDA1) drv = adafruit_drv2605.DRV2605(i2c) # "Real-time playback" (RTP) is an unusual mode of the DRV2605 that's not # handled in the library by default, but is desirable here to get accurate # Morse code timing. This requires bypassing the library for a moment and # writing a couple of registers directly... while not i2c.try_lock(): pass i2c.writeto(0x5A, bytes([0x1D, 0xA8])) # Amplitude will be unsigned i2c.writeto(0x5A, bytes([0x02, BUZZ])) # Buzz amplitude i2c.unlock() # WIFI CONNECT ------------------------------------------------------------- try: print("Connecting to {}...".format(secrets["ssid"]), end="") wifi.radio.connect(secrets["ssid"], secrets["password"]) print("OK") print("IP:", wifi.radio.ipv4_address) pool = socketpool.SocketPool(wifi.radio) requests = adafruit_requests.Session(pool, ssl.create_default_context()) # WiFi uses error messages, not specific exceptions, so this is "broad": except Exception as error: # pylint: disable=broad-except print("error:", error, "\nBoard will reload in 15 seconds.") time.sleep(15) supervisor.reload() # ADAFRUIT IO INITIALIZATION ----------------------------------------------- aio_username = secrets["aio_username"] aio_key = secrets["aio_key"] io = IO_HTTP(aio_username, aio_key, requests) # SUCCESSFUL STARTUP, PROCEED INTO MAIN LOOP ------------------------------- buzz_on() time.sleep(0.75) # Long buzz indicates everything is OK buzz_off() current_message = "" # No message on startup rep = REPS # Act as though message is already played out last_time = -POLL # Force initial Adafruit IO polling while True: # Repeat forever... now = time.monotonic() if now - last_time >= POLL: # Time to poll Adafruit IO feed? last_time = now # Do it! Do it now! feed = io.get_feed(FEED_KEY) new_message = feed["last_value"] if new_message != current_message: # If message has changed, current_message = new_message # Save it, rep = 0 # and reset the repeat counter # Play last message up to REPS times. If a new message has come along in # the interim, old message may repeat less than this, and new message # resets the count. if rep < REPS: play(current_message) time.sleep(MEDIUM_GAP) rep += 1
secrets.py
If you’ve previously worked with CircuitPython Wi-Fi projects, you might already have this file on the drive, or another CircuitPython board. If not, it’s easy enough to create anew. Using your text editor of preference, create a new file on the CIRCUITPY drive, called secrets.py.
Copy and paste the following exactly as it is, as a starting point:
secrets = { 'ssid' : 'wifi_network_name', 'password' : 'wifi_password', 'aio_username' : 'adafruit_io_username', 'aio_key' : 'adafruit_io_key' }
This is a list of Python 'key' : 'value' pairs. Do not edit the keys (the part before the colon : on each line), just the values, being careful to keep both 'quotes' around strings and the comma at the end of each line.
Replace wifi_network_name and wifi_password with the name or “SSID” of your wireless network and the password for access. If tethering from a phone, one or both might be auto generated…this information will be somewhere in the phone settings. Only 2.4 GHz networks are supported; 5 GHz is not compatible with ESP32.
Replace adafruit_io_username and adafruit_io_key with your name and unique key as explained on the “Adafruit IO Setup” page.
Arduino Code
The Arduino version of the code does essentially the same thing; you can use one or the other, whichever is more your programming style.
This requires the Adafruit DRV2605 and Adafruit IO libraries. Installing these using the Arduino Library Manager is recommended, as it will take care of all prerequisites: Sketch→Include Library→Manage Libraries…
There are two files in this project. One contains the bulk of the code, the other has configurable settings such as the Wi-Fi network name and password, plus the Adafruit IO account credentials and feed name. You’ll need to edit the latter file (config.h) with all your particulars…it’s all named descriptively and should be clear what goes where. Make sure the correct board type is selected before uploading.
You can either download a ZIP with both files:
Download Arduino “Cheekmate” Code
Or here they are in line for your perusal:
// SPDX-FileCopyrightText: Adafruit Industries // // SPDX-License-Identifier: MIT /* CHEEKMATE: secret message receiver using WiFi, Adafruit IO and a haptic buzzer. Monitors an Adafruit IO feed, converting new messages to Morse code. WiFi & Adafruit IO credentials are in the accompanying config.h file. */ #include <AdafruitIO_WiFi.h> #include <Adafruit_NeoPixel.h> #include <Adafruit_DRV2605.h> #include "config.h" // SET UP WIFI AND ADAFRUIT IO CREDENTIALS HERE AdafruitIO_WiFi io(IO_USERNAME, IO_KEY, WIFI_SSID, WIFI_PASS); AdafruitIO_Feed *feed = io.feed(FEED_NAME, FEED_OWNER); Adafruit_NeoPixel led(1, PIN_NEOPIXEL); Adafruit_DRV2605 drv; char message[51]; int rep = REPS; // Act as though message is already played out // Runs once at startup void setup() { Serial.begin(115200); led.begin(); led.setBrightness(LED_BRIGHTNESS); led.show(); // Wire1 is the "extra" I2C interface on the QT Py ESP32-S2's // STEMMA connector. If adapting to a different board, you might // want &Wire for the sole or primary I2C interface. drv.begin(&Wire1); drv.writeRegister8(0x1D, 0xA8); // Amplitude will be unsigned drv.setRealtimeValue(BUZZ); feed->onMessage(handleMessage); // Set up message handler for feed Serial.print("Connecting to Adafruit IO"); io.connect(); while(io.status() < AIO_CONNECTED) { // Wait for connection Serial.write('.'); delay(500); } Serial.println(io.statusText()); buzz_on(); delay(750); // Long buzz indicates everything is OK buzz_off(); } // Runs repeatedly until reset or power-off void loop() { io.run(); // Must periodically call Adafruit IO event manager // Play last message up to REPS times. If a new message has come // along in the interim, old message may repeat less than this, // and new message resets the count. if (rep < REPS) { play(message); delay(MEDIUM_GAP); rep++; } } // Turn on LED and haptic motor void buzz_on() { led.setPixelColor(0, LED_COLOR); led.show(); drv.setMode(DRV2605_MODE_REALTIME); } // Turn off LED and haptic motor void buzz_off() { led.setPixelColor(0, 0); led.show(); drv.setMode(DRV2605_MODE_INTTRIG); } // Convert a string to Morse code, output to both the onboard LED // and the haptic motor. void play(char *str) { while(char c = toupper(*str++)) { // Upper-caseify each character of string... int i=0; // Scan Morse dictionary (in config.h) for a match for (; i<NUM_SYMBOLS && morse[i].symbol != c; i++); if (i < NUM_SYMBOLS) { // Found one! char mark; for (int j=0; (mark = morse[i].mark[j]); j++) { buzz_on(); delay(mark == '-' ? DASH_LENGTH : DOT_LENGTH); buzz_off(); delay(SYMBOL_GAP); } delay(CHARACTER_GAP - SYMBOL_GAP); } else { // Not in dictionary, prob. a space delay(MEDIUM_GAP); } } } // Called when feed receives a message. void handleMessage(AdafruitIO_Data *data) { // Limit incoming message to fit char buffer + NUL strncpy(message, data->toChar(), sizeof message - 1); Serial.printf("Received '%s'\n", message); rep = 0; // Reset the message repeat counter }
// SPDX-FileCopyrightText: Adafruit Industries // // SPDX-License-Identifier: MIT #define WIFI_SSID "your_wifi_ssid" #define WIFI_PASS "your_wifi_password" // visit io.adafruit.com if you need to create an account, // or if you need your Adafruit IO key. #define IO_USERNAME "your_io_username" #define IO_KEY "your_io_key" #define FEED_OWNER "feed_owner_name" #define FEED_NAME "cheekmate" #define REPS 3 // Max number of times to repeat new message #define WPM 15 // Morse code words-per-minute #define BUZZ 255 // Haptic buzzer amplitude, 0-255 #define LED_BRIGHTNESS 50 // NeoPixel brightness 1-255, or 0 to disable #define LED_COLOR 0xFF0000 // NeoPixel color (RGB hexadecimal) // These values are derived from the 'WPM' setting above and do not require // manual editing. The dot, dash and gap times are set according to accepted // Morse code procedure. #define DOT_LENGTH 1200 / WPM // Duration of one Morse dot #define DASH_LENGTH (DOT_LENGTH * 3) // Duration of one Morse dash #define SYMBOL_GAP DOT_LENGTH // Duration of gap between dot or dash #define CHARACTER_GAP (DOT_LENGTH * 3) // Duration of gap between characters #define MEDIUM_GAP (DOT_LENGTH * 7) // Duraction of gap between words // Morse code symbol-to-mark conversion dictionary. This contains the // standard A-Z and 0-9, and extra symbols "+" and "=" sometimes used // in chess. If other symbols are needed for this or other games, they // can be added to the end of the list. const struct { char symbol; const char *mark; } morse[] = { 'A', ".-", 'B', "-...", 'C', "-.-.", 'D', "-..", 'E', ".", 'F', "..-.", 'G', "--.", 'H', "....", 'I', "..", 'J', ".---", 'K', "-.-", 'L', ".-..", 'M', "--", 'N', "-.", 'O', "---", 'P', ".--.", 'Q', "--.-", 'R', ".-.", 'S', "...", 'T', "-", 'U', "..-", 'V', "...-", 'W', ".--", 'X', "-..-", 'Y', "-.--", 'Z', "--..", '0', "-----", '1', ".----", '2', "..---", '3', "...--", '4', "....-", '5', ".....", '6', "-....", '7', "--...", '8', "---..", '9', "----.", '+', ".-.-.", '=', "-...-", }; #define NUM_SYMBOLS (sizeof morse / sizeof morse[0])
Testing and Analysis
When powered on, the device will take perhaps 20 seconds to connect to the wireless network and authenticate with Adafruit IO. On success it will emit a single long buzz and light the onboard LED.
If you do not get this buzz: there’s an issue with the Wi-Fi or Adafruit IO credentials, or the wiring between board and motor driver. Connecting the board to USB and watching with the Arduino serial monitor or other serial tool (e.g., Tio or screen) will give some indication of where the problem lies.
So, let’s say at this point you’re buzzed and working…
Return to the “Dashboards” tab of Adafruit IO and pick your Cheekmate dashboard from the list.
Type a brief message in the text field and press return or click or tab out of the field.
Within a few seconds, this should be relayed to the device, which will start to flash and buzz with a Morse code version of the message.
The message will repeat up to three times, unless a new message is received during that time, in which case the current message finishes and the new one repeats three times.
Meat and Greet
So, we know the code and device work in open air, but what about in a hypothetical use case? There are two things to find here:
Bodies are mostly water, and RF energy is greatly attenuated in water. Can signals penetrate if the device is nestled in, say, one’s armpit?
Once surrounded by flesh, is the vibration motor sufficiently muffled to avoid detection, or does it give away the gag?
Without a willing partner to test and record findings with, it seemed most objective to use a proxy with similar characteristics…like a quantity of meat. Initial plan was to shove the device between two large hams, but it turns out ham is really expensive in the off season.
Pound for pound, bone-in pork butt roast is quite affordable!
A channel was cut through the middle, into which the device was firmly lodged.
In Action
The unit was powered on, sealed, and inserted. A Wi-Fi access point was about 30 feet away, through two walls and a couple inches of meat now. The end cap did protrude slightly, so it’s not a perfect test for Wi-Fi penetration, but fixing this would require a bigger butt roast.
Secret messages were then entered in the project’s Adafruit IO Dashboard. Here’s what happened:
Analysis
While not a thoroughly scientific test, it does shine a light on the tenable aspects of the cheat device theory:
The circuit, and the internet dashboard, were both incredibly simple to build and code; it does not require extensive engineering skills. The hardest parts would be a bit of soldering and memorizing Morse code.
Wi-Fi had no problem penetrating at this distance and through this medium. If an internet connection can be established through an accomplice, and data relayed through wireless, messages can be relayed.
However, working against it…
The vibration motor, even when muffled through pounds of flesh, is anything but subtle. Officials or other players would be immediately aware. The vibration could be dialed down to a calmer level, but risks messages not being interpreted clearly as they’re harder to sense.
Thus, a reasonable conclusion is that such an idea is plausible, but unlikely. With refinement, a more discreet device could surely be developed…but, with the risk still present of being discovered, banned from competition, and being the butt of jokes for generations to come. One’s time is likely better spent learning and practicing game strategy.
A series of escalating measures and countermeasures come to mind, and it’s not clear there’s any real endgame to this.
Metal detectors are already in use at some events, but these are usually calibrated to ignore small nuisance items like coins or keys…a well-crafted receiver might slip through.
Blocking wireless signals would seem an obvious choice…but FCC laws prevent this. A deep-pocketed tournament might manage this by hosting events offshore, beyond Federal jurisdiction. Alternately, players might compete inside a Faraday cage, Thunderdome-style.
These measures might still be circumvented by eliminating the off-site component, with self-contained game AI carried on one’s person. A Raspberry Pi Zero would be a bit of a stretch…but devices are continually getting smaller and more powerful, and soon (if not already) something could tuck into one’s navel or another cavity.
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.
Visit TechForum