No description
  • C++ 81%
  • Shell 8%
  • JavaScript 5.7%
  • C 4.2%
  • CSS 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-05 19:34:55 +01:00
.superpowers/sdd chore: ignore superpowers scratch files 2026-08-19 22:31:32 +02:00
data fix: enforce WiFi startup policy 2026-08-19 18:54:02 +02:00
docs/superpowers fix: preserve queued paddle element order 2026-09-05 19:34:55 +01:00
scripts fix: use stable ESP32 upload speed 2026-08-21 13:12:52 +02:00
src fix: preserve queued paddle element order 2026-09-05 19:34:55 +01:00
tests fix: preserve queued paddle element order 2026-09-05 19:34:55 +01:00
web fix: enforce WiFi startup policy 2026-08-19 18:54:02 +02:00
.gitignore chore: ignore superpowers scratch files 2026-08-19 22:31:32 +02:00
AGENTS.md fix: preserve queued paddle element order 2026-09-05 19:34:55 +01:00
arduino-cli.yaml feat: define cross-platform firmware profiles 2026-08-09 20:29:52 +01:00
package-lock.json Add React web UI build pipeline 2026-08-03 23:22:33 +01:00
package.json Add local ESP web mock and build support 2026-08-04 23:12:41 +01:00
README.md fix: address final menu review 2026-08-19 22:22:25 +02:00

Multiplatform CW Paddle Monitor

Minimal Arduino sketch for a NodeMCU ESP8266 or ESP32-D0WDQ6 driving a display and reading a CW iambic paddle.

Target profiles

The default profile is CW_TARGET_ESP8266_OLED, which targets the NodeMCU ESP8266 and SSD1306 OLED. The second profile is CW_TARGET_ESP32_ST7789, which targets an ESP32-D0WDQ6 with an ST7789V display. Select exactly one profile with the compiler define.

The initial generic ESP32 FQBN is esp32:esp32:esp32; the exact carrier-board FQBN will be selected after the connected carrier is identified.

After the splash screen, the board starts at the on-device Main Menu, not Decode. WiFi is disabled by default, so keying and menus are immediately available without a network. When WiFi is enabled, configured, and connected, the browser UI is served at http://<device-ip>/ and live updates use ws://<device-ip>/ws.

Browser status and control endpoints are unauthenticated and intended only for a trusted LAN. Do not expose the device HTTP or WebSocket service to the internet or an untrusted network; WiFi remains opt-in and disabled by default.

Factory defaults are 10 WPM, a 700 Hz sidetone at 100% volume, tone enabled, font size 2, Straight key mode, Iambic A as the retained paddle variant, WiFi disabled, and empty WiFi credentials. Settings and learning progress are persisted in EEPROM.

On-device menu

The startup hierarchy is:

Main Menu
  Learn: Letters, Numbers, Symbols, All, Similars, Back
  Decode
  Games: Copy 3, Copy 5, Copy English, Back
  Config: WPM, Tone, Tone Hz, Volume, WiFi, Font Size, Key Mode, Reset, Back

Menu input uses completed Morse patterns: dit (.) moves down, dah (-) moves up, A (.-) selects, and <ESC> (....-.-.) returns to the parent menu. Selection does not wrap. A pattern is acted on only after the character gap completes, so A is one Select command rather than separate Down and Up commands. The gap is one dot-unit in Iambic A/B and two dot-units in Straight mode. <ESC> exits Decode to Main Menu, an answer-listening or no-pair lesson to Learn, and an answer-listening game to Games. It also cancels a numeric editor without applying its input.

Speed gestures

  • In paddle mode, holding both paddles produces an alternating stream. Closing dit first increases speed and closing dah first decreases it. The first three pairs make no change; each later pair changes WPM by one while held. A squeeze-completed pattern is consumed in menus and cannot navigate or select.
  • In the menu, either key type can manually send one uninterrupted alternating pattern. .-.-.- and -.-.-. make no change; each complete pair appended after those first six elements changes WPM by one, up for a dit-first pattern and down for a dah-first pattern. The pattern is consumed instead of being treated as menu input.
  • Decode also accepts QRS or QRS<n> to slow by 1 or n, and QRQ or QRQ<n> to speed up. Every path clamps speed to 10-50 WPM.

Paddle mode supports Iambic A and Iambic B. Iambic A discards an unconsumed opposite-paddle request when a squeeze releases; Iambic B permits one final remembered opposite element. Both retain eligible single-paddle memory. In Straight mode, either input can be used as the key: closing it holds the sidetone, a press shorter than two dot-units is a dit, and a longer press is a dah.

Learn modes

The curricula are fixed contrast-oriented sequences:

  • Letters: K M R S U A P T L O W I N J E F Y V G Q Z H B C D X
  • Numbers: 0 5 9 3 8 4 2 7 1 6
  • Symbols: . / ? , = + - @ ' ! ( ) & : ; _ " $
  • All: K M R S U A P T L O W I . N J E F 0 Y V G 5 / Q 9 Z H 3 8 B ? 4 2 7 C 1 D 6 X , = + - @ ' ! ( ) & : ; _ " $

A lesson shows LISTEN while sounding one target and KEY IT while waiting for a completed answer. A wrong answer is recorded, displays TRY AGAIN for 1200 ms, and replays the same target; the lesson advances only after a correct answer, which displays CORRECT for 800 ms. Character statistics are shared across curricula. The first character unlocks when a curriculum is first opened, and at most one next character unlocks after a correct answer when every currently unlocked character in that curriculum is mastered. Mastery requires at least five lifetime successes and at least 80% success over at least five, and at most ten, recent attempts.

Similars draws only from related characters that are both already unlocked in at least one of Letters, Numbers, Symbols, or All. It combines curated look-alike pairs with personal wrong-response pairs. With no eligible pair, it shows UNLOCK MORE; other patterns are ignored and <ESC> returns to Learn. Learning progress checkpoints after every ten answers and on normal <ESC> lesson exit. The write runs in the next timing-safe service window, so abrupt power loss before it runs can lose up to ten answers.

Copy games

  • Copy 3 sends a three-character group containing two letters and one digit.
  • Copy 5 sends five characters containing one digit after one or two leading letters.
  • Copy English chooses from a shuffled source of 50 uppercase five-letter words without repeating within the ten-target session.

All three games win after ten total correct targets; they do not require completing all 50 English source words. A wrong answer shows the target and repeats it, while a correct answer resets the consecutive-error counter and advances. Errors do not remove correct-count progress. Five consecutive errors end the game with GAME OVER; the tenth correct answer ends it with WIN. Either result returns to Games. Decode still recognizes keyed GAME 0, GAME 1, and GAME 2 commands for the same games. During a game, word spaces, prosigns, QRS/QRQ commands, and the web tone test are ignored.

Configuration

Config provides these exact choices:

  • WPM: 10, 12, 15, 18, 20, Enter value, Back
  • Tone: Off, On, Back
  • Tone Hz: 500, 700, 1200, Enter value, Back
  • Volume: 1%, 2%, 3%, 25%, 50%, 75%, 100%, Enter value, Back
  • WiFi: Off, On, Back
  • Font Size: 1-3 on SSD1306 or 1-6 on ST7789, then Back
  • Key Mode: Straight, Iambic A, Iambic B, Back
  • Reset: No, Yes, Back

Preset choices apply immediately and return to Config. Numeric entry is available only for WPM (10-50), Tone Hz (20-20000), and Volume (1-100): key Morse digits, use A to commit a nonempty in-range value, or <ESC> to cancel. Invalid input leaves the editor open and does not change the setting. Key Mode combines the physical mode and iambic variant: Straight selects straight-key handling; Iambic A/B selects paddle handling and that variant.

WiFi Enabled and WiFi Configured are separate states. On starts network operation only if a nonempty SSID has been stored through serial; otherwise the menu reports Serial setup required, the radio remains off, and all local functions continue. Off stops attempts and network servicing but retains credentials. Reset Yes erases settings and learning records, applies the factory defaults above, stops WiFi/audio activity, and returns to Main Menu; those defaults still apply after reboot.

The selected font applies to Decode, menus, editors, lessons, and games on both displays. Menu rows wrap and scroll to keep the selection visible. Titles and essential lesson/game content may use the largest smaller font that fits, so maximum configured sizes remain navigable on the 128x64 SSD1306 and 240x135 ST7789.

The UI also includes a serial console (Chrome Web Serial API, 115200 baud) that shows the chip's serial output and lets you type commands to it. Supported commands:

  • set <option> <value> — set wpm (10-50), freq (20-20000), volume (1-100), tone (on/off), font (1 to the target maximum), mode (paddle/straight), iambic (a/b), wifi-enabled (on/off), wifi (SSID), or wifi-pass (password)
  • get <option> — show wpm, freq, volume, tone, font, mode, iambic, wifi-enabled, wifi, or wifi-pass
  • connect — enable WiFi and connect using the saved credentials
  • help — list the commands

Setting wifi or wifi-pass saves the value and reconnects immediately only when WiFi is enabled. Serial input is echoed, and get wifi-pass prints the stored password; use a private terminal, avoid shared logs or screen captures, and clear any captured output after credential setup. The password-setting response itself does not repeat the value.

The firmware echoes every received character and supports backspace, so a plain terminal works without local echo. Connect with picocom (no --echo needed):

picocom --baud 115200 /dev/cu.wchusbserial14110

Note: the Web Serial API requires a secure context (HTTPS or localhost). On plain HTTP you can add the device origin to chrome://flags/#unsafely-treat-insecure-origin-as-secure, or access it through a localhost tunnel.

Wiring

ESP8266 OLED

  • GPIO12 / D6 -> SDA
  • GPIO14 / D5 -> SCL
  • 3V3 -> VCC
  • GND -> GND
  • GPIO4 / D2 -> DIT paddle contact
  • GPIO5 / D1 -> DAH paddle contact
  • GPIO13 / D7 -> sidetone output / amp input
  • Paddle common -> GND

ESP32 ST7789

  • GPIO19 -> MOSI
  • GPIO18 -> SCLK
  • GPIO5 -> CS
  • GPIO16 -> DC
  • GPIO23 -> RST
  • GPIO4 -> BL
  • GPIO15 -> DIT paddle contact
  • GPIO13 -> DAH paddle contact
  • GPIO12 -> sidetone output / amp input
  • Paddle common -> GND

The ST7789 panel is initialized with its native 135x240 dimensions and rotated to a logical 240x135 landscape orientation. The firmware initializes hardware SPI on the pins above, validates the resulting geometry, and leaves the decoder and keyer running if display initialization fails. ST7789 controller offsets, rotation, and backlight polarity vary between modules; this generic initialization is best effort and may require profile constants for a specific panel. A successful geometry check does not guarantee that every module's visible area is aligned correctly.

Files

  • src/main.ino
  • web/ — React source for the browser UI
  • data/ — ignored, generated LittleFS assets (index.html, style.css, app.js)
  • scripts/arduino — build/upload helper
  • WebSockets library

Required Arduino packages

Install these in Arduino CLI or the Arduino IDE:

  • Board package: esp8266:esp8266
  • Board package: esp32:esp32
  • Libraries: Adafruit GFX Library, Adafruit SSD1306, WebSockets
  • ESP32 display library: Adafruit ST7735 and ST7789 Library

Arduino CLI setup

arduino-cli core update-index --config-file arduino-cli.yaml
arduino-cli core install esp8266:esp8266 --config-file arduino-cli.yaml
arduino-cli core install esp32:esp32 --config-file arduino-cli.yaml
arduino-cli lib install "Adafruit GFX Library" "Adafruit SSD1306"
arduino-cli lib install "Adafruit ST7735 and ST7789 Library"
arduino-cli lib install "WebSockets"

Compile & Flash

./scripts/arduino build
./scripts/arduino build-esp8266
./scripts/arduino build-esp32

The default build compiles both target profiles. Use the focused commands when iterating on one board. The ESP32 FQBN defaults to esp32:esp32:esp32 and can be overridden without changing source files, for example:

ESP32_FQBN=esp32:esp32:esp32:your-board ./scripts/arduino build-esp32

Flashing uses profile-first syntax. Provide both the profile and port to skip prompts. If both are omitted, the helper prompts for the profile and then the port; if only the port is omitted, it prompts for the port rather than guessing which board is connected:

./scripts/arduino upload esp8266 /dev/cu.wchusbserial14110
./scripts/arduino upload esp32 /dev/cu.wchusbserial5B310123941
./scripts/arduino upload-fs esp32 /dev/cu.wchusbserial5B310123941
./scripts/arduino upload esp8266
./scripts/arduino upload-fs esp8266
./scripts/arduino upload
./scripts/arduino upload-fs
./scripts/arduino all esp8266 /dev/cu.wchusbserial14110

Build the web UI

The web interface is a React application bundled locally (including React itself, so the board does not need internet access). Its generated data/ assets are ignored by Git and rebuilt automatically before every LittleFS upload:

npm install
./scripts/arduino build-web

For continuous rebuilding while editing the React source:

./scripts/arduino watch-web

Build and upload everything

Run the complete firmware and LittleFS build/upload flow with one command. If you omit the port, the helper lists local serial devices and presents a numbered menu:

./scripts/arduino all esp8266

To bypass the menu, pass the port explicitly:

./scripts/arduino all esp8266 /dev/cu.wchusbserial14110

List the detected serial devices at any time with ./scripts/arduino list-ports.

Remove generated web assets and installed Node packages with:

./scripts/arduino clean

This does not remove source files, package-lock.json, or shared Arduino CLI caches.

Run a local mock ESP server

Test the generated LittleFS web app without a board by running the mock server. It serves data/ and provides the same /ws WebSocket status and control protocol used by the ESP8266:

npm run mock

Then open http://127.0.0.1:8080. The mock accepts WPM, tone, tone-frequency, and font changes, and returns the resulting status packet. Pass a port after --, for example npm run mock -- 3000.

The web UI is stored on the board's LittleFS partition (1 MB). After flashing the sketch, flash the filesystem:

./scripts/arduino upload-fs esp8266 /dev/cu.wchusbserial14110

Open http://<device-ip>/ in a browser after the board joins WiFi. The Settings tab reports WebSocket transport, WiFi enabled, WiFi connection, and IP separately. Before the first status packet its safe fallback is WiFi Off/Disconnected at 0.0.0.0; after packets arrive, a disconnected WebSocket retains the last valid device status. WiFi Enabled can therefore read On while WiFi Connection remains Disconnected, and enabled with no SSID reads On / serial setup required.

If the display stays blank, try OLED address 0x3D instead of 0x3C.

Selecting Decode shows:

  • WiFi connection status or http://<device-ip> on the first line
  • the current dot/dash pattern on the second line
  • decoded text in the remaining six lines

The provisional decoded character is drawn in inverse video and replaced as more elements are keyed. It is committed after an idle gap longer than one dot unit; a seven-unit gap inserts a word space. The decoded buffer starts full of spaces so new text appears at the end (bottom-right) and scrolls toward the top as it fills. The decoder includes letters, numbers, punctuation, and common unambiguous prosigns such as <BK>, <CL>, <CT>, <SK>, <SOS>, and <HH>.

The made-up <RST> prosign (.-....-, the joined patterns for R, S, and T) clears the decoded text buffer.

The web UI shows raw PADDLE and timed TX state separately. When both paddles are held, the keyer alternates between dits and dahs.

The paddle inputs and keyer run first in each loop. Display drawing, cursor blink, serial/network work, EEPROM saves/reset, and status broadcasts are deferred until no element, completed pattern, raw/debounced press, key memory, or required inter-element gap is active. Generated lesson/game playback is nonblocking; service work is limited to the initial silent pre-playback window when a screen is dirty. Serial polling is bounded to 32 characters per loop. Display failure degrades visual output without stopping keying, audio, serial, or state transitions, and slow-loop diagnostics are emitted only from a safe service window.