Building My Own Smart Roller Shades

A few weeks after moving into a new apartment, I started getting annoyed by a minor yet recurring problem: having to raise and lower my shades every morning and night. Wouldn't it be great if they did that on their own?
Around the same time, I came across the AXIS Gear. For $250 a pop, it promised to retrofit your existing dumb shades and bring them to the world of IoT. Outfitting the two shades in my room would cost $500, and retrofitting the whole apartment would approach $3,000, so going down the DIY route started to make sense. It also sounded much more fun and rewarding.
After several months of designing circuits, 3D-printing mechanical parts, writing firmware, and integrating with Google Assistant, I had a working system. I called it Shado.
A note on timing: I originally built Shado between 2019 and 2020 and revisited it intermittently afterward. It worked well enough to control the shades, though I never completed the final mechanical revision needed for long-term reliability before moving out of the apartment.
What I wanted to build
The core requirement was simple: build a contraption to pull on the existing beaded chain of my roller shade without modifying the shade itself. For it to be truly useful, it also needed to:
- Lift the largest shade in my room without stalling and in under 30 seconds.
- Include physical controls so anyone could operate it.
- Connect to Wi-Fi without hardcoded credentials.
- Support control from a browser and Google Assistant.
- Fit into a wall-mounted enclosure I could print at home.
- Be adaptable to the other, slightly different shades in the apartment.
How Shado works
At a high level, each shade is driven by a geared DC motor connected to a custom ESP8266-based controller. A 3D-printed drive wheel pulls the shade's existing beaded chain, with the motor and PCB held against the wall by a custom printed enclosure.
The controller accepts input from two physical buttons or receives a command over Wi-Fi via MQTT. A cloud service provides browser-based controls and integrates with Google Assistant, allowing a voice command to travel from Google to the service, down to the device, and ultimately to the motor.
The project thus broke down into four major pieces:
- A motor and drive-wheel assembly strong enough to move the shade.
- A custom circuit board to control it.
- Firmware for motor control, Wi-Fi provisioning, and communication.
- A web service that controls the device and connects it to Google.
Choosing the motor
Choosing the right motor required estimating both the force needed to move the shades and the desired speed. A rough estimate would be good enough — I did not have a spring scale, so I tied a pair of cheap earbuds around the chain and hung plates from some dumbbells I had lying around.

By attaching progressively heavier weights, I could get a rough sense of the force needed to open the shade. Each plate weighed in at around , and it turned out that I needed 1–2 of them to start lifting the smaller shade, and 2–3 for the larger one. Converting to metric, the heaviest case was equivalent to the weight of roughly , or about . I treated that as the practical lower bound for choosing the motor.
For the drive mechanism, I estimated an effective wheel diameter of roughly and about of chain travel from fully closed to fully open.
While half a minute to pull the shades up was acceptable, I wanted to set a more aggressive design target of 15 seconds. Given a drive wheel of diameter , the motor must complete revolutions to pull centimeters of chain. Therefore, its required speed is:
As for the torque:
Plugging that in, I could compute the required mechanical output power to be:
These numbers were theoretical, with additional margin needed for gearbox and motor losses, starting friction, and variations in shade resistance. Still, they gave me a useful starting point for choosing a motor and power supply. After spelunking on Amazon, I found a promising candidate: a uxcell worm gear motor (screenshot) for ~$20.
On paper, it had these specs:
- — though this was the no-load speed, so the actual speed would be lower.
- of stall torque — about 50% above my estimate, although stall torque is not directly comparable to the torque available at speed.
- DC, which meant I would need a buck converter for the ESP8266.
- Reasonable dimensions for the gearbox, and an shaft that the drive wheel could fit onto.
With a suitable motor in hand, I could now turn my attention to the mechanical assembly.
Building the drive mechanism
A few months ago, I came across a 3D printer on Marketplace for just shy of $100: the New Matter MOD-t. I had fun making shot glasses and 3D-printed figures, but it was time to start putting it to something useful. The filament it came with was PLA, which was a bit too malleable for the parts that were supposed to handle torque. ABS might have been a better mechanical choice, but the toxic fumes didn't seem that appealing for home printing—and the MOD-t didn't support ABS or PETG anyway. Looking for the toughest PLA available, I came across PolyMax PLA, which I figured I could use for the drive wheel. Then, for the enclosure, I ordered some cheaper Amazon Basics filament.
It was then time to brush up on my 3D modeling skills. Although I had some experience with SolidWorks and AutoCAD from first-year engineering courses, I had not touched 3D-modeling software since. After seeing the benefits of infrastructure as code, I knew I would enjoy a tool that let me generate and refine 3D models programmatically. OpenSCAD seemed perfect. I could write and iterate on my designs in code, generate an STL file, open it in UltiMaker Cura, and then export G-code to the printer.
Designing the drive wheel
The drive wheel was the first part I started with, and I knew I'd need to go through several iterations. My first design was a sprocket of sorts that had just the right grooves to grip the metal beads.
And thanks to WebGL, here's that same interactive model:
My 3D printer was essentially unsupported, but thanks to a Reddit community, and a bit of experimentation, I was able to get it up and running.

Suffice it to say, it took a few tries to find the right printer settings, notch spacing, and tolerance to get a drive wheel that gripped both the shaft and chain reliably enough for testing. Eventually, I had something workable.

Designing the electronics
With the motor and drive wheel selected, I needed a control system that could do three things: run the motor in either direction, accept commands over the internet, and support a pair of physical open and close buttons.
For the controller, I decided to use an ESP8266, a small, Wi-Fi-enabled chip often available for under $2 online. To make it easier on myself, I went with a Wemos D1 mini development board, which exposed 11 digital I/O pins and one analog input, and included a Micro-USB connector for flashing it.
And with that, I had my first electrical challenge. The motor ran at and required significant current, while the ESP8266 operated at . I wanted the whole device to use a single power adapter, so I needed a way to efficiently produce a stable supply from the motor's input.
Powering the controller
I initially explored the LM1117 linear voltage regulator, but it became quite hot while dissipating the excess voltage as heat. A buck converter was the better choice, and so I got one working with an TL2575-33IN and an inductor, diode, and capacitors to step the voltage down more efficiently.
Reversing the motor
The next problem was controlling the motor and its direction. The ESP8266 could not drive it directly. Its GPIO pins provide only low-power control signals, while the motor required substantially more voltage and current. For that, I needed an H-bridge. I began by experimenting with one built from N- and P-channel MOSFETs, but after blowing a few out while running the motor, I caved and bought the L6203 integrated full-bridge driver.
I combined the regulator and motor driver on perfboard and powered them with a 12 V, 5 A AC-to-DC adapter connected through a barrel jack. At last, I had an initial prototype:
Tracking the shade position
The prototype worked, and a basic program could now run the motor in either direction. Holding it against the beaded chain, I noticed that the motor slowed down a fair bit under load. The controller also needed a way to know how far the shade had moved, both to report its position and to know when to stop.
To add this position tracking, I integrated a small magnet into the drive wheel. With a Hall-effect sensor taped to the base of the motor, the controller could detect each revolution of the drive wheel.
From hand-wired prototype to PCB
After a few trips to Jameco Electronics near San Mateo, I had what seemed like a viable design. Putting it all together, the control circuit looked something like this:
My perfboard prototype worked, but it was bulky, difficult to reproduce if I wanted more than one, and frankly a bit unsightly. It was time to take things to the next level.
I had always pictured custom PCBs as being out of reach for all but the most well-funded projects. So when I discovered that I could have five boards fabricated and delivered within a few days for $20, I jumped at the opportunity. There are several PCB manufacturers on the market, but I went with PCBWay. (If you haven't seen it already, check out this tour of their Shenzhen factory.)
Schematic and board layout
The first step in creating a custom PCB was designing the schematic with the right tool. At the time, I got started with Autodesk EAGLE (now Fusion 360), but the newer versions of KiCad would have also worked. After a lot of pointing and clicking, and with the help of SparkFun's libraries, I had a presentable design.
The next part was the board layout. Following some tutorials and videos online, I arranged all the parts, routed them together with traces and vias (it's like solving a puzzle!), and validated the board for errors.
Sending it to the fab
With the design ready, it was time to have the boards fabbed. Exporting the Gerber files from Fusion 360, I sent them to PCBWay. Someone reviewed them, and just shy of 24 hours later I was notified that the boards I requested were ready to be shipped. The whole process was astoundingly fast.

It also didn't take long to get the boards delivered, and they looked phenomenal.

Assembling the board
Compared with the perfboard, the PCB was much easier to work with. The parts fit onto the board like a glove, and I just had to carefully solder them in place and clip off excess leads.
Wiring the remaining components
The final step was connecting the board to the remaining components: the motor, Hall effect sensor, and physical buttons. Since I wanted to adjust those more easily, I used a Dupont connector kit with a crimping tool to set up headers and removable wires.
Designing the enclosure
With the drive mechanism working and the PCB dimensions finalized, I could finally design the enclosure that would house everything in a neat package. This was not just for aesthetics: I also needed a way to mount the assembly on the wall while keeping the beaded chain under the right amount of tension.
Firing up OpenSCAD, I started by figuring out how to get the motor's unwieldy shape into a chassis that could hold it. I also needed a place for the PCB, so I built a small platform with grooves that it could slide into.
To print it, I split the chassis into smaller parts and superglued them together. This avoided supports, which were error-prone and increased both print time and filament usage.
For the wall mount, I designed a base plate with a dovetail groove. A matching insert on the underside of the chassis allowed it to slide into place—try rotating the model above to see it. After mounting the base plate, I could still adjust the chain tension by placing small trapezoidal spacers in the groove to change the chassis offset.
To finish up the enclosure, I added a chamfered removable shell with integrated button caps (and an interior mount for the switches), an opening for an LED, and an inset logo.
It was time to get everything printed and glued. As always, it took a few attempts (and misprints) to get it right, but I could now move on to writing some code.
Writing the firmware
Compared with the mechanical and electrical work, writing code was much more familiar territory, though previous embedded systems projects had taught me that it would have its own challenges. Developing the firmware was largely iterative, but the final architecture looked like this:
Adding cooperative multitasking
The ESP8266 runs on a single 80 MHz core with limited resources (~80 KB of available RAM). Despite that, it had to manage the motor, maintain its cloud connection, and remain responsive to physical controls. A single monolithic loop would quickly become difficult to manage, and integrating the TaskScheduler library kept the different tasks responsive while leaving the main loop dead simple:
void loop() { runner.execute(); }
Controlling and tracking the shades
At the end of the day, the controller's primary function was to operate the motor, so getting that working was my first order of business.
While the Hall-effect sensor provided the raw signal, it wasn't enough to just read the A0 analog pin and compare it against a fixed threshold.
In practice, I found the magnetic field strength to be quite sensitive, and to make the encoder more robust I implemented self-calibration. Here's how it worked on initial setup:
- The firmware runs the motor for 60 steps while recording the minimum and maximum 10-bit ADC values from the sensor.
- To calculate hysteresis, it sets the detection threshold to 1/3rd of the way between those minimum and maximum values.
- To complete alignment, the motor reverses to the baseline position, and the firmware saves the calibrated threshold to EEPROM.
void Motor::CalibrateFindSensorRange() {
if (calibration_counter_++ < kCalibrationSteps) {
unsigned int encoder_value = analogRead(HALL_EFFECT_SENSOR_PIN);
calibration_min_ = std::min(calibration_min_, encoder_value);
calibration_max_ = std::max(calibration_max_, encoder_value);
return;
}
// Calculate dynamic threshold
encoder_threshold_ = calibration_min_ + (calibration_max_ - calibration_min_) / 3;
state_ = MotorState::kCalibratingAligningMotor;
}
During operation, the motor task polls A0 every 50 ms, tracking rising threshold crossings and combining them with the motor direction to estimate the shade's current position.
Refinements
One practical refinement I had to make was handling motor acceleration. Starting the heavy worm gear motor at 100% duty cycle produced sudden torque spikes that wore down the drive wheel, so I updated the firmware to ramp up the PWM duty cycle gradually, reducing the mechanical shock at startup.
Another refinement was reducing flash wear. Microcontroller flash has finite write endurance, and an erase operation affects an entire 4 KB sector, so frequently updated values need to be stored carefully. The Wi-Fi X.509 certificates and private keys for AWS are large and seldom changed compared with values such as motor position and calibration data. I therefore stored the latter separately with rotation-based partitioning to reduce writes.
Provisioning Wi-Fi credentials
Connecting the ESP8266 to a hardcoded Wi-Fi network is quite easy, but I set out to make a complete end-to-end onboarding flow.
The idea was that an end user would visit a website (and perhaps eventually use an app) that would guide them through connecting the device to Wi-Fi and pairing it with their account.
To support that flow, the device started in access-point mode and hosted a small web server with a few endpoints (see ConfigPortal).
GET/kIndexContent). Redirects foreign host requests to http://shado.local.GET/shado_infoWiFi.scanNetworks() and returns JSON containing the device MAC address and nearby SSIDs.POST/shado_configssid, pw) and AWS IoT parameters (ep, cert_crt, cert_pk), saves them to EEPROM, and shuts down AP mode.OPTIONS*shado.osv.im), returning 204 No Content with CORS headers.*NotFound302) to http://shado.local.With the controller connected to Wi-Fi, it was time to tackle the next part of the puzzle: the Internet of Things.
Connecting it to the cloud
Going into this project, I wanted more exposure to AWS. Building a web service that could manage accounts, devices, commands, and state ultimately required a fair amount of infrastructure. By the time I was done with the Terraform config, it looked something like this:
To share a few details on infrastructure choices:
- 100% serverless: At least initially, I expected low utilization: each shade would open or close only a few times per day. Using Lambda, API Gateway, and CloudFront kept the monthly infrastructure bill close to $0.
- NoSQL: DynamoDB's
PAY_PER_REQUESTmodel fit the sparse device-to-user mapping workload and required no database maintenance. - AWS IoT Core: Persistent MQTT avoided polling, while Device Shadows separated the requested state from the state eventually reported by the device.
- AWS Cognito: Although many libraries make it possible to build authentication yourself, Cognito handled session management and provided an OAuth flow for Google Home account linking.
I wouldn't necessarily make all the same choices for a long-lived product, but they let me explore AWS while keeping both development time and operating costs low.
Registering a device
With the infrastructure in place, it was time to get the entire onboarding flow working end-to-end. Earlier on, I decided not to take shortcuts by hardcoding Wi-Fi credentials and, perhaps to my own detriment, I knew I wasn't about to do the same with AWS IoT Core.
AWS IoT Core handles authentication using client certificates, and a common pattern is for devices to come preinstalled with a certificate.
An alternative is to leverage AWS IoT fleet provisioning, which enables just-in-time registration.
This was a new pattern at the time, and as part of this, I contributed the aws_iot_provisioning_template resource to the AWS Terraform provider.
The details are in iot_client.cpp, but at a high level:
- The user signs up through the web portal and enters their Wi-Fi credentials.
- The web app calls
POST /provision_devicethrough API Gateway, which creates a short-lived provisioning claim certificate. - The web app asks the user to connect to the device's access-point network, then
POSTs the credentials and claim to/shado_config. - The device restarts, connects to Wi-Fi, and synchronizes its clock through NTP. It then connects to AWS IoT Core over MQTT and exchanges the claim for a permanent certificate through the
$aws/certificates/create/jsontopic. - The device publishes its serial number to
$aws/provisioning-templates/shado_fleet/provision/json. AWS IoT Core then executes theshado_fleettemplate, registers an IoT Thing, attaches theShadoDevicePolicy, and links it to the Cognito principal. - On subsequent boots, the device connects using its permanent certificate, updates its shadow to
online: true, and listens for commands.
Here's the full sequence diagram
To detect abrupt disconnects, the device publishes an MQTT Last Will and Testament message. An AWS IoT rule consumes that message and updates the Device Shadow to online: false.
Connecting Google Home
The final milestone was integrating with Google Home and Assistant via Cloud-to-cloud. After going through a brief developer registration process, Shado could now appear under "Works with Google" in the Google Home app. Selecting it started the OAuth 2.0 Authorization Code flow, with Cognito handling most of the work.
Following a voice command end to end
With the account linked, the web service now had to react to events from Google Home.
The interface is elegant: Google Home calls a single POST /smarthome endpoint implemented here, and the backend implements a few intents:
action.devices.INTENT.SYNC(Discovery)
BLINDS type with OpenClose trait), adding the shades to the Google Home app.action.devices.INTENT.EXECUTE(Control)
openPercent and updates the AWS Device Shadow (desired state). An MQTT delta message triggers the ESP8266 to spin the motor to the target position.action.devices.INTENT.QUERY(Status Check)
getThingShadow() from AWS IoT Core and returns the current reported.openPercent position and online status to Google Assistant.Getting this working wasn't difficult, and this codelab was a great reference.
How it turned out
This is a long write-up, so here is the video again:
By the end, I had a working end-to-end system. Looking back, it was a rewarding project that touched nearly every layer of the engineering stack:
- 3D CAD & mechanics: OpenSCAD modeling, drive-wheel design, and a wall-mounted enclosure
- Electrical engineering: custom PCB, L6203 motor driver, and Hall-effect rotation sensing
- Embedded firmware: cooperative C++ task scheduling, dynamic sensor calibration, access-point onboarding, and MQTT/IoT
- Cloud infrastructure: Terraform IaC, API Gateway v2, Lambda, and IoT Core
- Application and integrations: AWS Cognito, a React dashboard, and Google Home
At the same time, I had a few setbacks on the way:
- My 3D printer produced plenty of failed prints, and some of the larger parts took hours to reprint.
- During IoT fleet provisioning, serializing a 4 KB certificate payload into a JSON object wreaked havoc on the ESP8266's limited heap and occasionally caused crashes.
- My MQTT client also kept disconnecting — turns out the receive buffer was too small.
- AWS Cognito didn't provide a server-side way to resolve an Identity Pool ID from a User Pool profile. As a result, Google Assistant requests could not look up a user's devices until I cached the Identity ID in a custom user attribute during web login.
What I would change
While a working prototype was rewarding, there were a few notable issues:
- The enclosure did not keep the beaded chain perfectly aligned with the drive wheel, so over time, the lateral force gradually pulled the wheel along the shaft until it slipped off. A revised enclosure or a shaft collar could have kept it in place.
- Although it concealed all the components, the enclosure was bulky. A redesigned PCB or a lower-profile motor could have reduced its footprint.
- The motor was quite loud. A slower, quieter mode would have been useful for morning wakeups.
These days, I’d probably build around Matter, integrate with Home Assistant, and experiment with OpenClaw for natural-language control.
Source code and project files
The full source code, PCB files, OpenSCAD models, firmware, and Terraform configuration are available in the Shado repository on GitHub.