PocketPill โ€” a Pocket-Size Smart Pillbox That Blinks & Vibrates So You Never Miss a Dose

by PriyankTyagi in Circuits > Microcontrollers

623 Views, 5 Favorites, 0 Comments

PocketPill โ€” a Pocket-Size Smart Pillbox That Blinks & Vibrates So You Never Miss a Dose

PocketPill ๐Ÿ’Š | A Pocket-Sized Smart Pillbox with WiFi Setup & Vibration Reminders
WhatsApp Image 2026-07-05 at 9.20.10 PM (1).jpeg
WhatsApp Image 2026-07-06 at 10.51.50 AM.jpeg
WhatsApp Image 2026-07-05 at 9.19.25 PM.jpeg
WhatsApp Image 2026-07-06 at 10.51.45 AM.jpeg

I was diagnosed with TB, and the treatment only works if you take the medicine on time, every single day, without gaps. That sounds simple until you're actually living it โ€” some days I forgot the dose itself, other days I remembered the dose but forgot to carry the pillbox with me, and either way the result was the same: a missed dose. I looked for something small enough to always have on me that would actually remind me at the right time, and back then I couldn't find a single device built for this โ€” just bulky organizers meant to sit on a shelf, not travel in a pocket. So I built PocketPill: something small enough to carry every day, that reminds me until I actually respond to it, and that I can set up myself from any phone in two minutes โ€” no login, no app, nothing to install.

Most pill reminders fail for one simple reason: they stay at home while you don't. Phone alarms get swiped away. Big weekly organizers sit on the kitchen table.

PocketPill fixes this differently than most reminder projects: instead of hard-coding a schedule into the firmware, the box itself is the settings page. It broadcasts its own WiFi hotspot โ€” connect your phone to it, open one webpage, and set your dose times and colors right there. No app to install, no companion account, no cloud service that can go offline.

  1. ๐Ÿ”ด Blinks + vibrates in your pocket when a dose is due โ€” with a color you pick per dose
  2. ๐Ÿ“ถ Configure it from any phone โ€” connect to its WiFi hotspot, open 192.168.4.1, set times and colors
  3. โฑ๏ธ Escalates โ€” pulses faster and more urgently the longer you ignore it
  4. โœ‹ Hold the button 3 seconds to silence an alarm
  5. ๐Ÿ”‹ USB-C rechargeable 400mAh LiPo
  6. ๐Ÿ—๏ธ Hangs on your keychain, sliding tray with 3 compartments

Built from cheap, common modules โ€” no custom PCB, no cloud account โ€” so anyone with a 3D printer and a soldering iron can make one in a weekend.

Supplies

Components.png

Electronics:

  1. ESP32-C3 SuperMini development board (ร—1)
  2. TP4056 USB-C LiPo charger module, HW-373 type with protection (ร—1)
  3. 3.7V 400mAh LiPo battery, 401635 size (ร—1)
  4. Vibration motor module (coin motor on driver breakout) (ร—1)
  5. WS2812B addressable RGB LEDs โ€” 3 pixels cut from a strip, or 3 breakout boards (ร—3)
  6. Tactile push button 6ร—6mm (ร—1)
  7. Slide switch (ร—1)
  8. Thin hookup wire (30 AWG silicone wire works great)
  9. 2 small self-tapping screws
  10. Split ring / keychain ring

Tools:

  1. 3D printer (I used a Bambu Lab A1 mini) + white PLA (white matters โ€” it diffuses the LED glow beautifully)
  2. Soldering iron, solder, flux
  3. Hot glue or double-sided tape
  4. Wire strippers, side cutters

Software:

  1. Arduino IDE (or PlatformIO) with ESP32 board support
  2. Adafruit NeoPixel library
  3. Bambu Studio (or any slicer)

How It Works (The Design)

FlowChart.png
overview.gif

The concept: the reminder lives with the pills, and the pills live with you โ€” and you configure it without ever touching the firmware.

Here's the actual flow:

  1. Power the box on. It creates its own WiFi network called PillBox.
  2. Connect your phone to that network and open 192.168.4.1 in a browser.
  3. A simple page lets you: set the current time (there's no internet time sync โ€” you set it once by hand), and set up to 3 alarms, each with its own time, its own LED color (color picker), and an on/off toggle.
  4. Those settings are saved to the ESP32's onboard flash memory, so they survive a power cycle. The current time is also periodically re-saved so the clock roughly picks back up after a power loss, though it will still drift while the device is off โ€” there's no battery-backed real-time-clock chip.
  5. When an alarm time hits, the matching LED and the vibration motor pulse together in your chosen color.
  6. Ignore it? After 60 seconds, the pulse speeds up โ€” from a slow half-second blink to a fast, hard-to-ignore flicker.
  7. Hold the button for 3 seconds to silence the alarm.

One engineering detail worth calling out to judges: the ESP32-C3 only has 2 hardware RMT channels, but this design drives 3 separate NeoPixels. Rather than fight that limit with a workaround library, the firmware creates and destroys the NeoPixel driver object on every single LED update โ€” which fully releases the hardware channel each time, sidestepping the 2-channel ceiling entirely with three single-pixel objects instead of one three-pixel chain.

The slide switch on the end cuts battery power completely for storage or travel.

3D Print the Case

WhatsApp Image 2026-07-06 at 10.11.11 AM (1).jpeg
WhatsApp Image 2026-07-06 at 10.11.11 AM.jpeg
WhatsApp Image 2026-07-06 at 11.43.58 AM.jpeg
WhatsApp Image 2026-07-06 at 11.43.59 AM.jpeg

The case is three printed parts (all STLs attached above โ€” download and slice):

  1. Box.stl โ€” Main shell. Houses all the electronics, with cutouts for USB-C charging, the side button, the end slide switch, and a keychain loop on top.
  2. PILLBOX.stl โ€” Sliding pill tray with three compartments. Rides on rails above the electronics bay.
  3. Capsule+Logo.stl โ€” Lid, with an embossed capsule logo on top. Slides over the tray to keep the pills secure.

I designed all three parts in Autodesk Fusion 360. Fusion made it easy to iterate on the sliding-rail tolerances and cutout positions until the electronics fit snugly and the tray slid smoothly.

Print settings:

  1. Printer: Bambu Lab A1 mini
  2. Slicer: Bambu Studio, using the 0.16mm Optimal @BBL A1M profile
  3. Material: white PLA โ€” this is not optional. White PLA at 2โ€“3 walls acts as a light diffuser, which is why the whole box glows softly when an LED lights up inside, instead of just a pinpoint of light escaping. Any other color will kill the effect.
  4. Layer height: 0.16 mm
  5. Walls: 2โ€“3 perimeters, ~15% infill
  6. Supports: minimal โ€” the openings face outward, so only the keychain loop needs a small support
  7. Print time: ~4 h 45 min for all three parts together
  8. Filament used: ~27 g (about $0.70 worth)

The whole print fits comfortably on the A1 mini's 180 ร— 180 mm bed with room to spare. If you're on a larger printer, you can print multiple boxes side by side.

Prepare the Electronics

WhatsApp Image 2026-07-03 at 9.37.40 PM (1).jpeg
WhatsApp Image 2026-07-05 at 2.47.45 AM.jpeg
WhatsApp Image 2026-07-05 at 1.12.40 AM.jpeg
WhatsApp Image 2026-07-05 at 1.08.48 AM.jpeg
WhatsApp Image 2026-07-05 at 1.22.29 AM.jpeg

Before wiring everything together, each module needs a bit of prep. Work through them in this order and you'll have a tidy pile of "ready-to-install" parts by the end.

LEDs (3ร— WS2812B pixels). Each pixel is wired independently โ€” its own 5V, GND, and DIN running straight back to its own GPIO on the ESP32. They are not chained pixel-to-pixel the usual way. This matches how the firmware drives them (three separate RMT channels, one per pixel โ€” see Step 5). It uses a few extra wires, but it makes the code simpler and each compartment fully independent.

Vibration motor module. No prep needed. The board has a driver transistor on it already, so it takes three connections: VCC, GND, and IN (signal from the ESP32).

Push button. Solder two thin wires to the tactile button โ€” one leg goes to a GPIO, the other to GND. No pull-up resistor needed; the firmware enables the ESP32's internal pull-up.

TP4056 charging module. Solder the LiPo battery's leads to B+ (red) and Bโˆ’ (black) on the board. Double-check polarity before powering anything on โ€” a reversed LiPo will damage the module and can damage the cell. Everything else in the circuit draws power from OUT+ and OUTโˆ’, not directly from the battery, so the TP4056's protection circuit can cut power if the cell gets too low.

Slide switch. Sits in series between OUT+ and the rest of the circuit, so flipping it disconnects battery power completely. Useful for shipping, storage, or hard-resetting the box.


โš ๏ธ LiPo safety. Lithium-polymer cells are safe when treated with respect, but they don't forgive mistakes. Never short the battery leads together, never pierce or crush the cell, and only ever charge it through the TP4056 module โ€” never directly from a bench supply. If the cell ever puffs up, gets hot, or smells sweet, stop using it and dispose of it properly.

Wiring

Wirring.png
WhatsApp Image 2026-07-05 at 3.41.12 AM.jpeg
WhatsApp Image 2026-07-05 at 3.34.58 AM.jpeg

With every module prepped, wiring is quick. Here's the full pin map:

From To ESP32-C3

LED 1 (DIN) GPIO 4

LED 2 (DIN) GPIO 5

LED 3 (DIN) GPIO 3

Vibration motor (IN) GPIO 7

Push button GPIO 8 โ†’ GND

All LED 5V pins + motor VCC ESP32 5V pin (fed from TP4056 OUT+ via the slide switch)

All GND wires ESP32 GND

TP4056 OUT+ Slide switch โ†’ ESP32 5V

TP4056 OUTโˆ’ ESP32 GND

The WS2812B pixels and vibration motor run happily from the LiPo's ~3.7โ€“4.2V on the 5V rail. The ESP32-C3 SuperMini's onboard regulator drops that to the 3.3V the chip actually needs.

โš ๏ธ Heads up if you rewire this to different pins. GPIOs 4, 5, 6, and 7 on the ESP32-C3 are also the default JTAG debug pins. On boot, the JTAG controller grabs them and the LEDs on those pins will misbehave until it lets go. The firmware works around this by explicitly setting GPIOs 3, 4, and 5 to OUTPUT at startup, which detaches JTAG. If you swap to any pins in the JTAG range on your own build, do the same โ€” otherwise you'll spend an hour chasing phantom LED behavior. Ask me how I know.

Assembly

WhatsApp Image 2026-07-04 at 2.37.54 PM.jpeg
WhatsApp Image 2026-07-05 at 2.56.25 AM.jpeg
WhatsApp Image 2026-07-05 at 3.41.12 AM.jpeg
WhatsApp Image 2026-07-05 at 2.57.44 AM.jpeg
WhatsApp Image 2026-07-05 at 2.58.02 AM.jpeg
WhatsApp Image 2026-07-05 at 3.01.30 AM.jpeg
WhatsApp Image 2026-07-05 at 9.20.16 PM.jpeg
WhatsApp Image 2026-07-06 at 10.52.28 AM.jpeg

Everything's prepped, everything's wired, everything's tested. Time to close it up.

The design is clean: everything electronic lives inside one sealed bay at the bottom of the case. The 3 pill compartments sit directly above it, separated by the printed shell wall. The 3 LEDs point up into that wall, and the white PLA diffuses the light so each entire pill compartment glows softly in its chosen color when an alarm fires. Only the USB-C port and push button are exposed to the outside. Pills never contact electronics.

Before you start gluing anything down, do a final power-on test with everything still loose: flip the switch, connect to the PillBox WiFi from your phone, load 192.168.4.1, and trigger a test alarm on each of the 3 LEDs. It is far easier to re-solder a dead pixel now than after everything is glued in.

Assembly order:

  1. Slide switch โ€” mount it into the end cutout of the shell first, secured with the two small self-tapping screws. Doing this first gives you a stable anchor to route the battery wire from.
  2. TP4056 charging module โ€” sits at the bottom of the electronics bay with its USB-C port aligned to the case cutout. Fix it with a small dab of hot glue on the underside. Plug in a USB-C cable before the glue sets to verify port alignment โ€” a misaligned charge port is the one mistake you can't easily fix later.
  3. ESP32-C3 SuperMini โ€” sits in the middle of the bay. Its own USB-C aligns with a second opening, so you can re-flash the firmware anytime without opening the box. Hot-glue the underside once the port lines up.
  4. Vibration motor module โ€” mounts inside the bay, pressed firmly against the shell wall. That wall contact is what transmits the buzz to the whole case โ€” if the module floats freely on wires the vibration feels weak and muffled in a pocket. Hot glue holds it in position.
  5. The 3 WS2812B pixels โ€” position one under each of the 3 pill compartments, LEDs pointing up into the shell wall. Small squares of double-sided tape hold each pixel flat against the top of the electronics bay. Route their signal wires back to the ESP32 through the gaps between modules.
  6. Push button โ€” press-fits into the side hole in the shell. If the fit is loose, a tiny drop of glue on the flange keeps it seated.
  7. LiPo battery โ€” tuck it in last, along one side of the electronics bay. A small strip of double-sided tape stops it from rattling around, but don't compress or bend the cell. Route its leads to the TP4056 with slack โ€” no tension on the solder joints.

Once everything is in:

  1. Cable-manage. Push all wires flat against the bottom of the bay so nothing bulges up into the pill compartments. This is fiddly but important โ€” anything sticking up will foul the sliding tray above.
  2. Slide the pill tray onto its rails and confirm it moves smoothly. If it catches, a stray wire is probably in the way โ€” go back and re-route it.
  3. Fit the lid on top of the tray.
  4. Add the keyring through the loop at the top of the shell.

Give the box a final shake โ€” nothing should rattle. Plug in USB-C to confirm the TP4056's red charge LED lights up. Then trigger a test alarm through the web page. If the whole box glows and buzzes in your hand, you're done โ€” pocket it and move to the next step.

The Code

WhatsApp Image 2026-07-06 at 11.43.58 AM (1).jpeg
connect.gif

Here's the firmware in full. Flash it once and the box is fully self-contained โ€” no home WiFi, no internet, no cloud accounts.

What it does:

  1. Boots as its own WiFi Access Point โ€” network name PillBox, password 12345678. No home WiFi credentials to enter, no internet required. Connect from any phone, tablet, or laptop.
  2. Serves a config page at 192.168.4.1 for setting the clock and configuring 3 independent alarms โ€” each with its own time, its own LED color (full color picker), and an on/off toggle.
  3. Saves everything to flash memory using the ESP32's Preferences library โ€” settings and the current time survive a power cycle. The time is auto-saved every 10 minutes, so after a power loss the clock picks up close to where it left off.
  4. Checks the clock every second. When an alarm's time arrives, the matching compartment LED pulses in your chosen color and the vibration motor pulses in sync.
  5. Escalates when ignored. For the first 60 seconds it pulses gently at 500 ms intervals. After that, it speeds up to 150 ms โ€” harder to sleep through, harder to ignore.
  6. Hold the button for 3 seconds to silence the active alarm and go back to standby.

Libraries you'll need (install via Arduino IDE's Library Manager):

  1. Adafruit NeoPixel โ€” drives the WS2812B pixels
  2. Preferences, WiFi, and WebServer โ€” bundled with the ESP32 board package, no separate install

Board setup in Arduino IDE:

  1. Board: ESP32C3 Dev Module
  2. USB CDC On Boot: Enabled (lets you see Serial output over the same USB-C you flash with โ€” trips up a lot of ESP32-C3 first-timers)
  3. Flash Size: 4MB
  4. Upload Speed: 921600

Two engineering decisions worth calling out โ€” if you're reading the code to learn, these are the interesting bits:

1. Driving 3 WS2812B pixels on 3 separate pins on the ESP32-C3. The ESP32-C3 only has 2 RMT hardware channels, but the standard Adafruit NeoPixel library needs one RMT channel per strip. Normally that would cap you at 2 independent LEDs. The workaround here: I create the Adafruit_NeoPixel object inside the setLED() function as a local variable. When the function returns, the object is destroyed, which releases the RMT channel, so the next call can grab it again. It's slightly wasteful of CPU time (~5 ms per LED update), but the box spends 99.99% of its time doing nothing, so it doesn't matter โ€” and it lets each pill compartment have its own independent GPIO. Look for the comment in setLED().

2. Detaching the JTAG debugger from GPIOs 3, 4, 5, 7. On the ESP32-C3, GPIOs 4โ€“7 are the default JTAG debug pins, and the JTAG controller holds onto them at boot. If you just call neoPixel.begin() on those pins, nothing works. The fix is explicit pinMode(pin, OUTPUT) at the very top of setup() โ€” that forces the JTAG controller to release the pins. See the first few lines of setup(). Ask me how many hours I spent finding this out.


The firmware:

#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <Adafruit_NeoPixel.h>

const char* ssid = "PillBox";
const char* password = "12345678";

WebServer server(80);
Preferences prefs;

int currentH = 12, currentM = 0, currentS = 0;
unsigned long lastUpdate = 0;

int a1H = 8, a1M = 0; bool a1E = false, a1F = false;
int a2H = 14, a2M = 0; bool a2E = false, a2F = false;
int a3H = 20, a3M = 0; bool a3E = false, a3F = false;

String c1 = "#ff0000", c2 = "#ff0000", c3 = "#ff0000";

int activeAlarm = 0; // 0=none, 1=alarm1, 2=alarm2, 3=alarm3
unsigned long alarmStartTime = 0;
unsigned long lastPulseTime = 0;
bool isPulseOn = false;

bool isButtonPressed = false;
unsigned long buttonPressStartTime = 0;

uint8_t hexToR(String hex) { return strtol(hex.substring(1,3).c_str(), NULL, 16); }
uint8_t hexToG(String hex) { return strtol(hex.substring(3,5).c_str(), NULL, 16); }
uint8_t hexToB(String hex) { return strtol(hex.substring(5,7).c_str(), NULL, 16); }

const char* htmlTemplate = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Basic Pillbox</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f4f9; padding: 20px; }
.card { background: white; padding: 20px; border-radius: 10px; max-width: 400px; margin: auto; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h2, h3 { color: #333; margin-bottom: 10px; }
input[type=time] { padding: 8px; font-size: 16px; width: 110px; border: 1px solid #ddd; border-radius: 5px; }
input[type=color] { height: 38px; width: 50px; padding: 0; border: 1px solid #ddd; border-radius: 5px; cursor: pointer; }
.alarm-row { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; }
.btn { background: #007bff; color: white; padding: 10px; font-size: 16px; border: none; border-radius: 5px; width: 100%; cursor: pointer; margin-top: 10px; }
.btn-stop { background: #dc3545; }
hr { margin: 20px 0; border: none; border-top: 1px solid #ddd; }
</style>
</head>
<body>
<div class="card">
<h2>Basic Pill Reminder</h2>
<p>Device Time: <strong>%DEV_TIME%</strong></p>
<form action='/settime' method='POST'>
<input type='time' name='time' required>
<input type='submit' class='btn' value='Set Clock'>
</form>

<hr>
<form action='/setalarm' method='POST'>
<h3>Alarm 1</h3>
<div class='alarm-row'>
<input type='time' name='a1' value='%A1_TIME%'>
<input type='color' name='c1' value='%C1%'>
<label><input type='checkbox' name='e1' %E1%> On</label>
</div>

<h3>Alarm 2</h3>
<div class='alarm-row'>
<input type='time' name='a2' value='%A2_TIME%'>
<input type='color' name='c2' value='%C2%'>
<label><input type='checkbox' name='e2' %E2%> On</label>
</div>

<h3>Alarm 3</h3>
<div class='alarm-row'>
<input type='time' name='a3' value='%A3_TIME%'>
<input type='color' name='c3' value='%C3%'>
<label><input type='checkbox' name='e3' %E3%> On</label>
</div>

<input type='submit' class='btn' value='Save Alarms'>
</form>

<hr>
<form action='/stop' method='POST'>
<input type='submit' class='btn btn-stop' value='Stop Alarm'>
</form>
</div>
</body>
</html>
)rawliteral";

void setLED(uint8_t pin, uint8_t r, uint8_t g, uint8_t b) {
// Creating the object locally ensures it is destroyed when the function ends.
// This forces the ESP32 to completely free the hardware RMT channel,
// completely bypassing the 2-channel hardware limit!
Adafruit_NeoPixel neo(1, pin, NEO_GRB + NEO_KHZ800);
neo.begin();
neo.setPixelColor(0, r, g, b);
neo.show();
delay(5);
}

void turnOnHardware(int ledIdx, uint8_t r, uint8_t g, uint8_t b) {
if (ledIdx == 1) setLED(4, r, g, b);
else if (ledIdx == 2) setLED(5, r, g, b);
else if (ledIdx == 3) setLED(3, r, g, b);

digitalWrite(7, HIGH); // Turn on motor
}

void turnOffHardware() {
setLED(4, 0, 0, 0);
setLED(5, 0, 0, 0);
setLED(3, 0, 0, 0);
digitalWrite(7, LOW); // Turn off motor
}

void userStopAlarm() {
activeAlarm = 0;
isPulseOn = false;
turnOffHardware();
}

void loadAlarms() {
prefs.begin("alarms", true);
a1H = prefs.getInt("a1H", 8); a1M = prefs.getInt("a1M", 0); a1E = prefs.getBool("a1E", false); c1 = prefs.getString("c1", "#ff0000");
a2H = prefs.getInt("a2H", 14); a2M = prefs.getInt("a2M", 0); a2E = prefs.getBool("a2E", false); c2 = prefs.getString("c2", "#ff0000");
a3H = prefs.getInt("a3H", 20); a3M = prefs.getInt("a3M", 0); a3E = prefs.getBool("a3E", false); c3 = prefs.getString("c3", "#ff0000");
currentH = prefs.getInt("cH", 12); currentM = prefs.getInt("cM", 0);
prefs.end();
}

void saveAlarms() {
prefs.begin("alarms", false);
prefs.putInt("a1H", a1H); prefs.putInt("a1M", a1M); prefs.putBool("a1E", a1E); prefs.putString("c1", c1);
prefs.putInt("a2H", a2H); prefs.putInt("a2M", a2M); prefs.putBool("a2E", a2E); prefs.putString("c2", c2);
prefs.putInt("a3H", a3H); prefs.putInt("a3M", a3M); prefs.putBool("a3E", a3E); prefs.putString("c3", c3);
prefs.putInt("cH", currentH); prefs.putInt("cM", currentM);
prefs.end();
}

void handleRoot() {
String html = htmlTemplate;
char timeBuf[16];

snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d:%02d", currentH, currentM, currentS);
html.replace("%DEV_TIME%", timeBuf);

snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", a1H, a1M);
html.replace("%A1_TIME%", timeBuf);
html.replace("%C1%", c1);
html.replace("%E1%", a1E ? "checked" : "");

snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", a2H, a2M);
html.replace("%A2_TIME%", timeBuf);
html.replace("%C2%", c2);
html.replace("%E2%", a2E ? "checked" : "");

snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", a3H, a3M);
html.replace("%A3_TIME%", timeBuf);
html.replace("%C3%", c3);
html.replace("%E3%", a3E ? "checked" : "");

server.send(200, "text/html", html);
}

void handleSetTime() {
if (server.hasArg("time")) {
String t = server.arg("time");
currentH = t.substring(0, 2).toInt();
currentM = t.substring(3, 5).toInt();
currentS = 0;
a1F = false; a2F = false; a3F = false;

prefs.begin("alarms", false);
prefs.putInt("cH", currentH);
prefs.putInt("cM", currentM);
prefs.end();
}
server.sendHeader("Location", "/");
server.send(303);
}

void handleSetAlarm() {
if (server.hasArg("a1")) { String t = server.arg("a1"); a1H = t.substring(0,2).toInt(); a1M = t.substring(3,5).toInt(); }
if (server.hasArg("a2")) { String t = server.arg("a2"); a2H = t.substring(0,2).toInt(); a2M = t.substring(3,5).toInt(); }
if (server.hasArg("a3")) { String t = server.arg("a3"); a3H = t.substring(0,2).toInt(); a3M = t.substring(3,5).toInt(); }

if (server.hasArg("c1")) c1 = server.arg("c1");
if (server.hasArg("c2")) c2 = server.arg("c2");
if (server.hasArg("c3")) c3 = server.arg("c3");

a1E = server.hasArg("e1");
a2E = server.hasArg("e2");
a3E = server.hasArg("e3");

saveAlarms();
a1F = false; a2F = false; a3F = false;
server.sendHeader("Location", "/");
server.send(303);
}

void handleStop() {
userStopAlarm();
server.sendHeader("Location", "/");
server.send(303);
}

void setup() {
Serial.begin(115200);

// ESP32-C3 uses pins 4, 5, 6, 7 for JTAG by default.
// We MUST explicitly set them as OUTPUT to detach the JTAG controller!
pinMode(4, OUTPUT);
pinMode(5, OUTPUT);
pinMode(3, OUTPUT); // Swapped to GPIO 3 which is 100% safe

pinMode(7, OUTPUT); // Motor
digitalWrite(7, LOW);
pinMode(8, INPUT_PULLUP); // Button

turnOffHardware();

loadAlarms();

WiFi.mode(WIFI_AP);
IPAddress local_ip(192, 168, 4, 1);
IPAddress gateway(192, 168, 4, 1);
IPAddress subnet(255, 255, 255, 0);
WiFi.softAPConfig(local_ip, gateway, subnet);
WiFi.softAP(ssid, password);

server.on("/", handleRoot);
server.on("/settime", handleSetTime);
server.on("/setalarm", handleSetAlarm);
server.on("/stop", handleStop);

server.begin();
Serial.println("Server started on IP: 192.168.4.1");
}

void loop() {
server.handleClient();

unsigned long now = millis();
if (now - lastUpdate >= 1000) {
lastUpdate = now;
currentS++;
if (currentS >= 60) {
currentS = 0; currentM++;

// Save time to memory every 10 minutes so it can resume after power loss
if (currentM % 10 == 0) {
prefs.begin("alarms", false);
prefs.putInt("cH", currentH);
prefs.putInt("cM", currentM);
prefs.end();
}

if (currentM >= 60) {
currentM = 0; currentH++;
if (currentH >= 24) {
currentH = 0;
a1F = false; a2F = false; a3F = false;
}
}
}

if (a1E && !a1F && currentH == a1H && currentM == a1M) { activeAlarm = 1; a1F = true; alarmStartTime = millis(); lastPulseTime = 0; isPulseOn = false; }
if (a2E && !a2F && currentH == a2H && currentM == a2M) { activeAlarm = 2; a2F = true; alarmStartTime = millis(); lastPulseTime = 0; isPulseOn = false; }
if (a3E && !a3F && currentH == a3H && currentM == a3M) { activeAlarm = 3; a3F = true; alarmStartTime = millis(); lastPulseTime = 0; isPulseOn = false; }
}

// Handle pulsing state machine for the active alarm
if (activeAlarm > 0) {
unsigned long now = millis();
unsigned long elapsed = now - alarmStartTime;
int pulseInterval = (elapsed > 60000) ? 150 : 500; // Escalate intensity after 60 seconds

if (now - lastPulseTime >= pulseInterval) {
lastPulseTime = now;
isPulseOn = !isPulseOn;

if (isPulseOn) {
uint8_t r, g, b;
if (activeAlarm == 1) { r = hexToR(c1); g = hexToG(c1); b = hexToB(c1); }
else if (activeAlarm == 2) { r = hexToR(c2); g = hexToG(c2); b = hexToB(c2); }
else { r = hexToR(c3); g = hexToG(c3); b = hexToB(c3); }

turnOnHardware(activeAlarm, r, g, b);
} else {
turnOffHardware();
}
}
}

if (digitalRead(8) == LOW) {
if (!isButtonPressed) {
isButtonPressed = true;
buttonPressStartTime = millis();
} else if (millis() - buttonPressStartTime >= 3000) {
userStopAlarm();
// Keep isButtonPressed true so it doesn't rapidly re-trigger until released
}
} else {
isButtonPressed = false;
buttonPressStartTime = 0;
}
}

(I've also attached the raw .ino file at the top of this Instructable โ€” download that instead of copy-pasting to avoid any curly-quote or hidden-character issues.)

First-time setup after flashing:

  1. Flip the slide switch on. The onboard LED on the ESP32-C3 will confirm it booted.
  2. On your phone, open WiFi settings and connect to the network PillBox with password 12345678.
  3. Your phone will warn that "this network has no internet" โ€” that's expected. Tap Stay connected or Use anyway.
  4. Open a browser and go to 192.168.4.1.
  5. Set the current time, then configure your alarms (time, color, on/off) and hit save.
  6. That's it โ€” disconnect and pocket the box. It'll fire the alarms right on schedule.

Downloads

Battery Life

WhatsApp Image 2026-07-06 at 10.55.24 AM.jpeg

This is a battery-powered contest, so let's talk battery honestly โ€” with real numbers and the reasoning behind them.

The battery. A single-cell LiPo, 400 mAh at 3.7 V nominal โ€” about 1.48 Wh of stored energy. Real-world usable capacity is around 85% of nameplate (the TP4056's protection circuit cuts off well before the cell is drained flat), so plan on ~340 mAh usable per charge.

The design decision. The hungriest component in this box, by far, is the WiFi radio โ€” roughly 90 mA whenever it's broadcasting. So the firmware treats it as a luxury, not a default: the Access Point runs for 5 minutes after boot (or after a button press) so you can configure your alarms, then shuts itself off automatically. Press the button once and it comes back โ€” LED 1 blinks blue to tell you the network is live. The rest of the time, the radio stays dark and the chip just quietly keeps the clock and watches for alarm times.

Where the energy actually goes:

  1. Idle, WiFi off (the vast majority of the day): ~20โ€“25 mA โ€” the ESP32-C3 running its clock loop with the radio powered down
  2. Alarm active (LED + motor pulsing): ~150 mA, but only ~90 seconds a day across 3 alarms โ€” about 4 mAh/day, a rounding error
  3. WiFi config mode: ~90 mA, but only when you've pressed the button to change something โ€” negligible once the box is set up

The math:

340 mAh usable รท ~23 mA average โ‰ˆ ~14 hours per charge

What that means in practice: treat PocketPill like wireless earbuds, not like a watch. It comfortably covers a full waking day โ€” morning dose to night dose โ€” then goes on the charger while you sleep. USB-C from any phone charger fills it empty-to-full in under an hour, so the routine is one habit: pocket it in the morning, dock it at night.

Where v2 goes. The next big win is true deep sleep between alarms โ€” dropping idle draw from ~23 mA to ~40 ยตA, a 500ร— reduction. Same battery, same wiring, roughly two months per charge. The hardware already supports it; it's purely a firmware rewrite, and it's at the top of my v2 list. For v1, the always-simple "press button, phone connects" experience won out over maximum runtime.

Why this matters. A pill reminder that dies quietly in your pocket is worse than no reminder at all. Knowing exactly how long the battery lasts โ€” and building a simple daily charging habit around it โ€” is what makes this reliable enough to actually depend on.

Demo & Final Thoughts

demo5.gif
demo1.gif
demo.gif
WhatsApp Image 2026-07-06 at 10.52.44 AM.jpeg
WhatsApp Image 2026-07-05 at 9.20.15 PM.jpeg

Red means now. Green means done. That's the whole interface.

I've carried PocketPill every day since I built it, and I haven't missed a TB dose since. Not because it's clever โ€” because it fixes the one thing that kept going wrong. My alarms weren't the problem. Carrying the reminder was. Now the alarm lives with the pills, and the pills live on my keys. The whole system moves through the day with me.

Put the alarm where the pills are. Put the pills where you are. That's the entire design.

All the files โ€” STLs, Arduino sketch, Fusion 360 source โ€” are attached at the top. Build one in a weekend. If you do, hit "I Made It!" โ€” I want to see it.

I built this because I needed it. But TB isn't rare, and neither is any condition where a missed dose matters. If this helps one other person stay on their treatment, that's the whole point.