Building a custom ESP32 thermostat
A guided tour through the code of a battery-powered ESP32 thermostat with e-paper display, deep sleep, and remote control
Note: This is a write-up of a project I built about a year ago. I've bundled the KiCad files and code into a single Git repository to make it public. Since it's been a while, there may be minor inconsistencies. Consider this a guided tour rather than a step-by-step tutorial. For uBlock users: This page loads code snippets straight from the GitHub repository.
Introduction
In my precision telescope mount article I mentioned how I got introduced to custom PCB design. This article covers the first PCB I designed: a thermostat that fits inside my existing housing and allows me to control the temperature remotely.
If you're a beginner with Arduino or have just bought your first microcontroller devboard, you'll find some interesting snippets in here. We'll cover GPIO pin selection, button debouncing, reading sensors, drawing on an e-paper display, power management and deep sleep, task scheduling, a serial CLI for debugging, and connecting to your phone with Blynk or Bluetooth.
Since I used a LILYGO TTGO T5 V2.66 (an ESP32 board with a built-in 2.66" e-paper display), this article focuses on the ESP32 platform. However, the concepts apply to any Arduino-compatible microcontroller. The full schematic of that board can be found here
This article won't cover PCB design itself. If you're interested in learning I recommend checking out Phil's Lab, Robert Feranec, or EEVblog on YouTube.
Here's a high level overview of the system:
Firmware Architecture
The firmware can be divided into three major categories:
- Core
- Power management and deep sleep
- Task scheduling
- RTC time keeping
- Serial CLI for debugging
- IO
- Sensor reading (temperature, humidity, battery)
- Display rendering
- Button input handling
- Peripheral control (relay, LED)
- Network
- WiFi connectivity
- Bluetooth Low Energy
- Blynk IoT platform
While a thermostat might seem simple, there are quite a few components that need to work together: input buttons, temperature sensors, an e-paper display, power management, and wireless connectivity. I separated each component into its own namespace.
One of the first questions I usually think about when designing any software is "What is part of the core?" Everything else are just roads leading to Rome. The core should be simple, standalone and agnostic to who or what called it. This allows us to expose our core functionality over any transport such as Bluetooth, a Web UI or a Serial CLI and rely on the same behaviour every time.
Every component has its own namespace. A namespace typically contains configuration variables in the header file and function signatures. I use an init() function to bring up each component, plus whatever other functions the component needs. Here's the Pins namespace as an example:
The ESP32
Choosing pins wisely
Not all ESP32 pins are equal. Some have special functions during boot, others are input-only, and some don't work when WiFi is active.
Strapping pins
The ESP32 samples these pins at power-on to configure boot behavior:
| Pin | Default | Purpose |
|---|---|---|
| GPIO0 | Pull-up | Selects flash boot vs download mode |
| GPIO2 | Pull-down | Used with GPIO0 for download boot |
| GPIO4 | Pull-down | Unused strapping pin |
| GPIO5 | Pull-up | SDIO timing |
| GPIO12 | Pull-down | SDIO voltage level |
| GPIO15 | Pull-up | SDIO mode and boot message output |
I used GPIO0, 2, 12, and 15 for buttons:
static const uint8_t SW2 = 2;
static const uint8_t SW3 = 15;
static const uint8_t SW5 = 0;
static const uint8_t SW6 = 12;
This works because buttons are normally open: they don't affect pin state during boot. After boot completes, these pins function as regular GPIO.
One lesson learned: I initially added a external pull-ups for all buttons including GPIO0, thinking I could achieve higher power effiency using higher resistance external pullups. The problem is that the ESP32 requires the BOOT pin (GPIO 0) to be LOW to enter FLASH mode. With the external pullups this wasn't possible, and I had to unplug the ESP32 board from my PCB every time to program it. Very tedious, so I opted to go with the internal pullups instead which don't interfere with GPIO 0 as a strapping pin.
RTC-capable pins for wake
Most of the time our thermostat is asleep to save battery. But we need to wake up when a button is pressed. This requires RTC-capable pins: they're connected to the RTC (Real-Time Clock) controller which stays powered during deep sleep.
Not all GPIO pins have RTC capability. See the ESP32 GPIO Summary for the full list of which pins support RTC functions.
The ESP32 offers two wake modes:
- ext0: Wake on a single pin. Simple but limited to one button.
- ext1: Wake on multiple pins using a bitmask.
Ideally I'd use ext1 to wake on any button press. The problem is the original ESP32's ext1 only supports two modes:
| Mode | Behavior |
|---|---|
ESP_EXT1_WAKEUP_ANY_HIGH | Wake if any pin goes HIGH |
ESP_EXT1_WAKEUP_ALL_LOW | Wake if all pins go LOW simultaneously |
My buttons are pulled HIGH and go LOW when pressed. ANY_HIGH would trigger immediately since pins are already high. ALL_LOW would require pressing every button at once. Neither works.
Newer chips (ESP32-S2, S3, C6, H2) added ESP_EXT1_WAKEUP_ANY_LOW which would be perfect, but I'm stuck with the original ESP32.
So I ended up using ext0 with a single dedicated wake button:
esp_sleep_enable_ext0_wakeup(static_cast<gpio_num_t>(Pins::SW6), 0);
The thermostat spends most of its time asleep, periodically waking on a timer to check temperatures. When you want to interact with it, you press the wake button to bring the device back to life. The other buttons only work while the device is awake. This constraint shaped the UI: one dedicated "wake" button, with the remaining buttons handling navigation and settings once active.
Alternative approaches I considered but didn't implement:
- Diode-OR circuit: Combine multiple buttons into one ext0 pin using diodes
- ULP coprocessor: Use the Ultra Low Power processor to monitor GPIOs during sleep
- Timer + light sleep: Wake periodically, poll buttons, go back to sleep
Buttons and Input
Reading a button seems simple: check if the pin is HIGH or LOW. But mechanical buttons bounce: a single press can register as multiple rapid on/off transitions. You also need to handle different interaction patterns like long presses and double clicks.
AceButton handles all of this. It's a lightweight library that debounces inputs and detects click, double-click, long-press, and repeat-press events.
Setting up buttons
In init(), each button pin is configured with the internal pull-up resistor. The AceButton constructor takes the pin, its default state (HIGH since we're using pull-ups), and an ID we can use later to identify which button fired:
for (size_t i = 0; i < NUM_BUTTONS; i++) {
pinMode(Pins::BUTTON_PINS[i], INPUT_PULLUP);
buttons[i].init(Pins::BUTTON_PINS[i], HIGH, i);
}
The ButtonConfig object controls which events to detect and their timing. I enabled click, double-click, and long-press detection, with a 2-second threshold for long presses:
ButtonConfig *buttonConfig = ButtonConfig::getSystemButtonConfig();
buttonConfig->setEventHandler(handleEvent);
buttonConfig->setFeature(ButtonConfig::kFeatureClick);
buttonConfig->setFeature(ButtonConfig::kFeatureLongPress);
buttonConfig->setFeature(ButtonConfig::kFeatureDoubleClick);
buttonConfig->setFeature(ButtonConfig::kFeatureSuppressClickBeforeDoubleClick);
buttonConfig->setLongPressDelay(LONG_PRESS_DURATION_MS);
The kFeatureSuppressClickBeforeDoubleClick flag is important: it waits to see if a second click is coming before firing a single-click event. Without it, double-clicking would always trigger a click event first.
Handling events
AceButton calls your handler function whenever something happens. The handler receives the button, event type, and current state:
void handleEvent(AceButton *button, uint8_t eventType, uint8_t buttonState) {
uint8_t buttonId = button->getId() + 1;
switch (eventType) {
case AceButton::kEventClicked:
// Single click logic
break;
case AceButton::kEventDoubleClicked:
// Double click logic
break;
case AceButton::kEventLongPressed:
// Long press logic
break;
}
}
I use a switch on both eventType and buttonId to map each button/gesture combination to an action. AceButton doesn't use interrupts, so you need to call check() regularly in your main loop.
Sensors
The thermostat reads three things: temperature/humidity from a BME680, ambient light from a photoresistor, and battery voltage through a voltage divider.
BME680 and the BSEC library
The BME680 is a combined temperature, humidity, pressure, and gas sensor. Bosch provides the BSEC library which does sensor fusion to calculate an Indoor Air Quality (IAQ) index from the raw readings.
I2C initialization is straightforward:
Wire.begin(Pins::SENSOR_SDA, Pins::SENSOR_SCL);
sensor.begin(BME68X_I2C_ADDR_HIGH, Wire);
BSEC needs a configuration file that matches your power supply voltage (3.3V) and sampling rate. I use the 3-second low-power mode:
const uint8_t bsec_config_iaq[] = {
#include "config/generic_33v_3s_4d/bsec_iaq.txt"
};
sensor.setConfig(bsec_config_iaq);
The tricky part is deep sleep. BSEC expects continuous timing, but millis() resets to zero after each wake. The solution is to save and restore the sensor state to RTC memory:
RTC_DATA_ATTR uint8_t sensorState[BSEC_MAX_STATE_BLOB_SIZE] = {0};
// Before sleep
sensor.getState(sensorState);
// After wake
sensor.setState(sensorState);
This preserves the IAQ calibration across sleep cycles. Without it, the sensor would need to recalibrate from scratch every time, which takes a long time.
Photoresistor with voltage divider
The photoresistor (LDR) is used to detect ambient light levels, useful for adjusting display brightness or detecting if the room is occupied.
An LDR changes resistance based on light: low resistance in bright light, high resistance in darkness. To read this with the ESP32's ADC, we use a voltage divider circuit:
PHRES_EN ── LDR (R9) ──┬── 330Ω (R8) ── GND
│
└── ADC Pin (PHRES_READ)
I added a control pin (PHRES_EN) to power the circuit only when taking readings, saving a few microamps during sleep.
Battery voltage via ADC
LiPo batteries range from about 4.2V (full) to 3.3V (empty), but the ESP32's ADC can only read up to 3.3V. A simple voltage divider cuts the battery voltage in half:
VBAT ── R1 ──┬── R2 ── GND
│
└── ADC Pin (GPIO35)
With equal resistors, a 4.2V battery reads as 2.1V at the ADC pin. The code uses the ESP32's built-in ADC calibration (esp_adc_cal_characterize) for accuracy, then multiplies by the divider ratio.
For converting voltage to percentage, I use a sigmoid curve that models LiPo discharge behavior. This gives a more accurate reading than linear interpolation, since LiPo batteries hold their voltage steady in the middle of discharge and drop quickly at the ends.
E-Paper Display
I experimented with LVGL, a powerful embedded GUI library with widgets, animations, and a visual editor. It's impressive, but for a simple temperature display with a few debug screens, GxEPD2 is much simpler: just draw text and shapes directly.
Display namespace structure
I organized the display code into nested namespaces, each representing a screen:
namespace Display {
void init(bool fullInit = false);
void powerOff();
void clear();
void showText(const char *text, bool partial = false);
namespace Main {
void show();
void showTemperature(bool prepare = false);
void showTargetTemperature(bool prepare = false);
void showHeatingState(bool prepare = false);
}
namespace DebugSensor {
void show();
void showSensorData(const Sensor::Reading &reading);
}
namespace Time {
void show();
}
}
This lets you call Display::Main::show() to render the full main screen, or Display::Main::showTemperature() to update just the temperature.
The prepare pattern
E-paper displays are slow. A full refresh takes 2-3 seconds. To avoid flickering, I batch multiple drawing operations into a single update using a prepare flag:
void Main::show() {
display.setPartialWindow(0, 0, W, H);
display.firstPage();
display.fillScreen(BACKGROUND);
do {
showHeatingState(true); // prepare = true
showTargetTemperature(true);
showTemperature(true);
showTemperatureScale(true);
} while (display.nextPage());
}
When prepare = true, functions only write to the buffer without triggering a display update. The parent show() function handles the actual refresh. But you can also call showTemperature(false) directly for an immediate partial update of just that element.
E-paper quirks
E-paper displays work differently from LCDs:
A full refresh completely redraws the screen with a visible black-white-black flash. It's slow but prevents ghosting. Partial refresh updates only a region and is faster, but leaves faint traces of previous content over time. I do a full refresh on boot, then partial refreshes during use.
GxEPD2 uses a paged drawing approach to save RAM. Instead of buffering the entire display, you draw in a firstPage()/nextPage() loop (as shown in the prepare pattern above). Each iteration processes a portion of the display, so your drawing code runs multiple times and must be deterministic.
Before sleeping, I call display.hibernate() to power down the display controller. This reduces current draw while still preserving the image on the screen.
void powerOff() {
display.hibernate();
}
Custom fonts
GxEPD2 uses Adafruit GFX fonts. I used truetype2gfx to convert a 7-segment style font for the temperature display. The getTextBounds() function is essential for centering text, as it tells you the pixel dimensions before you draw.
Controlling the Heating
Bistable (latching) relays
A bistable relay has two stable states: it stays on or off without continuous power. You pulse the coil briefly to flip it, then it holds its position mechanically. This is perfect for battery operation. Switching takes a few milliseconds of current, then zero power to maintain state. I found out my relay is very particular on the timing for the on and off pulse. To find out the exact timing I hooked the original board up to a small oscilloscope.
DRV8837 H-bridge driver
You need to drive current in both directions through the coil. One polarity sets the relay, the opposite resets it. A single GPIO can't do this.
An H-bridge solves the polarity problem. The DRV8837 is a small motor driver IC that can push current through a load in either direction based on two control pins:
| IN1 | IN2 | Output |
|---|---|---|
| LOW | LOW | Coast (high-Z) |
| LOW | HIGH | Forward |
| HIGH | LOW | Reverse |
| HIGH | HIGH | Brake (both LOW) |
I use three GPIOs: COIL_SET, COIL_RESET, and COIL_ENABLE.
The pulse duration is asymmetric: turning on needs a longer pulse (8ms) than turning off (2ms).
GPIO hold during deep sleep
When the ESP32 wakes from deep sleep, GPIOs can briefly return to their default state before your code runs. This can cause unwanted pulses on the relay coil.
The solution is gpio_hold_en(). It tells the RTC controller to maintain the pin's current state through sleep and wake cycles:
gpio_hold_en((gpio_num_t)Pins::COIL_SET);
gpio_hold_en((gpio_num_t)Pins::COIL_RESET);
gpio_hold_en((gpio_num_t)Pins::COIL_ENABLE);
Before changing the pins, you must disable the hold with gpio_hold_dis(), make your changes, then re-enable it. The relay state is also stored in RTC memory (RTC_DATA_ATTR bool isHeating) so the firmware knows the current state after waking.
Connectivity
The thermostat supports both WiFi and Bluetooth LE. WiFi connects to the Blynk IoT platform for remote control via a phone app. BLE provides a local interface using standard GATT services.
WiFi with static IP
For a battery-powered device, every millisecond of WiFi time costs power. I optimized connection time by using a static IP and providing the exact BSSID and channel of my access point:
WiFi.config(staticIP, gateway, subnet, dns);
WiFi.begin(ssid, password, channel, bssid);
This skips DHCP negotiation and AP scanning. Connection typically completes in under 2 seconds instead of 5-10 seconds.
The codebase also has ArduinoOTA support for over-the-air firmware updates, though I currently have it disabled to save flash space. When enabled, you can upload new firmware over WiFi without physically connecting to the device.
Blynk IoT integration
Blynk is an IoT platform that lets you build a mobile app UI without writing any mobile code. You define "virtual pins" that map to values in your firmware, then drag widgets onto a canvas in their app builder.
On the ESP32 side, you handle reads and writes with macros:
// When the user changes the target temperature slider in the app
BLYNK_WRITE(BLYNK_PIN_TARGET_TEMPERATURE) {
const int value = param.asInt();
Peripherals::Thermo::setTarget(value);
}
// Push current temperature to the app
_Blynk::updateValue(BLYNK_PIN_CURRENT_TEMPERATURE, temperature);
I set up virtual pins for:
- Current temperature and humidity (read-only gauges)
- Target temperature (slider)
- Heating on/off state (indicator)
- A text field for sending arbitrary commands to the display
The free tier has limitations on data frequency, but for a thermostat that updates every few minutes, it's more than enough. The main advantage is getting a working mobile app in minutes rather than building one from scratch.
BLE GATT server
For local control without WiFi, I implemented a BLE GATT server using standard Bluetooth SIG services:
#define ENV_SENSE_SERVICE_UUID "181A" // Environmental Sensing
#define THERMOSTAT_SERVICE_UUID "1822" // Thermostat
#define BATTERY_SERVICE_UUID "180F" // Battery
Using standard UUIDs means any generic BLE scanner app can read the sensor values. The thermostat service exposes writable characteristics for the target temperature:
thermostat.targetTemp = thermostat.service->createCharacteristic(
TARGET_TEMP_UUID,
BLECharacteristic::PROPERTY_READ |
BLECharacteristic::PROPERTY_WRITE |
BLECharacteristic::PROPERTY_NOTIFY);
thermostat.targetTemp->setCallbacks(new ThermostatCallbacks());
When a connected device writes a new value, the callback fires and updates the thermostat setpoint.
While exploring my Samsung phone's Bluetooth settings, I noticed it automatically renders a media player UI when it discovers devices advertising the Audio/Video Remote Control Profile (AVRCP). I considered pretending to be a media device. The seek bar could control temperature, and play/pause could toggle heating. Any phone would get a built-in control UI without installing an app. I ended up using standard thermostat services instead, but it's a fun hack if you want zero-app control.
Power Management
Battery life is critical for a thermostat. The device spends most of its time asleep, waking only to check temperatures, update the display, or respond to button presses.
Deep sleep and RTC memory
In deep sleep, the ESP32 powers down almost everything — CPU, WiFi, Bluetooth, most RAM. Only the RTC (Real-Time Clock) controller stays active, drawing around 10µA. When it wakes, the CPU reboots from scratch.
The problem: normal variables are lost on wake. The solution is RTC_DATA_ATTR, which places variables in the small RTC memory that survives deep sleep:
RTC_DATA_ATTR int bootCount = 0;
RTC_DATA_ATTR bool deepSleepEnabled;
RTC_DATA_ATTR bool isHeating = false;
On each boot, I increment bootCount to distinguish a fresh power-on (count = 1) from a wake (count > 1):
void init() {
bootCount++;
}
bool isFirstBoot() {
return bootCount == 1;
}
This matters because first boot needs full initialization (display refresh, WiFi setup), while wakes can skip straight to the task at hand.
Handling wake events
As covered in the RTC-capable pins section, I use ext0 for button wake and a timer for scheduled wake. When the device wakes, it needs to determine why it woke to decide what to do:
esp_sleep_wakeup_cause_t reason = esp_sleep_get_wakeup_cause();
switch (reason) {
case ESP_SLEEP_WAKEUP_EXT0:
// User pressed the wake button - enable interaction mode
Power::enableInactivityChecking();
break;
case ESP_SLEEP_WAKEUP_TIMER:
// Scheduled wake - check temperature, update display, go back to sleep
break;
}
Timer wake is configured with a target time rather than a duration. The scheduleSleep() function calculates how long to sleep based on the RTC's current time:
void scheduleSleep(uint64_t wakeTimeMs) {
uint64_t now = RTC::getTimestampMs();
uint64_t sleepTime = (wakeTimeMs - now) * 1000ULL; // Convert to microseconds
esp_sleep_enable_timer_wakeup(sleepTime);
goToSleep();
}
Preparing peripherals for sleep
Before sleeping, each peripheral needs proper shutdown to minimize power draw and avoid glitches on wake. The display goes into hibernate mode, WiFi disconnects and powers down the radio, and Bluetooth deinitializes if enabled.
One important detail: rtc_gpio_isolate() on unused RTC pins. Without it, floating inputs cause micro-amp current leakage that adds up over months of battery life.
Inactivity timeout
After user interaction, the device stays awake for 30 seconds to allow further button presses. If nothing happens, it goes back to sleep:
static const unsigned long INACTIVITY_TIMEOUT_MS = 30000;
bool checkInactivity() {
if (!inactivityCheckingEnabled) return false;
return (millis() - lastActivityTime) >= INACTIVITY_TIMEOUT_MS;
}
// Called from button handler
void updateActivity() {
lastActivityTime = millis();
}
Every button press resets the timer with updateActivity(). The main loop checks checkInactivity() and triggers sleep when it returns true.
Task Scheduling
A thermostat needs to do things periodically: check the temperature every few minutes, update the display, maybe sync time with NTP once a day. But most of the time it's asleep. This creates a challenge: how do you schedule tasks when millis() resets to zero on every wake?
Existing scheduling options
The ESP32 runs FreeRTOS under the hood, which provides powerful task scheduling with priorities, queues, and semaphores. You can create persistent tasks that run in parallel:
xTaskCreate(sensorTask, "Sensor", 4096, NULL, 1, NULL);
xTaskCreate(displayTask, "Display", 4096, NULL, 1, NULL);
This works for always-on devices, but FreeRTOS tasks are designed for a running system. When you enter deep sleep, everything stops. When you wake, it's a fresh boot. FreeRTOS has no concept of "resume where you left off."
There are also Arduino-style schedulers like TaskScheduler that use cooperative multitasking. These are lighter weight than FreeRTOS but still assume continuous operation with millis() as the timebase.
Why I built something simpler
My requirements were different:
- Tasks must survive sleep/wake cycles
- The BSEC sensor library tells me the exact timestamp for the next reading, not a relative delay
- Just a few periodic jobs, no need for priorities or preemption
The solution: store task state in RTC memory using wall-clock time instead of millis(). Each task tracks when it last ran and when it should run next as Unix timestamps. The scheduler simply finds the earliest pending task and sleeps until then.
How it works
Tasks are registered at first boot with a callback and default period. The callback returns when the task should run next, either a relative delay (positive) or an absolute timestamp (negative):
Tasks::addTask(environmentTask, SENSOR_SAMPLERATE * 1000, "Environment");
When entering sleep, getNextWakeTimeMs() finds the earliest scheduled task. After waking, runDueTasks() executes anything that's due. The key insight is updating timestamps after callbacks complete. If you calculate "next run = now + delay" before running the task, long-running callbacks cause drift.
The negative return value convention came from the BSEC library, which provides exact timestamps for sensor readings. Returning -Sensor::lastReading.nextCall passes that timestamp directly to the scheduler without any conversion math.
CLI for Debugging
During development, I found myself constantly adding and removing Serial.println() statements to test different parts of the system. A CLI (Command Line Interface) over serial provides a much cleaner approach: type commands to inspect state, trigger actions, and test components without recompiling.
Command registration pattern
The CLI uses a simple registration system. Each command has a name, callback, usage string, and description. Commands are registered at startup:
CLI::registerCommand("heat", handleHeating, "heat <on|off>", "Control the heating system");
CLI::registerCommand("target", handleTarget, "target <temp>", "Set target temperature");
CLI::registerCommand("sensor", handleSensor, "sensor [show|init]", "Read sensor data");
Handlers receive parsed arguments in standard argc/argv format. The built-in help command lists all registered commands, making the system self-documenting.
I also added single-character shortcuts for common operations during testing. Pressing = or - adjusts the target temperature without typing a full command, useful when you're watching the display and don't want to look at the keyboard.
Remote CLI via Blynk
The text input widget in Blynk can extend the CLI beyond serial. I used it to send arbitrary text to the display for testing:
BLYNK_WRITE(BLYNK_PIN_SHOWTEXT) {
const char *value = param.asString();
Display::showText(value);
}
You could also route this through the full command parser. Useful for debugging a device that's mounted on a wall without easy serial access.
Bringing It Together
First boot vs wake
The setup() function handles two different scenarios: a fresh power-on and a wake from deep sleep. First boot needs full initialization with a full display refresh, WiFi setup, and registering tasks. Wake just needs to handle whatever triggered it and go back to sleep, using only a partial display refresh.
The loop() function handles interactive mode: process CLI commands, check buttons, run scheduled tasks. When the inactivity timer expires, the device goes back to sleep.
Feature flags
During development, I frequently needed to enable or disable features. Bluetooth uses significant flash space. The sensor might not be connected while testing the display. OTA updates are useful during development but waste resources in production.
Rather than commenting out code everywhere, I use preprocessor flags in config.h and wrap optional code in #ifdef blocks. This keeps the code clean and makes it easy to create different build configurations.
Conclusion
Building a custom thermostat turned out to be a deep dive into embedded systems. What started as "just read a temperature and flip a relay" expanded into power management, task scheduling, display drivers, and wireless protocols.
The biggest lessons:
- Not all GPIO pins are equal. Strapping pins, ADC conflicts with WiFi, and RTC wake limitations all shaped the hardware design.
- Deep sleep changes everything. You can't use normal timers or keep state in regular variables. RTC memory and absolute timestamps become essential.
Here's the funny bit: the thermostat isn't actually being used. The original turned out to be super reliable, and remote control is overrated when the thing is mounted next to your couch. The original board runs on two AA batteries and lasts months longer than my ESP32 version. It turns out the LILYGO T5 board has a known issue with deep sleep power draw that I didn't discover until after building everything. I got my power draw in deep sleep down to 500uA.
But that's fine. This was mostly a Christmas holiday project to learn about embedded systems and custom PCB design. Mission accomplished.
Source code
Continue reading
Building a custom telescope mount with harmonic drives and ESP32
How I went from buying a €200 tracker to building a custom telescope mount with harmonic drives, ESP32, and way more engineering than necessary
Nearby peer discovery without GPS using environmental fingerprints
I propose a peer discovery technique to detect nearby devices by comparing similarity in their observed environments, such as WiFi or Bluetooth networks. Using locality-sensitive hashing and private set intersection, peers can compare their environments without disclosing the full details. With sufficient similarity between environments, peers can conclude they are near each other.
A durable task primitive built on BullMQ
Building reliable task systems with BullMQ requires juggling queues, workers, and events, turning simple functions into scattered configuration. DurableTask solves this by wrapping BullMQ primitives into a single abstraction that gives any function automatic archiving, retries, scheduling, and complete execution history.