TRKeys 4-Key Macropad
A DIY four-button keypad built on an ESP32-C3. A single hardware switch picks one of three completely different personalities at power-on — a Bluetooth keyboard, a Wi-Fi button that fires MQTT messages, or a daily habit tracker.
BLE Keyboard
Each key sends a Bluetooth HID keystroke to a paired computer or phone.
Wi-Fi / MQTT
Each key publishes a message to an MQTT broker over Wi-Fi, with LED feedback.
Habit Tracker
Four lit LEDs; press a key to mark a habit done for the day. Auto-resets nightly.
Overview
TRKeys is firmware for a four-button macropad running on an ESP32-C3 (RISC-V, Wi-Fi + Bluetooth LE). All three modes are compiled into a single firmware image; the active mode is chosen by a physical mode-select switch that is read once at boot. Changing modes means flipping the switch and power-cycling (or resetting) the board — no reflashing required.
| Property | Value |
|---|---|
| Microcontroller | ESP32-C3 (QFN32, rev v0.4), 4 MB flash, 40 MHz crystal |
| Framework | ESP-IDF v5.3.2 |
| Bluetooth stack | NimBLE (HID-over-GATT) |
| Keys / LEDs | 4 push-buttons, 4 indicator LEDs |
| Console / flashing | Native USB (USB-Serial-JTAG) |
| BLE device name | TRKeys |
Quick start
- Pick a mode on the mode-select switch (see the switch map below).
- Power the board over USB. On boot the four LEDs blink together to confirm the mode: 1 blink = BLE, 2 blinks = Wi-Fi/MQTT, 3 blinks = Habit tracker.
- Use it:
- BLE — pair with "TRKeys" from your computer/phone Bluetooth menu, then press keys.
- Wi-Fi/MQTT — the board joins Wi-Fi and connects to the broker; press keys to publish.
- Habit tracker — all four LEDs light; press a key to switch its LED off for the day.
Board & pinout
Buttons are wired active-high (common leg to 3V3, sense pin pulled down internally). LEDs are wired active-low — 3V3 → 220 Ω resistor → LED → GPIO — so a GPIO driven low turns the LED on.
| Function | Key 1 | Key 2 | Key 3 | Key 4 |
|---|---|---|---|---|
| Button GPIO | GPIO0 | GPIO1 | GPIO5 | GPIO6 |
| LED GPIO | GPIO4 | GPIO7 | GPIO20 | GPIO21 |
| Signal | GPIO | Notes |
|---|---|---|
| Mode-select A | GPIO3 | Input, internal pull-down |
| Mode-select B | GPIO10 | Input, internal pull-down |
| Console / USB | native USB | USB-Serial-JTAG — flashing, monitor, and the config console all share this port |
Mode-select switch
The mode is encoded on just two GPIOs using a 4-position switch (OFF plus three
"on" throws). Each throw ties the board's 3V3 to a specific 2-bit combination of
GPIO3 (A) and GPIO10 (B). The pins are read once at boot,
5 ms after the pull-downs are enabled.
| Mode | A (GPIO3) | B (GPIO10) | Boot blinks |
|---|---|---|---|
| 1 · BLE Keyboard | HIGH | LOW | 1 |
| 2 · Wi-Fi / MQTT | LOW | HIGH | 2 |
| 3 · Habit Tracker | HIGH | HIGH | 3 |
Wiring notes
- Buttons: one leg to
3V3, the other to the button GPIO. Internal pull-downs are enabled in firmware, so no external resistor is needed. - LEDs:
3V3 → 220 Ω → LED anode…cathode → GPIO. Each LED has its own resistor. Driving the GPIO low sinks current and lights the LED. - UART0 is unused — its TX pin overlaps a key GPIO, so the console deliberately runs on native USB instead.
Mode 1 · BLE Keyboard
The board advertises as a standard Bluetooth LE HID keyboard named
TRKeys. Pair it from any host's Bluetooth menu; it reconnects
automatically and re-advertises if the host disconnects. Each key press sends a
modifier + keycode "tap" (press then release) as a standard 8-byte boot-keyboard report.
| Key | Default combo | Modifier byte | Keycode |
|---|---|---|---|
| Key 1 | Ctrl+1 | 0x01 | 0x1E |
| Key 2 | Ctrl+2 | 0x01 | 0x1F |
| Key 3 | Ctrl+3 | 0x01 | 0x20 |
| Key 4 | Ctrl+4 | 0x01 | 0x21 |
Key mappings are configurable at runtime over the serial console
(see below) — no reflashing needed. In BLE mode each key's LED lights while the
button is physically held and turns off on release. The modifier byte follows the
USB HID spec: 0x01 = Left Ctrl, 0x02 = Left Shift,
0x08 = Left GUI (⌘ on macOS).
0x16C0, PID 0x05DF,
appearance = Keyboard.Mode 2 · Wi-Fi / MQTT
The board joins a Wi-Fi network and opens a TLS WebSocket connection to an MQTT broker. Each key press publishes a configurable JSON message to the broker; the board also subscribes to a response topic. Power-save is disabled to keep the long-lived TLS/WebSocket connection stable.
| Setting | Value |
|---|---|
| Broker | wss://mqtt.uranus.im/mqtt (WebSocket Secure / TLS) |
| Client ID | device1 (broker ACL requires this exact ID) |
| Publish topic | agents/default/device1/messages |
| Subscribe topic | agents/default/device1/responses |
| Default payloads | {"message":"button 1"} … button 4 |
| TLS verification | Built-in Mozilla CA certificate bundle |
LED feedback: after a press the four LEDs run a chase animation while awaiting the broker's response; when a response arrives on the subscribe topic, all four LEDs flash together once.
USER CONFIG block. Per-key MQTT payloads can also be
overridden at runtime via the serial console.Mode 3 · Habit Tracker
A daily habit tracker. Each of the four LEDs represents a habit and starts lit ("not done yet today"). Pressing a key marks that habit done: its LED turns off, the state is saved to flash, and — because the board also connects to Wi-Fi/MQTT in this mode — a message is published to the broker. A habit can only be marked once per day.
- Persistence: the "muted" state and the current day are stored in NVS, so the LED pattern survives a power cycle and is restored immediately at boot — before Wi-Fi or time sync completes.
- Daily reset: the board syncs the clock over SNTP
(
pool.ntp.org). A background task checks every 15 s; when the local calendar day rolls over, all habits reset (LEDs back on). - Timezone: the day boundary uses a POSIX
TZstring, defaultUTC0. Set this to your local zone (e.g.PST8PDT,M3.2.0,M11.1.0) or the reset happens at UTC midnight.
UTC0, the nightly reset fires at midnight UTC, which may be the middle
of your day.Firmware configuration (compile-time)
The tunable settings live in the USER CONFIG block at the top of
main/main.c. Edit and reflash to change them.
#define WIFI_SSID "<your-ssid>"
#define WIFI_PASS "<your-wifi-password>"
#define MQTT_BROKER_URI "wss://mqtt.uranus.im/mqtt"
#define MQTT_USERNAME "default"
#define MQTT_PASSWORD "<broker-password>"
#define MQTT_CLIENT_ID "device1"
#define MQTT_TOPIC_PUB "agents/default/device1/messages"
#define MQTT_TOPIC_SUB "agents/default/device1/responses"
#define COPYPASTE_MOD 0x01 /* Left Ctrl; use 0x08 for macOS Cmd */
#define HID_KEY_1 0x1E /* keys 1-4 default to Ctrl+1..Ctrl+4 */
#define SNTP_SERVER "pool.ntp.org"
#define HABIT_TZ "UTC0" /* set to your local POSIX TZ string */
Serial config console
Per-key settings can be changed live over the same USB serial port used for flashing — no rebuild required. Open the port (any serial terminal) and use this simple line protocol. Changed values are written to NVS immediately and persist across reboots.
| Command | Effect |
|---|---|
CFG? | Dump the current config; output ends with a CFG:END line. |
CFG:SET KEY<n>_BLE_MOD=0x01 | Set the BLE modifier byte for key n (1–4). |
CFG:SET KEY<n>_BLE_KEY=0x1E | Set the BLE HID keycode for key n. |
CFG:SET KEY<n>_MQTT=<text> | Set the MQTT payload string for key n (up to 63 chars). |
Each CFG:SET replies CFG:OK on success or
CFG:ERR … on a bad key/field. Example session:
> CFG?
CFG:KEY1_BLE_MOD=0x01
CFG:KEY1_BLE_KEY=0x1E
CFG:KEY1_MQTT={"message":"button 1"}
... (keys 2-4) ...
CFG:END
> CFG:SET KEY1_BLE_KEY=0x04 # remap key 1 to the letter "a"
CFG:OK
> CFG:SET KEY2_MQTT={"cmd":"lights_on"}
CFG:OK
Stored state (NVS)
Both the per-key config and the habit-tracker state are kept in the ESP32's
non-volatile storage under the cfg namespace.
| Key | Contents |
|---|---|
keycfg4 | Per-key BLE modifiers, BLE keycodes, and MQTT payloads. |
habitst | Habit tracker: last-reset day (YYYYMMDD) and the "already done today" bitmask. |
Build & flash
Built with ESP-IDF v5.3.2, target esp32c3. From the
project directory:
# one-time: select the target
idf.py set-target esp32c3
# build, flash, and open the serial monitor (replace COM4 with your port)
idf.py -p COM4 build flash monitor
To flash an already-built image without rebuilding (esptool directly):
esptool.py --chip esp32c3 -p COM4 -b 460800 \
--before=default_reset --after=hard_reset write_flash \
--flash_mode dio --flash_freq 80m --flash_size 2MB \
0x0 bootloader/bootloader.bin \
0x10000 keypress.bin \
0x8000 partition_table/partition-table.bin
Key build options (from sdkconfig.defaults): NimBLE
enabled with the HID service, the large single-app partition table, the mbedTLS CA
certificate bundle for the wss:// broker, and the console routed to
USB-Serial-JTAG.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
Could not open COMx, the port is busy or doesn't exist |
A serial monitor or another program is holding the port — close it before flashing. If the port truly isn't there, replug the USB cable and confirm the COM number. |
| Wrong mode after power-on | Count the boot blinks (1/2/3). If they don't match the switch, re-check the
mode-select wiring on GPIO3/GPIO10. Unwired = falls back to Mode 2. |
| Wi-Fi connects then stalls | Router likely runs WPA2/WPA3 "transition" mode; the firmware advertises PMF-capable
to handle this. Confirm SSID/password in USER CONFIG. |
| MQTT "not connected, dropping" in logs | Broker/TLS not connected yet — presses are dropped until the connection is up.
Check broker URI, credentials, and that the client ID is device1. |
| Habit tracker resets at the wrong time | Set HABIT_TZ to your local POSIX timezone string and reflash. |
| BLE keys do nothing | Host not paired/connected — presses are dropped when disconnected. Re-pair "TRKeys" from the host Bluetooth menu. |