A three-position desk switch that starts and stops work sessions. Left and right each select a client or project (a "company" in the dashboard); middle stops the active session. The dashboard updates live and keeps a history of hours and pay periods.
The fingerprint authenticator is a separate device and is not part of this repository.
- An ESP32-C3 Super Mini and a three-position latching switch
- A USB data cable
- Node.js 22+, pnpm 10, and ESP-IDF 5.3.5
- A deployed copy of the cloud service
This repository is configured for time.jarenkempton.dev. To deploy your own copy, change the hostname and Cloudflare Access values in cloud/wrangler.jsonc, then follow the deployment guide.
With the angled switch terminals on the right and A/B/C/D labeled counter-clockwise from the top:
| Switch terminal | ESP32-C3 Super Mini |
|---|---|
| A | GND |
| B | GPIO4 — physical right |
| C | GPIO3 — physical left |
| D | Disconnected |
Insulate terminal D. The firmware uses the ESP32's internal pull-ups, so do not apply external voltage to either switch input.
Open Devices in desktop Chrome or Edge, connect the ESP32 over USB, and choose Add device. The dashboard:
- Identifies the connected chip.
- Installs the current ESP32-C3 firmware.
- Lets you select the companies for the left and right positions.
- Sends the Wi-Fi credentials directly from the browser to the ESP32 over USB.
- Shows the controller's live Wi-Fi, network-time, and Cloudflare registration status over USB.
- Finishes only after the controller's first authenticated check-in.
Use Configure on an existing device to reinstall firmware and replace its Wi-Fi or switch mapping. Use Delete to remove an obsolete or incomplete registration; its existing credential stops working immediately.
The Wi-Fi password never reaches the Worker or its database. Safari and iOS can display device status but do not support the browser installer.
The controller uses 2.4 GHz 802.11b/g in 20 MHz mode, scans all channels for matching mesh access points, and gracefully leaves Wi-Fi before a reinstall. Transmit power is capped at 8.5 dBm because the ESP32-C3 Super Mini antenna distorts frames at full power, which the access point reports as an authentication timeout (reason 2) even with a strong signal. Setup reports the ESP-IDF reason code and signal level when the access point rejects or times out a connection; the progress indicator remains indeterminate during network registration and reaches 100% only after the service confirms the controller is online.
source /path/to/esp-idf/export.sh
cd firmware
idf.py set-target esp32c3
idf.py build
idf.py -p /dev/cu.usbmodemXXXX flash monitorReplace /dev/cu.usbmodemXXXX with the serial port that appears when the board is connected. Exit the monitor with Ctrl+].
Open the dashboard, select Settings, and add both companies. Copy the UUID shown under each company; those values determine what the left and right positions track.
The dashboard is the supported provisioning path. For firmware debugging, the same serial protocol is available in the ESP-IDF monitor:
set wifi_ssid <network name>
set wifi_password <network password>
set api_url https://time.jarenkempton.dev
set device_id <device UUID created by the dashboard>
set provisioning_token <one-time setup token created by the dashboard>
set left_company_id <left company UUID>
set right_company_id <right company UUID>
show
reboot
show confirms which values are stored while hiding the Wi-Fi password, setup token, and device secret. The controller exchanges the one-time token for its own credential after joining Wi-Fi. Configuration survives power loss.
- Leave the switch in the middle and reboot the board.
- Wait for
Wi-Fi connectedandNetwork time synchronizedin the monitor. - Flip left. A session for the left company should appear in the dashboard.
- Flip to the middle. That session should stop.
- Flip right. A session for the right company should start.
If Wi-Fi is unavailable, the controller retains up to 16 pending changes and retries them after reconnecting.
cd cloud
pnpm install --frozen-lockfile
cp .dev.vars.example .dev.vars
pnpm dev --host 0.0.0.0To reach the dev server through another hostname, such as a Tailscale MagicDNS name, list it in DEV_ALLOWED_HOSTS (comma-separated, a leading dot allows subdomains): DEV_ALLOWED_HOSTS=.your-tailnet.ts.net pnpm dev --host 0.0.0.0.
No local secret is required. Device credentials are created during provisioning and remain out of source control.
Before opening a pull request, run:
cd cloud
pnpm format:check
pnpm check
pnpm test
pnpm deploy:checkGitHub Actions builds the ESP32 project and merged browser image only when a pull request changes firmware. Every production deploy rebuilds the browser image from the deployed commit, so the installer always serves the firmware on main; the image is not committed. Successful CI on main deploys production automatically without a separate environment approval. Database changes are defined with Drizzle in cloud/src/db/schema.ts; generate migrations with pnpm migrate:generate from cloud/.
show Display stored configuration with secrets redacted
identify Report the model, firmware, and provisioning protocol
reboot Restart the controller
prepare_install Leave Wi-Fi cleanly before a firmware reinstall
clear_state Clear the active session marker and offline queue
Each controller receives a separate, revocable credential and signs requests over HTTPS. Cloudflare Access protects the dashboard. Fingerprints and biometric templates never enter this service.