I wanted to move a robot arm from my phone. Not from a laptop sitting on the same desk, and not through a web dashboard I'd have to build, host and then log into - from the app I already have open all day, from anywhere.
WhatsApp turns out to be a surprisingly good control surface. Everyone already has it. It's authenticated. It works over cellular from another building. And the chat history doubles as a log of every command you've ever sent the robot.
That's the entire project: you text a robot arm, and it moves.
The hardwareThe arm is a myCobot 280 X Edition from Elephant Robotics - 850g, 280mm reach, 6 DOF, 250g payload, +/-0.5mm repeatability. Small enough to share a desk with my laptop, but a real 6-DOF collaborative arm rather than a toy. One USB cable is the whole setup.
Two ESP32s run it: an M5Stack Basic at the base, and an Atom at the wrist with an RGB LED on it. Remember that LED - it comes back later.
How it worksFour stages, and only one of them is code I wrote:
Phone (WhatsApp)
|
WhatsApp servers
|
Python bridge on a PC <- the part I built
|USB serial
myCobot 280 X EditionThe bridge logs into WhatsApp as a linked device - the same mechanism as WhatsApp Web - reads incoming messages, parses them into commands, and drives the arm over serial.
Logging into WhatsApp from PythonThe library is neonize, Python bindings around whatsmeow, a Go implementation of the WhatsApp Web protocol. It handles pairing and hands you event callbacks:
client = NewClient("session.sqlite3")
@client.event(MessageEv)
def on_message(cli, event):
text = event.Message.conversation
chat = event.Info.MessageSource.Chat
cli.send_message(chat, f"got: {text}")
client.connect()Run it, a QR code prints in your terminal, you scan it with your phone, and the session is saved to a SQLite file. You only ever scan once.
At least, that's how it's supposed to go.
The trap that cost me an afternoonThis is the part I'd want to read if I were you.
I scanned the QR with my iPhone's camera app. WhatsApp opened, showed a linking screen, then told me "Couldn't link device - check your internet connection" and asked me to scan again. Then again. Then again.
My internet was fine. I turned on whatsmeow's own debug logging, and five seconds after a scan that had visibly succeeded, this appeared:
Recv <notification type="companion_reg_refresh">
<companion_reg_refresh/>
</notification>
Unhandled notification with type companion_reg_refresh
Send <ack class="notification" type="companion_reg_refresh"/>The server had accepted my scan and replied with a notification type whatsmeow doesn't implement. The library acknowledges it, discards it, never emits pair-success, and rotates to the next QR code - which the phone reports as failure.
The fix is almost insultingly simple: don't scan with the camera app. The QR encodes a wa.me/settings/linked_devices URL, so pointing a phone camera at it makes the OS open that link, which kicks off a different linking flow. Scan it from inside WhatsApp instead - Settings, Linked Devices, Link a Device - and it pairs first try.
If you search this error you'll find it described as a total pairing outage affecting every unofficial WhatsApp client. It isn't - at least not through the in-app scanner.
Parsing the messagesThe parser is deliberately forgiving, because people type on phones with autocorrect and stray punctuation:
"home" -> preset pose
"up 40" -> relative move, 40mm
"move 120 0 80" -> absolute xyz
"grip open" -> gripper
"grab" / "drop"-> aliases for the same
"where" -> report position
"dance" -> routineAnything it doesn't recognise returns None and is ignored in silence, so the bot is usable in a chat where you also talk normally. Bad numbers, though, always produce an error message rather than a silent default - "up abc" tells you why it failed.
Driving the armpymycobot gives you two ways to aim it, and choosing the right one per task makes everything downstream simpler.
# joint space: the six joint angles, directly
mc.send_angles([0, -40, 0, -45, 0, 45], 30)
# cartesian: where the gripper should be, arm solves the angles
mc.send_coords([214, -60, 100, -178, 1, -134], 30, 1)Angles are predictable and always reachable, but you have to know the pose. Coordinates are better when you're thinking about the world rather than the arm - "come straight down 150mm" is one number in Cartesian space and a nasty piece of trigonometry in joint space.
Poses are easiest to capture by hand. The arm backdrives, so you release the servos, drag it where you want, and read the six angles back. Every named position in this project was taught that way, not calculated.
One design decision worth explaining: a single worker thread owns the serial port. neonize dispatches every incoming message on its own thread, and pyserial is not safe to hit from several threads at once. So message handlers only push Command objects onto a queue, and one thread drains it. As a bonus, a slow ten-second move can't stall message reception - the reply just tells you you're second in line.
Not letting strangers drive your robotThe bot runs as a linked device on a WhatsApp account, which means it sees every message that account receives. That deserves more thought than it first appears to.
● The sender whitelist fails closed. An empty list rejects everyone, rather than defaulting to open.
● Rejections are silent. An unknown number gets no reply at all, so the bot never advertises that it exists.
● Every joint angle is clamped to the arm's limits (+/-165 degrees) before it goes out - cheap insurance against a typo in a text message.
● 'stop' jumps the queue and aborts a running routine between steps.
Making it danceRoutines are just lists of poses with a speed for each:
ROUTINES = {
"dance": [
([ 0, -20, -20, 0, 0, 0], 50),# stand up
([ 40, -30, -25, 0, 30, 0], 70),# lean right
([-40, -30, -25, 0, -30, 0], 70), # lean left
...
([ 0,0, 0, 0, 0, 0], 50),# home, take a bow
],
}And this is where that LED earns its place. The arm has no screen facing you and no speaker - the Atom's RGB LED is the only feedback channel it has. It sits green while idle, then cycles a colour on every dance step and returns to green when it's done. It's the difference between "the script is running" and "the script is doing something."
The cube sits at a fixed spot, so the sequence is fixed too - and it's a good illustration of using both coordinate systems for what each is good at:
1. move to the approach pose (joint angles)
2. descend to z=100(cartesian, z only)
3. close the gripper
4. ascend back to travel height(cartesian, z only)
5. slide across to y=60(cartesian, y only)
6. descend to z=100(cartesian, z only)
7. open the gripper - cube in box
8. ascend clearThe approach pose is a taught set of joint angles. Everything after it is Cartesian and changes exactly one axis at a time, which keeps the wrist orientation fixed and makes each move a straight line I can predict by eye.
There's a guard in front of it: if the configured pick height isn't actually below the travel height, it refuses to run rather than driving the gripper somewhere unexpected. A 'descend' that turns out to be an ascent means your numbers don't match reality, and finding that out through a servo pressing into a table is the expensive way to learn it.
What I'd do next● Wait for arrival properly. send_coords is fire-and-forget, so right now the code waits a fixed interval and hopes. Polling until the arm actually stops would make long moves reliable.
● Camera instead of a fixed cube position - the obvious upgrade, and the point at which this stops being a demo.
● Voice notes. WhatsApp delivers them as audio; transcription would make 'pick up the cube' work as a spoken sentence.
● A different end effector. With 250g of payload and LEGO Technic mounts at both ends, swapping the gripper is a five-minute job.
Interested on the product ?Click to find out more details on myCobot 280 X Edition Pi
Share your innovative Elephant Robotics projects with our community and join our User Case Initiative today: https://www.elephantrobotics.com/en/call-for-user-cases-en/








Comments