ProfessorSparks
Published © GPL3+

M5Cardputer Morse Code Suite

A growing set of Cardputer programs to learn, practice and play with Morse Code. using paddles or straight keys.

BeginnerWork in progress5 hours117
M5Cardputer Morse Code Suite

Things used in this project

Hardware components

Cardputer
M5Stack Cardputer
×2
CW Paddle
×1
CW Straight Key
×1
M5 Grove cable
×1
3d Printed Base (custom)
×1

Story

Read more

Custom parts and enclosures

cardputer stand simple

Here is a simplified 3d printed cardputer stand with base.

Sketchfab still processing.

Schematics

paddlejack to grove

Here is how you wire up the grove port to use a cw paddle.

StraightKey to grove port

Here is how you wire up a straight key to the grove port

Code

Morse _Oscillator2.0

Arduino
connect up a cw paddle to a cardputer using the grove port and practice sending code
// Morse Oscillator
// Version: 2.0
// Author: Matthew Sparks N6MMS
// Email: ProfessorSparks@gmail.com
// Platform: M5Cardputer
// License: GNU General Public License v3.0



const int groveG1Pin = 1;  // G1 = GPIO1
const int groveG2Pin = 2;  // G2 = GPIO2

bool lastG1State = HIGH;
bool lastG2State = HIGH;

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);

  pinMode(groveG1Pin, INPUT_PULLUP);
  pinMode(groveG2Pin, INPUT_PULLUP);

  Serial.begin(115200);
  Serial.println("M5 Morse Oscillator");

  M5.Lcd.setTextSize(2);
  M5.Lcd.setCursor(10, 10);
  M5.Lcd.println("M5 Morse Oscillator");
}

void loop() {
  int g1State = digitalRead(groveG1Pin);
  int g2State = digitalRead(groveG2Pin);

  // Detect G1 change to LOW (grounded)
  if (g1State == LOW && lastG1State == HIGH) {
    Serial.println("G1 LOW - Long Beep");
    M5.Speaker.tone(1000, 300);  // 300 ms long beep
  }

  // Detect G2 change to LOW (grounded)
  if (g2State == LOW && lastG2State == HIGH) {
    Serial.println("G2 LOW - Short Beep");
    M5.Speaker.tone(1000, 100);  // 100 ms short beep
  }

  // Update screen
  M5.Lcd.setCursor(10, 50);
  M5.Lcd.fillRect(10, 50, 240, 60, BLACK);  // Clear area

  M5.Lcd.setTextColor(WHITE);
  M5.Lcd.setCursor(10, 50);
  M5.Lcd.printf("CW Key: %s\n", g1State == LOW ? "Dash" : " ");
  M5.Lcd.setCursor(11, 50);
  M5.Lcd.printf("CW Key: %s\n", g2State == LOW ? "Dot" : " ");

  // Save last states
  lastG1State = g1State;
  lastG2State = g2State;

  delay(100);  // Loop delay
}

Morsefalls2.4

Arduino
a portrait oriented morse code practice game (for paddles)
// MorseFalls v2.4 for M5Cardputer
// Author: Matthew Sparks N6MMS
// Features: Leveling, scoring, proper Morse input, centered startup, bug fixes

#include <M5Cardputer.h>
#include <M5GFX.h>

M5Canvas canvas(&M5Cardputer.Display);

const char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
char currentChar = 'A';
int charX = 0, charY = 22;
int score = 0, level = 1;
int fallDelay = 80;
int correctStreak = 0;
int errorStreak = 0;
unsigned long lastFall = 0;

char morse[10] = "";
int morseLen = 0;
unsigned long lastInputTime = 0;
bool dotPressed = false, dashPressed = false;

const int dotPin = 2;
const int dashPin = 1;

void showStartupScreen() {
  canvas.deleteSprite();
  canvas.createSprite(132, 239);
  canvas.fillScreen(BLACK);
  canvas.setTextColor(GREEN);
  canvas.setTextSize(1);
  canvas.setTextDatum(middle_center);
  canvas.drawString("MorseFalls", 66, 80);
  canvas.drawString("Version 2.4", 66, 110);
  canvas.drawString("Matthew Sparks N6MMS", 66, 140);
  canvas.pushSprite(0, 0);
  delay(3000);
  canvas.deleteSprite();
}

void newChar() {
  currentChar = charset[random(0, sizeof(charset) - 1)];
  charX = random(2, 132 - 16);
  charY = 22;
}

void drawScreen() {
  canvas.fillScreen(BLACK);

  // Borders
  canvas.drawFastHLine(0, 0, 132, GREEN);        // Top
  canvas.drawFastHLine(0, 20, 132, GREEN);       // Bottom of top box
  canvas.drawFastHLine(0, 218, 132, RED);        // Top of bottom box
  canvas.drawFastHLine(0, 238, 132, RED);        // Bottom
  canvas.drawFastVLine(0, 0, 239, GREEN);        // Left
  canvas.drawFastVLine(131, 0, 239, GREEN);      // Right

  // Score and level
  canvas.setTextColor(GREEN);
  canvas.setTextSize(1);
  canvas.setCursor(4, 4);
  canvas.printf("L%d", level);
  canvas.setCursor(90, 4);
  canvas.setTextSize(1.5);
  canvas.printf("%d", score);

  // Falling letter
  canvas.setTextSize(2);
  canvas.setTextDatum(top_left);
  canvas.drawString(String(currentChar), charX, charY);

  // Morse code input (larger and centered)
  canvas.setTextSize(2);
  canvas.setTextDatum(middle_center);
  canvas.drawString(morse, 66, 228);

  canvas.pushSprite(0, 0);
}

void flashBox(uint16_t color, int yStart, int yEnd, int count = 1) {
  for (int i = 0; i < count; i++) {
    canvas.fillRect(1, yStart, 130, yEnd - yStart, color);
    canvas.pushSprite(0, 0);
    delay(150);
    drawScreen();
    delay(100);
  }
}

String decodeMorse(const char *m) {
  if (strcmp(m, ".-") == 0) return "A";
  else if (strcmp(m, "-...") == 0) return "B";
  else if (strcmp(m, "-.-.") == 0) return "C";
  else if (strcmp(m, "-..") == 0) return "D";
  else if (strcmp(m, ".") == 0) return "E";
  else if (strcmp(m, "..-.") == 0) return "F";
  else if (strcmp(m, "--.") == 0) return "G";
  else if (strcmp(m, "....") == 0) return "H";
  else if (strcmp(m, "..") == 0) return "I";
  else if (strcmp(m, ".---") == 0) return "J";
  else if (strcmp(m, "-.-") == 0) return "K";
  else if (strcmp(m, ".-..") == 0) return "L";
  else if (strcmp(m, "--") == 0) return "M";
  else if (strcmp(m, "-.") == 0) return "N";
  else if (strcmp(m, "---") == 0) return "O";
  else if (strcmp(m, ".--.") == 0) return "P";
  else if (strcmp(m, "--.-") == 0) return "Q";
  else if (strcmp(m, ".-.") == 0) return "R";
  else if (strcmp(m, "...") == 0) return "S";
  else if (strcmp(m, "-") == 0) return "T";
  else if (strcmp(m, "..-") == 0) return "U";
  else if (strcmp(m, "...-") == 0) return "V";
  else if (strcmp(m, ".--") == 0) return "W";
  else if (strcmp(m, "-..-") == 0) return "X";
  else if (strcmp(m, "-.--") == 0) return "Y";
  else if (strcmp(m, "--..") == 0) return "Z";
  else if (strcmp(m, ".----") == 0) return "1";
  else if (strcmp(m, "..---") == 0) return "2";
  else if (strcmp(m, "...--") == 0) return "3";
  else if (strcmp(m, "....-") == 0) return "4";
  else if (strcmp(m, ".....") == 0) return "5";
  else if (strcmp(m, "-....") == 0) return "6";
  else if (strcmp(m, "--...") == 0) return "7";
  else if (strcmp(m, "---..") == 0) return "8";
  else if (strcmp(m, "----.") == 0) return "9";
  else if (strcmp(m, "-----") == 0) return "0";
  else return "";
}

void setup() {
  auto cfg = M5.config();
  M5Cardputer.begin(cfg);
  M5Cardputer.Display.setRotation(0);

  pinMode(dotPin, INPUT_PULLUP);
  pinMode(dashPin, INPUT_PULLUP);

  canvas.createSprite(132, 239);
  showStartupScreen();
  canvas.createSprite(132, 239);
  newChar();
}

void loop() {
  M5Cardputer.update();
  bool dot = digitalRead(dotPin) == LOW;
  bool dash = digitalRead(dashPin) == LOW;
  unsigned long now = millis();

  if (dot && !dotPressed) {
    strcat(morse, ".");
    morseLen++;
    lastInputTime = now;
  }
  if (dash && !dashPressed) {
    strcat(morse, "-");
    morseLen++;
    lastInputTime = now;
  }
  dotPressed = dot;
  dashPressed = dash;

  if (morseLen > 0 && now - lastInputTime > 1000) {
    String decoded = decodeMorse(morse);
    if (decoded == "") {
      flashBox(RED, 218, 238);
      score -= 2;
      errorStreak++;
      correctStreak = 0;
    } else if (decoded[0] == currentChar) {
      flashBox(GREEN, 22, 218, 2);
      score += 10;
      correctStreak++;
      errorStreak = 0;
      if (correctStreak % 5 == 0) {
        level++;
        fallDelay = max(20, fallDelay - 10);
        canvas.fillScreen(BLACK);
        canvas.setTextColor(GREEN);
        canvas.setTextSize(2);
        canvas.setTextDatum(middle_center);
        canvas.drawString("Level " + String(level), 66, 120);
        canvas.pushSprite(0, 0);
        delay(1500);
      }
      newChar();
    } else {
      score -= 5;
      correctStreak = 0;
      errorStreak++;
    }
    morse[0] = '\0';
    morseLen = 0;
  }

  if (now - lastFall > fallDelay) {
    charY += 3;
    lastFall = now;
    if (charY > 205) {
      score -= 10;
      flashBox(RED, 218, 238);
      newChar();
      errorStreak++;
      correctStreak = 0;
    }
  }

  if (errorStreak >= 5) {
    canvas.fillScreen(BLACK);
    canvas.setTextColor(RED);
    canvas.setTextSize(2);
    canvas.setTextDatum(middle_center);
    canvas.drawString("Game Over", 66, 100);
    canvas.drawString("Score: " + String(score), 66, 140);
    canvas.drawString("Level: " + String(level), 66, 170);
    canvas.pushSprite(0, 0);
    while (true) delay(1000);
  }

  drawScreen();
}

MorseFalls 2.7 for straight key

Arduino
a portrait orientd morse code game (for use with a straight key)
// MorseFalls SK v2.7
// Fixes: top box offset, longer build wait, 6-error game over
// New: On-screen WPM indicator
// Author: Matthew Sparks N6MMS
// Platform: M5Cardputer

#include <M5Cardputer.h>
#include <M5GFX.h>

M5Canvas canvas(&M5Cardputer.Display);

// ---------------- Game state ----------------
const char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
char currentChar = 'A';
int charX = 0, charY = 30;   // shifted down
int score = 0, level = 1;
int fallDelay = 80;
int correctStreak = 0;
int errorStreak = 0;
unsigned long lastFall = 0;

char morse[16] = "";
int morseLen = 0;

// ---------------- Straight key + timing ----------------
const int keyPin = 1;     // G1
bool keyDown = false;
unsigned long pressStartMs = 0;
unsigned long lastChangeMs = 0;
unsigned long lastElementEnd = 0;
const int debounceMs = 10;

// WPM timing
float measuredWPM = 8.0f;
float ditMs, dotDashThresholdMs, letterGapMs;
const float WPM_MIN = 5.0f, WPM_MAX = 25.0f;

// ---------------- UI helpers ----------------
void showStartupScreen() {
  canvas.deleteSprite();
  canvas.createSprite(132, 239);
  canvas.fillScreen(BLACK);
  canvas.setTextColor(GREEN);
  canvas.setTextSize(1);
  canvas.setTextDatum(middle_center);
  canvas.drawString("MorseFalls", 66, 80);
  canvas.drawString("SK Version 2.7", 66, 110);
  canvas.drawString("Matthew Sparks N6MMS", 66, 140);
  canvas.pushSprite(0, 0);
  delay(3000);
  canvas.deleteSprite();
}

void newChar() {
  currentChar = charset[random(0, sizeof(charset) - 1)];
  charX = random(2, 132 - 16);
  charY = 30;  // start below header box
}

void drawScreen() {
  canvas.fillScreen(BLACK);

  // Borders
  canvas.drawFastHLine(0, 0, 132, GREEN);
  canvas.drawFastHLine(0, 20, 132, GREEN);  // bottom of top bar
  canvas.drawFastHLine(0, 218, 132, RED);
  canvas.drawFastHLine(0, 238, 132, RED);
  canvas.drawFastVLine(0, 0, 239, GREEN);
  canvas.drawFastVLine(131, 0, 239, GREEN);

  // Score, level, WPM
  canvas.setTextColor(GREEN);
  canvas.setTextSize(1);
  canvas.setCursor(4, 4);
  canvas.printf("L%d", level);

  canvas.setCursor(70, 2);
  canvas.setTextSize(1.5);
  canvas.printf("%d", score);

  canvas.setCursor(70, 12);
  canvas.setTextSize(1);
  canvas.printf("WPM:%2.0f", measuredWPM);

  // Falling letter
  canvas.setTextSize(2);
  canvas.setTextDatum(top_left);
  canvas.drawString(String(currentChar), charX, charY);

  // Morse code input
  canvas.setTextSize(2);
  canvas.setTextDatum(middle_center);
  canvas.drawString(morse, 66, 228);

  canvas.pushSprite(0, 0);
}

void flashBox(uint16_t color, int yStart, int yEnd, int count = 1) {
  for (int i = 0; i < count; i++) {
    canvas.fillRect(1, yStart, 130, yEnd - yStart, color);
    canvas.pushSprite(0, 0);
    delay(150);
    drawScreen();
    delay(100);
  }
}

// ---------------- Morse decode ----------------
String decodeMorse(const char *m) {
  if (strcmp(m, ".-") == 0) return "A";
  else if (strcmp(m, "-...") == 0) return "B";
  else if (strcmp(m, "-.-.") == 0) return "C";
  else if (strcmp(m, "-..") == 0) return "D";
  else if (strcmp(m, ".") == 0) return "E";
  else if (strcmp(m, "..-.") == 0) return "F";
  else if (strcmp(m, "--.") == 0) return "G";
  else if (strcmp(m, "....") == 0) return "H";
  else if (strcmp(m, "..") == 0) return "I";
  else if (strcmp(m, ".---") == 0) return "J";
  else if (strcmp(m, "-.-") == 0) return "K";
  else if (strcmp(m, ".-..") == 0) return "L";
  else if (strcmp(m, "--") == 0) return "M";
  else if (strcmp(m, "-.") == 0) return "N";
  else if (strcmp(m, "---") == 0) return "O";
  else if (strcmp(m, ".--.") == 0) return "P";
  else if (strcmp(m, "--.-") == 0) return "Q";
  else if (strcmp(m, ".-.") == 0) return "R";
  else if (strcmp(m, "...") == 0) return "S";
  else if (strcmp(m, "-") == 0) return "T";
  else if (strcmp(m, "..-") == 0) return "U";
  else if (strcmp(m, "...-") == 0) return "V";
  else if (strcmp(m, ".--") == 0) return "W";
  else if (strcmp(m, "-..-") == 0) return "X";
  else if (strcmp(m, "-.--") == 0) return "Y";
  else if (strcmp(m, "--..") == 0) return "Z";
  else if (strcmp(m, ".----") == 0) return "1";
  else if (strcmp(m, "..---") == 0) return "2";
  else if (strcmp(m, "...--") == 0) return "3";
  else if (strcmp(m, "....-") == 0) return "4";
  else if (strcmp(m, ".....") == 0) return "5";
  else if (strcmp(m, "-....") == 0) return "6";
  else if (strcmp(m, "--...") == 0) return "7";
  else if (strcmp(m, "---..") == 0) return "8";
  else if (strcmp(m, "----.") == 0) return "9";
  else if (strcmp(m, "-----") == 0) return "0";
  else return "";
}

// ---------------- WPM logic ----------------
void applyWPM(float wpm) {
  measuredWPM = constrain(wpm, WPM_MIN, WPM_MAX);
  ditMs              = 1200.0f / measuredWPM;
  dotDashThresholdMs = 2.0f * ditMs;
  letterGapMs        = 6.0f * ditMs;   // longer idle gap for character finalize
}

// ---------------- Input handling ----------------
void handleKey(unsigned long now, bool currentDown) {
  if (!keyDown && currentDown && (now - lastChangeMs > debounceMs)) {
    keyDown = true;
    lastChangeMs = now;
    pressStartMs = now;
    M5Cardputer.Speaker.tone(700);
  }

  if (keyDown && !currentDown && (now - lastChangeMs > debounceMs)) {
    keyDown = false;
    lastChangeMs = now;
    unsigned long pressDur = now - pressStartMs;
    M5Cardputer.Speaker.stop();

    char symbol = (pressDur < dotDashThresholdMs) ? '.' : '-';
    if (morseLen < 15) {
      morse[morseLen++] = symbol;
      morse[morseLen] = '\0';
    }
    lastElementEnd = now;
  }
}

void pollWPMKeys() {
  if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
    auto ks = M5Cardputer.Keyboard.keysState();
    for (auto ch : ks.word) {
      if (ch == ';') applyWPM(measuredWPM + 1.0f);
      if (ch == '.') applyWPM(measuredWPM - 1.0f);
    }
  }
}

// ---------------- Setup ----------------
void setup() {
  auto cfg = M5.config();
  M5Cardputer.begin(cfg);
  M5Cardputer.Display.setRotation(0);

  pinMode(keyPin, INPUT_PULLUP);
  M5Cardputer.Speaker.setVolume(70);

  canvas.createSprite(132, 239);
  showStartupScreen();
  canvas.createSprite(132, 239);

  applyWPM(measuredWPM);
  newChar();
}

// ---------------- Loop ----------------
void loop() {
  M5Cardputer.update();
  pollWPMKeys();

  unsigned long now = millis();
  bool currentDown = (digitalRead(keyPin) == LOW);
  handleKey(now, currentDown);

  // Decode after idle gap
  if (morseLen > 0 && lastElementEnd > 0 && now - lastElementEnd > letterGapMs) {
    String decoded = decodeMorse(morse);
    if (decoded == "") {
      flashBox(RED, 218, 238);
      score -= 2;
      errorStreak++;
      correctStreak = 0;
    } else if (decoded[0] == currentChar) {
      flashBox(GREEN, 22, 218, 2);
      score += 10;
      correctStreak++;
      errorStreak = 0;
      if (correctStreak % 5 == 0) {
        level++;
        fallDelay = max(20, fallDelay - 10);
        canvas.fillScreen(BLACK);
        canvas.setTextColor(GREEN);
        canvas.setTextSize(2);
        canvas.setTextDatum(middle_center);
        canvas.drawString("Level " + String(level), 66, 120);
        canvas.pushSprite(0, 0);
        delay(1500);
      }
      newChar();
    } else {
      score -= 5;
      correctStreak = 0;
      errorStreak++;
    }
    morse[0] = '\0';
    morseLen = 0;
    lastElementEnd = 0;
  }

  // Falling logic
  if (now - lastFall > fallDelay) {
    charY += 3;
    lastFall = now;
    if (charY > 205) {
      score -= 10;
      flashBox(RED, 218, 238);
      newChar();
      errorStreak++;
      correctStreak = 0;
    }
  }

  // Game over at 6 errors
  if (errorStreak >= 6) {
    canvas.fillScreen(BLACK);
    canvas.setTextColor(RED);
    canvas.setTextSize(2);
    canvas.setTextDatum(middle_center);
    canvas.drawString("Game Over", 66, 100);
    canvas.drawString("Score: " + String(score), 66, 140);
    canvas.drawString("Level: " + String(level), 66, 170);
    canvas.drawString("Press ENTER", 66, 200);
    canvas.pushSprite(0, 0);

    // Wait for ENTER to restart
    while (true) {
      M5Cardputer.update();
      if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
        auto ks = M5Cardputer.Keyboard.keysState();
        if (ks.enter) {
          // Reset game state
          score = 0;
          level = 1;
          fallDelay = 80;
          correctStreak = 0;
          errorStreak = 0;
          morse[0] = '\0';
          morseLen = 0;
          lastFall = millis();
          newChar();
          return;
        }
      }
      delay(100);
    }
  }

  drawScreen();
}

Wifi Telegraph

Arduino
a program to let you send morse from one cardputer to another over wifi. As long as they are on the same wifi network they do not have to be next to each other to send and receive (note at this time this program only uses paddles.)
// WiFi_Telegraph
// Version: 1.6 (official M5Cardputer keyboard input)
// Author: Matthew Sparks N6MMS
// Platform: M5Cardputer
// License: GNU GPL v3.0

#include <M5Cardputer.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <SPIFFS.h>

WiFiUDP udp;
IPAddress remoteIP;
const int udpPort = 4210;

const int groveG1Pin = 1;  // Dash
const int groveG2Pin = 2;  // Dot

bool lastG1State = HIGH;
bool lastG2State = HIGH;

int wpm = 15;
int ditLen = 1200 / 15;

int txCursorX = 10;
int txCursorY = 30;

unsigned long lastReceiveTime = 0;
String morseLine = "";

void drawMessageLabel() {
  M5Cardputer.Display.setTextSize(2);
  M5Cardputer.Display.setTextColor(WHITE, BLACK);
  M5Cardputer.Display.setCursor(10, 10);
  M5Cardputer.Display.print("Message:");
}

void clearMessageArea() {
  M5Cardputer.Display.fillScreen(BLACK);
  drawMessageLabel();
  txCursorX = 10;
  txCursorY = 30;
}

void showLocalMorse(char symbol) {
  if (txCursorX > 220) {
    txCursorX = 10;
    txCursorY += 20;
  }
  if (txCursorY > 120) {
    clearMessageArea();
  }

  M5Cardputer.Display.setCursor(txCursorX, txCursorY);
  M5Cardputer.Display.setTextColor(WHITE, BLACK);
  M5Cardputer.Display.setTextSize(2);
  M5Cardputer.Display.print(symbol);
  txCursorX += 12;
}

void sendMorse(char symbol) {
  udp.beginPacket(remoteIP, udpPort);
  udp.write(symbol);
  udp.endPacket();
}

void receiveMorse() {
  int packetSize = udp.parsePacket();
  if (packetSize > 0) {
    char symbol;
    udp.read(&symbol, 1);

    unsigned long now = millis();
    if (now - lastReceiveTime > ditLen * 7 && morseLine.length() > 0) {
      txCursorX = 10;
      txCursorY += 20;
      if (txCursorY > 120) {
        clearMessageArea();
      }
      morseLine = "";
    }

    morseLine += symbol;
    lastReceiveTime = now;

    if (symbol == '.') {
      M5Cardputer.Speaker.tone(1000, 100);
    } else if (symbol == '-') {
      M5Cardputer.Speaker.tone(1000, 300);
    }

    showLocalMorse(symbol);
  }
}

String getStoredPassword(const String& ssid) {
  String path = "/" + ssid;
  if (SPIFFS.exists(path)) {
    File file = SPIFFS.open(path, "r");
    if (file) {
      String pw = file.readStringUntil('\n');
      file.close();
      return pw;
    }
  }
  return "";
}

void storePassword(const String& ssid, const String& password) {
  String path = "/" + ssid;
  File file = SPIFFS.open(path, "w");
  if (file) {
    file.println(password);
    file.close();
  }
}

void connectWiFi() {
  M5Cardputer.Display.fillScreen(BLACK);
  M5Cardputer.Display.setTextColor(WHITE);
  M5Cardputer.Display.setTextSize(1);
  M5Cardputer.Display.setCursor(10, 10);
  M5Cardputer.Display.println("Scanning networks...");

  int n = WiFi.scanNetworks();
  if (n == 0) {
    M5Cardputer.Display.println("No networks found.");
    while (true);
  }

  std::vector<String> ssids;
  for (int i = 0; i < n; i++) {
    ssids.push_back(WiFi.SSID(i));
  }

  int selected = 0;
  bool confirmed = false;
  while (!confirmed) {
    M5Cardputer.Display.fillScreen(BLACK);
    M5Cardputer.Display.setCursor(10, 10);
    M5Cardputer.Display.println("Select WiFi:");

    for (int i = 0; i < ssids.size(); i++) {
      M5Cardputer.Display.setCursor(20, 30 + i * 15);
      if (i == selected) M5Cardputer.Display.print("> ");
      else M5Cardputer.Display.print("  ");
      M5Cardputer.Display.print(ssids[i]);
    }

    M5Cardputer.update();
    if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
      auto keys = M5Cardputer.Keyboard.keysState();
      for (char c : keys.word) {
        if (c == '.') selected = (selected + 1) % ssids.size();
        if (c == ';') selected = (selected - 1 + ssids.size()) % ssids.size();
      }
      if (keys.enter) confirmed = true;
    }

    delay(100);
  }

  String chosenSSID = ssids[selected];
  String storedPW = getStoredPassword(chosenSSID);
  String enteredPW = storedPW;
  bool done = false;

  while (!done) {
    M5Cardputer.Display.fillScreen(BLACK);
    M5Cardputer.Display.setCursor(10, 10);
    M5Cardputer.Display.print("SSID: ");
    M5Cardputer.Display.println(chosenSSID);
    M5Cardputer.Display.setCursor(10, 40);
    M5Cardputer.Display.println("Enter Password:");
    M5Cardputer.Display.setCursor(10, 70);
    M5Cardputer.Display.print("> ");
    M5Cardputer.Display.print(enteredPW);

    M5Cardputer.update();
    if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
      auto keys = M5Cardputer.Keyboard.keysState();
      for (char c : keys.word) {
        if (isPrintable(c) && enteredPW.length() < 32) enteredPW += c;
      }
      if (keys.del && enteredPW.length() > 0) enteredPW.remove(enteredPW.length() - 1);
      if (keys.enter) done = true;
    }

    delay(50);
  }

  if (enteredPW != storedPW) {
    storePassword(chosenSSID, enteredPW);
  }

  WiFi.begin(chosenSSID.c_str(), enteredPW.c_str());
  M5Cardputer.Display.fillScreen(BLACK);
  M5Cardputer.Display.setCursor(10, 30);
  M5Cardputer.Display.println("Connecting...");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    M5Cardputer.Display.print(".");
  }
  M5Cardputer.Display.println("\nConnected!");
  delay(1000);
  clearMessageArea();
}

void promptForRemoteIP() {
  IPAddress local = WiFi.localIP();
  int base1 = local[0];
  int base2 = local[1];
  int base3 = local[2];

  String input = "";
  bool confirmed = false;

  M5Cardputer.Display.fillScreen(BLACK);
  M5Cardputer.Display.setTextColor(WHITE);
  M5Cardputer.Display.setTextSize(2);
  M5Cardputer.Display.setCursor(10, 10);
  M5Cardputer.Display.printf("My IP: %d.%d.%d.%d", base1, base2, base3, local[3]);

  M5Cardputer.Display.setCursor(10, 50);
  M5Cardputer.Display.print("Remote IP Octet:");

  M5Cardputer.Display.setCursor(10, 85);
  M5Cardputer.Display.print("Press ENTER when done");

  M5Cardputer.Display.setCursor(10, 120);
  M5Cardputer.Display.print("> ");

  while (!confirmed) {
    M5Cardputer.update();
    if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
      auto keys = M5Cardputer.Keyboard.keysState();
      for (char c : keys.word) {
        if (isdigit(c) && input.length() < 3) input += c;
      }
      if (keys.del && input.length() > 0) input.remove(input.length() - 1);
      if (keys.enter && input.length() > 0) {
        int lastOctet = input.toInt();
        remoteIP = IPAddress(base1, base2, base3, lastOctet);
        confirmed = true;
      }

      M5Cardputer.Display.fillRect(40, 120, 180, 24, BLACK);
      M5Cardputer.Display.setCursor(40, 120);
      M5Cardputer.Display.print(input);
    }
  }

  M5Cardputer.Display.fillScreen(BLACK);
  M5Cardputer.Display.setCursor(10, 60);
  M5Cardputer.Display.setTextColor(GREEN);
  M5Cardputer.Display.printf("Remote IP: %d.%d.%d.%d",
                             remoteIP[0], remoteIP[1], remoteIP[2], remoteIP[3]);
  delay(1500);
  clearMessageArea();
}

void setup() {
  auto cfg = M5.config();
  M5Cardputer.begin(cfg);
  SPIFFS.begin(true);
  pinMode(groveG1Pin, INPUT_PULLUP);
  pinMode(groveG2Pin, INPUT_PULLUP);
  Serial.begin(115200);
  clearMessageArea();
  connectWiFi();
  promptForRemoteIP();
  udp.begin(udpPort);
}

void loop() {
  M5Cardputer.update();

  int g1State = digitalRead(groveG1Pin);
  int g2State = digitalRead(groveG2Pin);

  if (g1State == LOW && lastG1State == HIGH) {
    M5Cardputer.Speaker.tone(1000, 300);
    showLocalMorse('-');
    sendMorse('-');
  }

  if (g2State == LOW && lastG2State == HIGH) {
    M5Cardputer.Speaker.tone(1000, 100);
    showLocalMorse('.');
    sendMorse('.');
  }

  receiveMorse();

  lastG1State = g1State;
  lastG2State = g2State;

  delay(100);
}

Gettysburg.txt

Arduino
a text file for the CW_FilePlayer to play - place the .txt file on the cardputer sd card.
Four score and seven years ago our fathers brought forth on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal.
Now we are engaged in a great civil war, testing whether that nation, or any nation so conceived and so dedicated, can long endure. We are met on a great battle-field of that war. We have come to dedicate a portion of that field, as a final resting place for those who here gave their lives that that nation might live. It is altogether fitting and proper that we should do this.
But, in a larger sense, we can not dedicatewe can not consecratewe can not hallowthis ground. The brave men, living and dead, who struggled here, have consecrated it, far above our poor power to add or detract. The world will little note, nor long remember what we say here, but it can never forget what they did here. It is for us the living, rather, to be dedicated here to the unfinished work which they who fought here have thus far so nobly advanced. It is rather for us to be here dedicated to the great task remaining before usthat from these honored dead we take increased devotion to that cause for which they gave the last full measure of devotionthat we here highly resolve that these dead shall not have died in vainthat this nation, under God, shall have a new birth of freedomand that government of the people, by the people, for the people, shall not perish from the earth.
A. Lincoln 1863

CW_Straight-Key_Oscillator2.1

Arduino
morse code oscillator for a straight key using a grove port connection
// M5 CW Straight-Key Oscillator
// Version: 2.1
// Author: Matthew Sparks N6MMS
// Platform: M5Cardputer (M5Unified + M5Cardputer 1.0.3)
// License: GPLv3
//
// 2.1 fixes:
// - Append logic no longer clears before every new dot/dash.
// - On first element after WPM screen, set firstAppend=false so stream persists.
// - On truncation, refresh interior without calling resetCWStream() (which re-set firstAppend=true).
//
// Controls:
//   ';'  -> WPM up   (5..25)
//   '.'  -> WPM down
//
// Idle behavior:
//   - Insert a single space at 3 dits (inter-letter).
//   - Clear the CW box only after 6 dits idle *and* a space was already inserted.

#include <M5Unified.h>
#include <M5Cardputer.h>

const int groveG1Pin   = 1;     // Straight key input (active LOW)
const int sidetoneHz   = 700;   // Sidetone frequency
const int debounceMs   = 10;    // Debounce delay

// Mutable WPM and derived timing
float measuredWPM = 8.0f;
float ditMs, dotDashThresholdMs, letterGapMs, resetGapMs;

const float WPM_MIN = 5.0f;
const float WPM_MAX = 25.0f;

bool keyDown                   = false;
unsigned long lastChangeMs     = 0;
unsigned long pressStartMs     = 0;
unsigned long lastElementEndMs = 0;

// UI box
const int BOX_X = 8;
const int BOX_Y = 90;
const int BOX_W = 225;
const int BOX_H = 40;

String cwLine;
const int CW_MAX_CHARS = 36;

bool showingWPM = true;          // CW box currently shows "CW: <WPM> WPM"
bool firstAppend = true;         // clear box on first Morse char, then set false
bool letterSpaceInserted = false;

// ---------- UI helpers ----------
void drawTitle() {
  M5.Lcd.clear(BLACK);
  M5.Lcd.setTextSize(2);
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.setCursor(10, 10);  M5.Lcd.println("Straight-Key");
  M5.Lcd.setCursor(10, 35);  M5.Lcd.print("       Oscillator");
  M5.Lcd.setCursor(10, 55);  M5.Lcd.print("By       N6MMS");
}

void drawCWFrameOnce() {
  M5.Lcd.drawRect(BOX_X, BOX_Y, BOX_W, BOX_H, GREEN);
}

void clearCWInterior() {
  M5.Lcd.fillRect(BOX_X + 2, BOX_Y + 2, BOX_W - 4, BOX_H - 4, BLACK);
}

void showLabelCW() {
  M5.Lcd.setCursor(BOX_X + 6, BOX_Y + 10);
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.print("CW:");
}

void showWPMBox() {
  clearCWInterior();
  showLabelCW();
  M5.Lcd.setCursor(BOX_X + 6 + 34, BOX_Y + 10); // after "CW:"
  M5.Lcd.printf("%.0f WPM", measuredWPM);
  showingWPM = true;
  firstAppend = true;
  letterSpaceInserted = false;
}

void startCWStreamIfNeeded() {
  if (showingWPM || firstAppend) {
    // Switch from WPM notice to live stream
    clearCWInterior();
    showLabelCW();
    showingWPM = false;
    firstAppend = false;  // IMPORTANT: allow subsequent appends to persist
    letterSpaceInserted = false;
  }
}

void printCWLine() {
  M5.Lcd.setCursor(BOX_X + 6 + 34, BOX_Y + 10); // after "CW:"
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.print(cwLine);
}

void truncateAndRefresh() {
  // Trim to last CW_MAX_CHARS and refresh interior without resetting firstAppend
  if ((int)cwLine.length() > CW_MAX_CHARS) {
    cwLine = cwLine.substring(cwLine.length() - CW_MAX_CHARS);
    clearCWInterior();
    showLabelCW();
  }
}

void appendCWChar(char c) {
  startCWStreamIfNeeded();

  cwLine += c;
  truncateAndRefresh();
  printCWLine();
}

// ---------- Timing ----------
void applyWPM(float wpm) {
  measuredWPM = wpm;
  if (measuredWPM < WPM_MIN) measuredWPM = WPM_MIN;
  if (measuredWPM > WPM_MAX) measuredWPM = WPM_MAX;

  ditMs               = 1200.0f / measuredWPM;
  dotDashThresholdMs  = 2.0f * ditMs; // dot vs dash split
  letterGapMs         = 3.0f * ditMs; // inter-letter
  resetGapMs          = 6.0f * ditMs; // clear after long idle (post-space)

  showWPMBox(); // immediately reflect WPM in CW box
}

// ---------- Straight-key handling ----------
void handleKeyTransitions(unsigned long now, bool currentDown) {
  // Debounced press
  if (!keyDown && currentDown && (now - lastChangeMs > debounceMs)) {
    keyDown = true;
    lastChangeMs = now;
    pressStartMs = now;
    M5.Speaker.tone(sidetoneHz);
  }

  // Debounced release
  if (keyDown && !currentDown && (now - lastChangeMs > debounceMs)) {
    keyDown = false;
    lastChangeMs = now;
    unsigned long pressDur = now - pressStartMs;
    M5.Speaker.stop();

    appendCWChar((float)pressDur < dotDashThresholdMs ? '.' : '-');

    lastElementEndMs = now;
    letterSpaceInserted = false; // new element => same letter
  }
}

// ---------- Keyboard (M5Cardputer 1.0.3 API) ----------
void pollWPMKeys() {
  if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
    Keyboard_Class::KeysState ks = M5Cardputer.Keyboard.keysState();
    for (auto ch : ks.word) {
      if (ch == ';') { applyWPM(measuredWPM + 1.0f); }
      if (ch == '.') { applyWPM(measuredWPM - 1.0f); }
    }
  }
}

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  M5Cardputer.begin(cfg, true);

  pinMode(groveG1Pin, INPUT_PULLUP);
  M5.Speaker.setVolume(70);

  applyWPM(measuredWPM);

  drawTitle();
  drawCWFrameOnce();
  showWPMBox();
}

void loop() {
  M5.update();
  M5Cardputer.update();

  pollWPMKeys();

  unsigned long now = millis();
  bool currentDown = (digitalRead(groveG1Pin) == LOW);
  handleKeyTransitions(now, currentDown);

  // Inter-letter space + clear logic while key is up
  if (!currentDown && lastElementEndMs != 0) {
    unsigned long idle = now - lastElementEndMs;

    // Insert a single space after 3 dits (separates letters in-stream)
    if (!showingWPM && cwLine.length() > 0 && !letterSpaceInserted && idle >= (unsigned long)letterGapMs) {
      appendCWChar(' ');
      letterSpaceInserted = true;
    }

    // Clear only if we've already separated letters AND idle >= 6 dits
    if (!showingWPM && cwLine.length() > 0 && letterSpaceInserted && idle >= (unsigned long)resetGapMs) {
      // Clear the CW area and keep streaming mode (don't revert to WPM)
      cwLine = "";
      clearCWInterior();
      showLabelCW();
      // Keep firstAppend=false so next elements continue appending
      lastElementEndMs = now; // avoid repeated clears
    }
  }
}

M5_morse3.0

Arduino
a morse decoder program for paddles, enter morse it decodes the morse and shows you the letter.
// Program Name: CW Paddle Decoder
// Version     : 3.0
// Date        : 2025-06-06
// Platform    : M5Cardputer
// Author      : Matthew Sparks N6MMS
// EMail       : ProfessorSparks@gmail.com
// Description : Morse code paddle decoder for use on an M5Cardputer, that uses dot/dash paddles on G1/G2,
//               plays tones, and displays decoded letters, punctuation and prosigns.
//               Includes WPM input and error handling.
// Notes       : - Enter WPM 520, default is 15 if none entered.
//               - Uses M5GFX and M5Cardputer libraries.
 /* License      : GNU General Public License v3.0
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

#include <M5Cardputer.h>
#include <M5GFX.h>

const int dotPin = 2;   // Grove G1
const int dashPin = 1;  // Grove G2

const char* version = "3.0";  // Version variable

M5Canvas canvas(&M5Cardputer.Display);

char morsestring[10] = "";
char morseletter[8] = "";
int morselen = 0;
int wpm = 15;
int ditLen = 0;
unsigned long lastInputTime = 0;
bool dotState = false;
bool dashState = false;
bool isProsign = false;

void playTone(int freq, int dur) {
  M5Cardputer.Speaker.tone(freq, dur);
  delay(dur);
  M5Cardputer.Speaker.end();
}

void drawMorseInput() {
  canvas.fillSprite(BLACK);
  canvas.setCursor(0, 0);
  canvas.setTextColor(GREEN, BLACK);
  canvas.setTextSize(1.25);  // Set as requested
  canvas.println("Morse:");
  canvas.print("    ");
  canvas.println(morsestring);
  canvas.pushSprite(0, 0);
}

void decodeMorse() {
  strcpy(morseletter, "");
  isProsign = false;
  bool foundmatch = false;

  // Letters
  if (strcmp(morsestring, ".-") == 0) strcpy(morseletter, "A");
  else if (strcmp(morsestring, "-...") == 0) strcpy(morseletter, "B");
  else if (strcmp(morsestring, "-.-.") == 0) strcpy(morseletter, "C");
  else if (strcmp(morsestring, "-..") == 0) strcpy(morseletter, "D");
  else if (strcmp(morsestring, ".") == 0) strcpy(morseletter, "E");
  else if (strcmp(morsestring, "..-.") == 0) strcpy(morseletter, "F");
  else if (strcmp(morsestring, "--.") == 0) strcpy(morseletter, "G");
  else if (strcmp(morsestring, "....") == 0) strcpy(morseletter, "H");
  else if (strcmp(morsestring, "..") == 0) strcpy(morseletter, "I");
  else if (strcmp(morsestring, ".---") == 0) strcpy(morseletter, "J");
  else if (strcmp(morsestring, "-.-") == 0) strcpy(morseletter, "K");
  else if (strcmp(morsestring, ".-..") == 0) strcpy(morseletter, "L");
  else if (strcmp(morsestring, "--") == 0) strcpy(morseletter, "M");
  else if (strcmp(morsestring, "-.") == 0) strcpy(morseletter, "N");
  else if (strcmp(morsestring, "---") == 0) strcpy(morseletter, "O");
  else if (strcmp(morsestring, ".--.") == 0) strcpy(morseletter, "P");
  else if (strcmp(morsestring, "--.-") == 0) strcpy(morseletter, "Q");
  else if (strcmp(morsestring, ".-.") == 0) strcpy(morseletter, "R");
  else if (strcmp(morsestring, "...") == 0) strcpy(morseletter, "S");
  else if (strcmp(morsestring, "-") == 0) strcpy(morseletter, "T");
  else if (strcmp(morsestring, "..-") == 0) strcpy(morseletter, "U");
  else if (strcmp(morsestring, "...-") == 0) strcpy(morseletter, "V");
  else if (strcmp(morsestring, ".--") == 0) strcpy(morseletter, "W");
  else if (strcmp(morsestring, "-..-") == 0) strcpy(morseletter, "X");
  else if (strcmp(morsestring, "-.--") == 0) strcpy(morseletter, "Y");
  else if (strcmp(morsestring, "--..") == 0) strcpy(morseletter, "Z");

  // Numbers
  else if (strcmp(morsestring, ".----") == 0) strcpy(morseletter, "1");
  else if (strcmp(morsestring, "..---") == 0) strcpy(morseletter, "2");
  else if (strcmp(morsestring, "...--") == 0) strcpy(morseletter, "3");
  else if (strcmp(morsestring, "....-") == 0) strcpy(morseletter, "4");
  else if (strcmp(morsestring, ".....") == 0) strcpy(morseletter, "5");
  else if (strcmp(morsestring, "-....") == 0) strcpy(morseletter, "6");
  else if (strcmp(morsestring, "--...") == 0) strcpy(morseletter, "7");
  else if (strcmp(morsestring, "---..") == 0) strcpy(morseletter, "8");
  else if (strcmp(morsestring, "----.") == 0) strcpy(morseletter, "9");
  else if (strcmp(morsestring, "-----") == 0) strcpy(morseletter, "0");

  // Punctuation
  else if (strcmp(morsestring, "-.-.--") == 0 || strcmp(morsestring, "..--.") == 0) strcpy(morseletter, "!");
  else if (strcmp(morsestring, "...-..-") == 0) strcpy(morseletter, "$");
  else if (strcmp(morsestring, ".-...") == 0) strcpy(morseletter, "&");
  else if (strcmp(morsestring, ".----.") == 0) strcpy(morseletter, "'");
  else if (strcmp(morsestring, "-.--.") == 0) strcpy(morseletter, "(");
  else if (strcmp(morsestring, "-.--.-") == 0) strcpy(morseletter, ")");
  else if (strcmp(morsestring, ".-.-.") == 0) strcpy(morseletter, "+");
  else if (strcmp(morsestring, "--..--") == 0) strcpy(morseletter, ",");
  else if (strcmp(morsestring, "-....-") == 0) strcpy(morseletter, "-");
  else if (strcmp(morsestring, ".-.-.-") == 0) strcpy(morseletter, ".");
  else if (strcmp(morsestring, "-..-.") == 0) strcpy(morseletter, "/");
  else if (strcmp(morsestring, "---:") == 0) strcpy(morseletter, ":");
  else if (strcmp(morsestring, "-.-.-.") == 0) strcpy(morseletter, ";");
  else if (strcmp(morsestring, "..--..") == 0) strcpy(morseletter, "?");
  else if (strcmp(morsestring, ".--.-.") == 0) strcpy(morseletter, "@");
  else if (strcmp(morsestring, "..--.-") == 0) strcpy(morseletter, "_");

  // Prosigns
  else if (strcmp(morsestring, ".-.-") == 0) { strcpy(morseletter, "AA"); isProsign = true; }
  // else if (strcmp(morsestring, ".-.-.") == 0) { strcpy(morseletter, "AR"); isProsign = true; }// this prosign is the same CW as the +.
  else if (strcmp(morsestring, "-...-.-") == 0) { strcpy(morseletter, "BK"); isProsign = true; }
  else if (strcmp(morsestring, "-...-") == 0) { strcpy(morseletter, "BT"); isProsign = true; }
  else if (strcmp(morsestring, "-.-..-..") == 0) { strcpy(morseletter, "CL"); isProsign = true; }
  else if (strcmp(morsestring, "-.-.-") == 0) { strcpy(morseletter, "CT"); isProsign = true; }
  else if (strcmp(morsestring, "-.--.") == 0) { strcpy(morseletter, "KN"); isProsign = true; }
  else if (strcmp(morsestring, "...-.-") == 0) { strcpy(morseletter, "SK"); isProsign = true; }
  else if (strcmp(morsestring, "...-.") == 0) { strcpy(morseletter, "SN"); isProsign = true; }
  else if (strcmp(morsestring, "...---...") == 0) { strcpy(morseletter, "SOS"); isProsign = true; }
  else if (strcmp(morsestring, "-.-.--.-") == 0) { strcpy(morseletter, "CQ"); isProsign = true; }
  else if (strcmp(morsestring, "........") == 0) { strcpy(morseletter, "Err"); isProsign = true; } // also shown as HH

  if (strlen(morseletter) == 0) {
    M5Cardputer.Display.fillScreen(RED);
    M5Cardputer.Display.setTextColor(BLACK);
    M5Cardputer.Display.setTextFont(&fonts::FreeSerifBoldItalic18pt7b);
    M5Cardputer.Display.setTextSize(2);
    int16_t centerX = (M5Cardputer.Display.width() / 2) - 12;
    int16_t centerY = (M5Cardputer.Display.height() / 2) - 20;
    M5Cardputer.Display.setCursor(centerX, centerY);
    M5Cardputer.Display.print("?");
    playTone(300, ditLen * 3);
    delay(ditLen * 2);
    M5Cardputer.Display.fillScreen(BLACK);
    strcpy(morsestring, "");
    drawMorseInput();
    return;
  }

  M5Cardputer.Display.fillScreen(BLACK);
  M5Cardputer.Display.setTextColor(GREEN);
  M5Cardputer.Display.setTextFont(&fonts::FreeSerifBoldItalic18pt7b);
  M5Cardputer.Display.setTextSize(2);
  int16_t x = (M5Cardputer.Display.width() / 2) - 32;
  int16_t y = (M5Cardputer.Display.height() / 2) - 24;
  if (isProsign) {
    M5Cardputer.Display.fillRect(x - 8, y - 20, 120, 8, GREEN);  // Thick line
  }
  M5Cardputer.Display.setCursor(x, y);
  M5Cardputer.Display.print(morseletter);

  for (int i = 0; i < strlen(morsestring); i++) {
    playTone(1000, morsestring[i] == '.' ? ditLen : ditLen * 3);
    delay(ditLen);
  }

  delay(ditLen * 2);
  strcpy(morsestring, "");
  drawMorseInput();
}

void promptWPM() {
  canvas.setTextSize(0.65);
  canvas.fillSprite(BLACK);
  canvas.setTextColor(GREEN, BLACK);
  canvas.setCursor(0, 0);
  canvas.printf("CWPaddle v%s\n", version);
  canvas.println("(M5Cardputer)");
  canvas.println("");
  canvas.println("Enter WPM (5-20):");
  canvas.pushSprite(0, 0);

  String input = "> ";
  bool confirmed = false;

  while (!confirmed) {
    M5Cardputer.update();
    if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
      auto keys = M5Cardputer.Keyboard.keysState();

      for (auto c : keys.word) {
        if (isdigit(c)) input += c;
      }

      if (keys.del && input.length() > 2) {
        input.remove(input.length() - 1);
      }

      if (keys.enter) {
        if (input.length() <= 2) {
          // User pressed enter with no digits  use default
          wpm = 15;
          confirmed = true;
        } else {
          int val = input.substring(2).toInt();
          if (val >= 5 && val <= 20) {
            wpm = val;
            confirmed = true;
          } else {
            input = "> ";
          }
        }
      }

      canvas.fillSprite(BLACK);
      canvas.setCursor(0, 0);
      canvas.setTextSize(0.65);
      canvas.printf("CWPaddle v%s\n", version);
      canvas.println("(M5Cardputer)");
      canvas.println("");
      canvas.println("Enter WPM (520):");
      canvas.println(input);
      canvas.pushSprite(0, 0);
    }
  }

  ditLen = 1200 / wpm;
}


void setup() {
  auto cfg = M5.config();
  M5Cardputer.begin(cfg, true);
  M5Cardputer.Display.setRotation(1);
  canvas.createSprite(M5Cardputer.Display.width(), M5Cardputer.Display.height());
  canvas.setTextFont(&fonts::FreeSerifBoldItalic18pt7b);
  canvas.setTextSize(0.5);
  canvas.setTextColor(GREEN);
  canvas.fillSprite(BLACK);
  canvas.pushSprite(0, 0);

  pinMode(dotPin, INPUT_PULLUP);
  pinMode(dashPin, INPUT_PULLUP);

  promptWPM();
  drawMorseInput();
}

void loop() {
  M5Cardputer.update();
  dotState = digitalRead(dotPin) == LOW;
  dashState = digitalRead(dashPin) == LOW;

  if (dotState) {
    strcat(morsestring, ".");
    playTone(1000, ditLen);
    drawMorseInput();
    delay(ditLen);
    lastInputTime = millis();
  } else if (dashState) {
    strcat(morsestring, "-");
    playTone(1000, ditLen * 3);
    drawMorseInput();
    lastInputTime = millis();
  }

  if (strlen(morsestring) > 0 && millis() - lastInputTime > ditLen * 5) {
    decodeMorse();
    delay(ditLen * 2);
  }
}

CW_Straight_key_Decoder 2.8

Arduino
a morse decoder program for a straight key with adjustable words per minute.
// M5 Straight-Key CW Decoder
// Version: 2.8
// Author: Matthew Sparks N6MMS
// Platform: M5Cardputer (M5Unified + M5Cardputer 1.0.3)
// License: GPLv3
//
// Whats new in 2.8
// - Revert to compact screen-safe text (setTextSize(2)) like v2.6.
// - Add numbers & punctuation decoding (AZ, 09, common symbols).
// - Keep decoded output visible until next keypress or 7-dits idle.
// - Decode idle gap = 4 dits; clear BOTH boxes after 8 dits.
//
// Controls
// - Straight key on G1 (active LOW).
// - ; to increase WPM, . to decrease WPM (5..25).

#include <M5Unified.h>
#include <M5Cardputer.h>

// --- Hardware / timing ---
const int groveG1Pin   = 1;     // Straight key input (active LOW)
const int sidetoneHz   = 700;   // Sidetone frequency
const int debounceMs   = 10;    // Debounce delay

float measuredWPM = 8.0f;
float ditMs, dotDashThresholdMs, letterGapMs, wordGapMs, resetGapMs;
const float WPM_MIN = 5.0f, WPM_MAX = 25.0f;

bool keyDown = false;
unsigned long lastChangeMs = 0;
unsigned long pressStartMs = 0;
unsigned long lastElementEndMs = 0;

// --- UI layout ---
int16_t DISP_W = 240, DISP_H = 135;
int BOX_X = 8, BOX_Y = 50, BOX_W = 224, BOX_H = 40;   // CW box (green)
int DECODED_BOX_Y = 100;                               // Decoded box (red)

// --- State ---
String seq;                         // current dot/dash sequence
String lastDecoded = "";            // what is currently shown in decoded box
bool showingWPM = true;             // box shows "CW: <WPM> WPM"
bool firstAppend = true;            // first element switches to stream
bool letterSpaceInserted = false;   // used for visual reset timing

const int SEQ_MAX_CHARS = 16;

// ---------------- UI helpers ----------------
void computeLayout() {
  DISP_W = M5.Display.width();
  DISP_H = M5.Display.height();

  BOX_X = 8;
  BOX_W = DISP_W - 2 * BOX_X;   // leave small margins
  BOX_H = 40;
  BOX_Y = (DISP_H >= 135) ? 48 : max(10, DISP_H / 4);

  DECODED_BOX_Y = BOX_Y + BOX_H + 12;
  if (DECODED_BOX_Y + BOX_H > DISP_H - 4) {
    DECODED_BOX_Y = max(BOX_Y + BOX_H + 4, DISP_H - BOX_H - 4);
  }
}

void drawTitle() {
  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setTextSize(2);
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.setCursor(10, 10);  M5.Lcd.println("Straight-Key");
  M5.Lcd.setCursor(10, 30);  M5.Lcd.print("Decoder");
  M5.Lcd.setCursor(10, 50);  M5.Lcd.print("By N6MMS");
}

void drawFramesOnce() {
  // CW box (green)
  M5.Lcd.drawRect(BOX_X, BOX_Y, BOX_W, BOX_H, GREEN);
  // Decoded box (red)
  M5.Lcd.drawRect(BOX_X, DECODED_BOX_Y, BOX_W, BOX_H, RED);
}

void clearCWInterior() {
  M5.Lcd.fillRect(BOX_X + 2, BOX_Y + 2, BOX_W - 4, BOX_H - 4, BLACK);
}

void clearDecodedBox() {
  M5.Lcd.fillRect(BOX_X + 2, DECODED_BOX_Y + 2, BOX_W - 4, BOX_H - 4, BLACK);
}

void showLabelCW() {
  M5.Lcd.setCursor(BOX_X + 6, BOX_Y + 10);
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.print("CW:");
}

void showWPMBox() {
  clearCWInterior();
  showLabelCW();
  M5.Lcd.setCursor(BOX_X + 40, BOX_Y + 10);   // after "CW:"
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.printf("%.0f WPM", measuredWPM);
  showingWPM = true;
  firstAppend = true;
  letterSpaceInserted = false;

  // Initialize/refresh decoded box header
  clearDecodedBox();
  M5.Lcd.setCursor(BOX_X + 6, DECODED_BOX_Y + 10);
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.print("Decoded:");
  lastDecoded = "";
}

void startCWStreamIfNeeded() {
  if (showingWPM || firstAppend) {
    clearCWInterior();
    showLabelCW();
    showingWPM = false;
    firstAppend = false;
    letterSpaceInserted = false;
  }
}

void resetCWBoxOnly() {
  clearCWInterior();
  showLabelCW();
}

void printSeqInBox() {
  M5.Lcd.setCursor(BOX_X + 40, BOX_Y + 10);
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.print(seq);
}

void printDecodedText(const String& text) {
  clearDecodedBox();
  M5.Lcd.setCursor(BOX_X + 6, DECODED_BOX_Y + 10);
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.print("Decoded: ");
  M5.Lcd.setTextColor(GREEN, BLACK);
  M5.Lcd.print(text);
  lastDecoded = text;
}

// --------------- Decoder (letters, numbers, punctuation) ---------------
bool decodeSymbol(const String& s, String& out) {
  // Letters AZ
  if      (s == ".-")    out = "A";
  else if (s == "-...")  out = "B";
  else if (s == "-.-.")  out = "C";
  else if (s == "-..")   out = "D";
  else if (s == ".")     out = "E";
  else if (s == "..-.")  out = "F";
  else if (s == "--.")   out = "G";
  else if (s == "....")  out = "H";
  else if (s == "..")    out = "I";
  else if (s == ".---")  out = "J";
  else if (s == "-.-")   out = "K";
  else if (s == ".-..")  out = "L";
  else if (s == "--")    out = "M";
  else if (s == "-.")    out = "N";
  else if (s == "---")   out = "O";
  else if (s == ".--.")  out = "P";
  else if (s == "--.-")  out = "Q";
  else if (s == ".-.")   out = "R";
  else if (s == "...")   out = "S";
  else if (s == "-")     out = "T";
  else if (s == "..-")   out = "U";
  else if (s == "...-")  out = "V";
  else if (s == ".--")   out = "W";
  else if (s == "-..-")  out = "X";
  else if (s == "-.--")  out = "Y";
  else if (s == "--..")  out = "Z";
  // Numbers 09
  else if (s == "-----") out = "0";
  else if (s == ".----") out = "1";
  else if (s == "..---") out = "2";
  else if (s == "...--") out = "3";
  else if (s == "....-") out = "4";
  else if (s == ".....") out = "5";
  else if (s == "-....") out = "6";
  else if (s == "--...") out = "7";
  else if (s == "---..") out = "8";
  else if (s == "----.") out = "9";
  // Punctuation (common)
  else if (s == ".-.-.-") out = ".";
  else if (s == "--..--") out = ",";
  else if (s == "..--..") out = "?";
  else if (s == ".----.") out = "'";
  else if (s == "-.-.--") out = "!";
  else if (s == "-..-.")  out = "/";
  else if (s == "-.--.")  out = "(";
  else if (s == "-.--.-") out = ")";
  else if (s == ".-...")  out = "&";
  else if (s == "---...") out = ":";
  else if (s == "-.-.-.") out = ";";
  else if (s == "-...-")  out = "=";
  else if (s == ".-.-.")  out = "+";
  else if (s == "-....-") out = "-";
  else if (s == "..--.-") out = "_";
  else if (s == ".--.-.") out = "@";
  else return false;
  return true;
}

// ---------------- Timing / WPM ----------------
void applyWPM(float wpm) {
  measuredWPM = constrain(wpm, WPM_MIN, WPM_MAX);
  ditMs              = 1200.0f / measuredWPM;
  dotDashThresholdMs = 2.0f * ditMs;   // dot/dash split
  letterGapMs        = 4.0f * ditMs;   // decode letter after this idle
  wordGapMs          = 7.0f * ditMs;   // keep decoded visible until this idle
  resetGapMs         = 8.0f * ditMs;   // clear BOTH boxes after longer idle
  showWPMBox();
}

// -------- Straight-key handling (G1) --------
void handleKeyTransitions(unsigned long now, bool currentDown) {
  // Debounced press
  if (!keyDown && currentDown && (now - lastChangeMs > debounceMs)) {
    keyDown = true;
    lastChangeMs = now;
    pressStartMs = now;
    M5.Speaker.tone(sidetoneHz);

    // If a decoded symbol is shown, hide it as we begin a new letter
    if (lastDecoded.length() > 0) {
      clearDecodedBox();
      M5.Lcd.setCursor(BOX_X + 6, DECODED_BOX_Y + 10);
      M5.Lcd.setTextColor(WHITE, BLACK);
      M5.Lcd.print("Decoded:");
      lastDecoded = "";
    }
  }

  // Debounced release
  if (keyDown && !currentDown && (now - lastChangeMs > debounceMs)) {
    keyDown = false;
    lastChangeMs = now;
    unsigned long pressDur = now - pressStartMs;
    M5.Speaker.stop();

    startCWStreamIfNeeded();

    // Append element
    seq += ((float)pressDur < dotDashThresholdMs) ? '.' : '-';
    if ((int)seq.length() > SEQ_MAX_CHARS) {
      seq = seq.substring(seq.length() - SEQ_MAX_CHARS);
    }
    resetCWBoxOnly();
    printSeqInBox();

    lastElementEndMs = now;
    letterSpaceInserted = false; // still within a letter
  }
}

// --------------- Keyboard (WPM adjust) ---------------
void pollWPMKeys() {
  if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
    auto ks = M5Cardputer.Keyboard.keysState();
    for (auto ch : ks.word) {
      if (ch == ';') applyWPM(measuredWPM + 1.0f);
      if (ch == '.') applyWPM(measuredWPM - 1.0f);
    }
  }
}

// --------------- Decode on idle ---------------
void maybeDecodeOnIdle(unsigned long now, bool currentDown) {
  if (currentDown || lastElementEndMs == 0) return;
  unsigned long idle = now - lastElementEndMs;

  // Decode at letter gap (4 dits)
  if (!showingWPM && seq.length() > 0 && idle >= (unsigned long)letterGapMs) {
    String sym;
    if (decodeSymbol(seq, sym)) {
      printDecodedText(sym);
    } else {
      printDecodedText("Error");
    }
    // Prepare for next letter
    seq = "";
    resetCWBoxOnly();
    letterSpaceInserted = true;  // we've separated letters
  }

  // If a decoded symbol is visible and we reach a word gap (7 dits), clear it
  if (lastDecoded.length() > 0 && idle >= (unsigned long)wordGapMs) {
    clearDecodedBox();
    M5.Lcd.setCursor(BOX_X + 6, DECODED_BOX_Y + 10);
    M5.Lcd.setTextColor(WHITE, BLACK);
    M5.Lcd.print("Decoded:");
    lastDecoded = "";
  }

  // Cosmetic clear after longer idle (8 dits): clear both boxes
  if (!showingWPM && letterSpaceInserted && idle >= (unsigned long)resetGapMs) {
    resetCWBoxOnly();
    clearDecodedBox();
    M5.Lcd.setCursor(BOX_X + 6, DECODED_BOX_Y + 10);
    M5.Lcd.setTextColor(WHITE, BLACK);
    M5.Lcd.print("Decoded:");
    lastElementEndMs = now;
  }
}

// ---------------- Setup / Loop ----------------
void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  M5Cardputer.begin(cfg, true);

  pinMode(groveG1Pin, INPUT_PULLUP);
  M5.Speaker.setVolume(70);

  computeLayout();
  drawTitle();
  drawFramesOnce();

  // Initialize decoded box label
  clearDecodedBox();
  M5.Lcd.setCursor(BOX_X + 6, DECODED_BOX_Y + 10);
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.print("Decoded:");

  applyWPM(measuredWPM); // also shows WPM in the CW box
}

void loop() {
  M5.update();
  M5Cardputer.update();

  pollWPMKeys();

  unsigned long now = millis();
  bool currentDown = (digitalRead(groveG1Pin) == LOW);

  handleKeyTransitions(now, currentDown);
  maybeDecodeOnIdle(now, currentDown);
}

CW_FilePlayer2.5

Arduino
This program reads .txt files off the cardputer sd cards and translates them to morse and plays them outloud so you can practice your translating skills
// CW_FilePlayer
// Version: 2.5 (Fix: file selection with ;/. and ENTER)
// Author: Matthew Sparks N6MMS
// Platform: M5Cardputer
// License: GNU GPL v3.0

#include <M5Cardputer.h>
#include <M5GFX.h>
#include <SD.h>
#include <vector>

int wpm = 15;
float ditlen = 1200.0 / wpm;
std::vector<String> fileList;
int selectedFileIndex = 0;
bool paused = false;
bool stopped = false;

const char* getMorseCodeForChar(char c) {
  switch (toupper(c)) {
    case 'A': return ".-"; case 'B': return "-..."; case 'C': return "-.-.";
    case 'D': return "-.."; case 'E': return "."; case 'F': return "..-.";
    case 'G': return "--."; case 'H': return "...."; case 'I': return "..";
    case 'J': return ".---"; case 'K': return "-.-"; case 'L': return ".-..";
    case 'M': return "--"; case 'N': return "-."; case 'O': return "---";
    case 'P': return ".--."; case 'Q': return "--.-"; case 'R': return ".-.";
    case 'S': return "..."; case 'T': return "-"; case 'U': return "..-";
    case 'V': return "...-"; case 'W': return ".--"; case 'X': return "-..-";
    case 'Y': return "-.--"; case 'Z': return "--..";
    case '0': return "-----"; case '1': return ".----"; case '2': return "..---";
    case '3': return "...--"; case '4': return "....-"; case '5': return ".....";
    case '6': return "-...."; case '7': return "--..."; case '8': return "---..";
    case '9': return "----.";
    case '.': return ".-.-.-"; case ',': return "--..--"; case '?': return "..--..";
    case '!': return "-.-.--"; case '-': return "-....-"; case '/': return "-..-.";
    default: return "";
  }
}

bool hasChar(const std::vector<char>& word, char target) {
  for (char c : word) if (c == target) return true;
  return false;
}

void centerText(const String& text, int y, float size = 1.0) {
  M5Cardputer.Display.setTextSize(size);
  int w = text.length() * 6 * size;
  int x = (M5Cardputer.Display.width() - w) / 2;
  M5Cardputer.Display.setCursor(x, y);
  M5Cardputer.Display.print(text);
}

void showTitleScreen() {
  M5Cardputer.Display.fillScreen(BLACK);
  M5Cardputer.Display.setTextColor(WHITE);
  centerText("CW FilePlayer", 25, 2.0);
  centerText("by N6MMS", 60, 1.5);
  delay(3000);
}

void showWPMScreen() {
  M5Cardputer.Display.fillScreen(BLACK);
  centerText("Enter WPM (5-25,", 20, 2);
  centerText("default = 10):", 45, 1.7);
  centerText(">", 85, 2);

  String input = "";
  bool confirmed = false;

  while (!confirmed) {
    M5Cardputer.update();
    if (M5Cardputer.Keyboard.isChange()) {
      auto keys = M5Cardputer.Keyboard.keysState();
      for (char c : keys.word) {
        if (isdigit(c) && input.length() < 2) input += c;
        else if (c == '\b' && input.length() > 0) input.remove(input.length() - 1);
      }
      if (keys.enter) confirmed = true;

      M5Cardputer.Display.fillRect(10, 110, 300, 20, BLACK);
      M5Cardputer.Display.setCursor((M5Cardputer.Display.width() - input.length() * 6) / 2, 110);
      M5Cardputer.Display.print(input);
    }
    delay(10);
  }

  int entered = input.toInt();
  wpm = (entered >= 5 && entered <= 25) ? entered : 10;
  ditlen = 1200.0 / wpm;
}

void listTextFiles() {
  File root = SD.open("/");
  fileList.clear();
  File file = root.openNextFile();
  while (file) {
    String name = file.name();
    name.toUpperCase();
    if (!file.isDirectory() && name.endsWith(".TXT")) fileList.push_back(name);
    file = root.openNextFile();
  }
}

void showFileSelection() {
  bool confirmed = false;
  while (!confirmed) {
    M5Cardputer.update();
    if (M5Cardputer.Keyboard.isChange()) {
      auto keys = M5Cardputer.Keyboard.keysState();
      if (hasChar(keys.word, ';')) {
        selectedFileIndex = (selectedFileIndex - 1 + fileList.size()) % fileList.size();
        delay(150);
      } else if (hasChar(keys.word, '.')) {
        selectedFileIndex = (selectedFileIndex + 1) % fileList.size();
        delay(150);
      } else if (keys.enter) {
        confirmed = true;
        break;
      }
    }

    M5Cardputer.Display.fillScreen(BLACK);
    centerText("Select File", 10, 1.5);
    int y = 40;
    for (int i = 0; i < fileList.size(); i++) {
      M5Cardputer.Display.setCursor(10, y);
      M5Cardputer.Display.setTextColor(i == selectedFileIndex ? YELLOW : WHITE);
      M5Cardputer.Display.print(fileList[i]);
      y += 18;
    }
    delay(20);
  }
}

void playMorse(const char* code) {
  for (int i = 0; code[i] != '\0'; i++) {
    int duration = (code[i] == '.') ? ditlen : ditlen * 3;
    M5Cardputer.Speaker.tone(800);
    delay(duration);
    M5Cardputer.Speaker.end();
    delay(ditlen);
  }
}

void playChar(char c) {
  c = toupper(c);
  const char* morse = getMorseCodeForChar(c);

  M5Cardputer.Display.fillScreen(BLACK);
  M5Cardputer.Display.setTextColor(WHITE);
  M5Cardputer.Display.setCursor(5, 0);
  M5Cardputer.Display.setTextSize(1.0);
  M5Cardputer.Display.printf("CW Player v2.4   WPM = %d", wpm);

  M5Cardputer.Display.setTextSize(6.0);
  M5Cardputer.Display.setTextColor(GREEN);
  int x = (M5Cardputer.Display.width() - 24) / 2;
  M5Cardputer.Display.setCursor(x-1, 40);
  M5Cardputer.Display.print(c);
  M5Cardputer.Display.setTextColor(WHITE);

  playMorse(morse);
  delay(ditlen * 3);
}

void playFile(String filename) {
  File file = SD.open("/" + filename);
  if (!file) return;
  stopped = false;
  paused = false;

  while (file.available() && !stopped) {
    M5Cardputer.update();
    if (M5Cardputer.Keyboard.isChange()) {
      auto keys = M5Cardputer.Keyboard.keysState();
      for (char c : keys.word) {
        if (c == ' ') { paused = !paused; delay(200); }
        if (c == ';') { wpm = min(25, wpm + 1); ditlen = 1200.0 / wpm; }
        if (c == '.') { wpm = max(5, wpm - 1); ditlen = 1200.0 / wpm; }
      }
      if (keys.enter) { stopped = true; break; }
    }

    if (paused) { delay(100); continue; }

    char c = file.read();
    if (c >= 32 && c <= 126) playChar(c);
  }
  file.close();
}

void setup() {
  auto cfg = M5.config();
  M5Cardputer.begin(cfg);
  M5Cardputer.Display.setRotation(1);
  M5Cardputer.Display.setBrightness(128);
  M5Cardputer.Display.setFont(&fonts::Font0);
  M5Cardputer.Display.setTextColor(WHITE);
  M5Cardputer.Display.setTextSize(1.0);
  M5Cardputer.Display.setTextWrap(false);
  M5Cardputer.Speaker.setVolume(165);

  if (!SD.begin()) {
    M5Cardputer.Display.fillScreen(BLACK);
    M5Cardputer.Display.setTextColor(RED);
    centerText("SD Card Init Failed", 50, 1.5);
    while (1);
  }

  showTitleScreen();
  showWPMScreen();
  listTextFiles();
  showFileSelection();

  if (fileList.size() > 0) playFile(fileList[selectedFileIndex]);

  M5Cardputer.Display.fillScreen(BLACK);
  centerText("Playback complete", 50, 1.5);
}

void loop() {}

Morsegame4.22

Arduino
a simple game to play with a paddle to practice your morse sending alphabet
// CW Paddle Quiz
// Version: 4.22
// Author: Matthew Sparks N6MMS
// Email: ProfessorSparks@gmail.com
// Platform: M5Cardputer
// License: GNU General Public License v3.0

#include <M5Cardputer.h>
#include <M5GFX.h>

const int dotPin = 2;
const int dashPin = 1;

const char* version = "4.22";
const int maxRounds = 25;

M5Canvas canvas(&M5Cardputer.Display);

char morsestring[10] = "";
char morseletter[8] = "";
char currentPrompt[4] = "";
bool includeLetters = true;
bool includeNumbers = true;
bool includePunctuation = false;
int wpm = 15;
int ditLen = 0;
unsigned long lastInputTime = 0;
unsigned long promptStartTime = 0;
unsigned long entryStartTime = 0;
bool dotState = false;
bool dashState = false;
bool startedSending = false;
int totalScore = 0;
int roundScore = 0;
int roundsPlayed = 0;

const struct { const char* ch; const char* code; } morseLookup[] = {
  {"A",".-"}, {"B","-..."}, {"C","-.-."}, {"D","-.."}, {"E","."}, {"F","..-."}, {"G","--."},
  {"H","...."}, {"I",".."}, {"J",".---"}, {"K","-.-"}, {"L",".-.."}, {"M","--"}, {"N","-."},
  {"O","---"}, {"P",".--."}, {"Q","--.-"}, {"R",".-."}, {"S","..."}, {"T","-"}, {"U","..-"},
  {"V","...-"}, {"W",".--"}, {"X","-..-"}, {"Y","-.--"}, {"Z","--.."},
  {"1",".----"}, {"2","..---"}, {"3","...--"}, {"4","....-"}, {"5","....."},
  {"6","-...."}, {"7","--..."}, {"8","---.."}, {"9","----."}, {"0","-----"},
  {"!", "-.-.--"}, {"$", "...-..-"}, {"&", ".-..."}, {"'", ".----."}, {"(", "-.--."},
  {")", "-.--.-"}, {"+", ".-.-."}, {",", "--..--"}, {"-", "-....-"}, {".", ".-.-.-"},
  {"/", "-..-."}, {":", "---..."}, {";", "-.-.-."}, {"?", "..--.."}, {"@", ".--.-."},
  {"_", "..--.-"}
};
const int morseCount = sizeof(morseLookup) / sizeof(morseLookup[0]);

void playTone(int freq, int dur) {
  M5Cardputer.Speaker.tone(freq, dur);
  delay(dur);
  M5Cardputer.Speaker.end();
}

void centerText(const char* text, int y, float size = 2.0) {
  canvas.setTextSize(size);
  int charWidth = 6 * size;
  int textWidth = strlen(text) * charWidth;
  int x = (canvas.width() - textWidth) / 2;
  canvas.setCursor(x > 0 ? x : 0, y);
  canvas.println(text);
}

void titleScreen() {
  canvas.fillSprite(BLACK);
  centerText("CW Paddle Quiz", 20, 2.0);
  centerText("Version 4.22", 60, 2);
  centerText("by N6MMS", 85, 3);
  canvas.pushSprite(0, 0);
  delay(2500);
}

void instructionScreen() {
  canvas.fillSprite(BLACK);
  canvas.setTextSize(1.5);
  centerText("Key Morse to match", 20);
  centerText("the character shown.", 50);
  centerText("25 rounds or 8 dots ends game.", 90, 1.25);
  centerText("Press ENTER to begin.", 120, 1.25);
  canvas.pushSprite(0, 0);
  while (true) {
    M5Cardputer.update();
    if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
      if (M5Cardputer.Keyboard.keysState().enter) break;
    }
  }
}

void promptInclusion() {
  struct { const char* label; bool* flag; bool defaultYes; } prompts[] = {
    {"Letters? (Y/N)", &includeLetters, true},
    {"Numbers? (Y/N)", &includeNumbers, true},
    {"Punctuation? (Y/N)", &includePunctuation, false},
  };

  for (auto& p : prompts) {
    *(p.flag) = p.defaultYes;
    bool decided = false;
    while (!decided) {
      canvas.fillSprite(BLACK);
      centerText(p.label, 30, 2);
      centerText("ENTER = ", 70, 2);
      centerText(p.defaultYes ? "YES" : "NO", 95, 2);
      canvas.pushSprite(0, 0);
      M5Cardputer.update();
      if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
        auto keys = M5Cardputer.Keyboard.keysState();
        if (keys.enter) { *(p.flag) = p.defaultYes; decided = true; }
        else if (keys.word[0] == 'y' || keys.word[0] == 'Y') { *(p.flag) = true; decided = true; }
        else if (keys.word[0] == 'n' || keys.word[0] == 'N') { *(p.flag) = false; decided = true; }
      }
    }
  }
}

void promptWPM() {
  canvas.fillSprite(BLACK);
  centerText("WPM (5 - 20)", 30, 2.0);
  centerText("ENTER = 15 WPM", 60, 2);
  canvas.pushSprite(0, 0);
  String input = "> ";
  bool confirmed = false;
  while (!confirmed) {
    M5Cardputer.update();
    if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
      auto keys = M5Cardputer.Keyboard.keysState();
      for (auto c : keys.word) {
        if (isdigit(c)) input += c;
      }
      if (keys.del && input.length() > 2) input.remove(input.length() - 1);
      if (keys.enter) {
        int val = input.substring(2).toInt();
        wpm = (val >= 5 && val <= 20) ? val : 15;
        confirmed = true;
      }
      canvas.fillSprite(BLACK);
      centerText("WPM (5 - 20)", 30, 2.0);
      centerText("ENTER = 15 WPM", 60, 1.75);
      centerText(input.c_str(), 100, 1.5);
      canvas.pushSprite(0, 0);
    }
  }
  ditLen = 1200 / wpm;
}

void decodeMorse() {
  strcpy(morseletter, "?");
  for (int i = 0; i < morseCount; i++) {
    if (strcmp(morsestring, morseLookup[i].code) == 0) {
      strcpy(morseletter, morseLookup[i].ch);
      break;
    }
  }
  if (strcmp(morseletter, "?") == 0) {
    canvas.fillSprite(RED);
    canvas.setTextColor(WHITE);
    centerText("?", canvas.height() / 2 - 10, 3.0);
    canvas.setTextColor(GREEN);
    canvas.pushSprite(0, 0);
    delay(ditLen * 5);
  }
}

void calculateScore() {
  bool correct = strcasecmp(morseletter, currentPrompt) == 0;
  roundScore = correct ? 100 : -50;
  totalScore += roundScore;
  roundsPlayed++;
}

void showResultsScreen() {
  canvas.fillSprite(BLACK);
  canvas.setTextColor(GREEN);
  char buffer[64];

  snprintf(buffer, sizeof(buffer), "Expected: %s", currentPrompt);
  centerText(buffer, 20, 1.5);

  snprintf(buffer, sizeof(buffer), "You: %s", morseletter);
  centerText(buffer, 50, 1.5);

  snprintf(buffer, sizeof(buffer), "Round: %d / %d", roundsPlayed, maxRounds);
  centerText(buffer, 80, 1.5);

  snprintf(buffer, sizeof(buffer), "Score: %d", totalScore);
  centerText(buffer, 110, 1.5);

  canvas.pushSprite(0, 0);
  delay(1000);
}

void gameOverScreen() {
  canvas.fillSprite(BLACK);
  centerText("Game Over!", 30, 2.0);
  char buf[32];
  snprintf(buf, sizeof(buf), "Final Score: %d", totalScore);
  centerText(buf, 70, 1.75);
  centerText("Press ENTER to restart", 110, 1.25);
  canvas.pushSprite(0, 0);
  while (true) {
    M5Cardputer.update();
    if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
      if (M5Cardputer.Keyboard.keysState().enter) {
        fullRestart();
        break;
      }
    }
  }
}

void newPrompt() {
  int tries = 0;
  while (tries++ < 50) {
    const char* pick = morseLookup[random(morseCount)].ch;
    bool isLetter = isalpha(pick[0]);
    bool isDigit = isdigit(pick[0]);
    bool isPunct = !isLetter && !isDigit;
    if ((isLetter && includeLetters) || (isDigit && includeNumbers) || (isPunct && includePunctuation)) {
      strcpy(currentPrompt, pick);
      break;
    }
  }
  canvas.fillSprite(BLACK);
  canvas.setTextSize(4.5);
  int charWidth = 6 * 6.0;
  int x = (canvas.width() - strlen(currentPrompt) * charWidth) / 2;
  canvas.setCursor(x > 0 ? x : 0, 20);
  canvas.println(currentPrompt);
  canvas.setTextSize(1.25);
  if (strcmp(currentPrompt, "0") == 0)
    centerText("(ZERO)", 100, 2);
  else if (strcmp(currentPrompt, "O") == 0)
    centerText("(LETTER O)", 100, 2);
  canvas.pushSprite(0, 0);
  promptStartTime = millis();
  startedSending = false;
  strcpy(morsestring, "");
  strcpy(morseletter, "");
}

void fullRestart() {
  totalScore = 0;
  roundsPlayed = 0;
  promptWPM();
  promptInclusion();
  newPrompt();
}

void setup() {
  auto cfg = M5.config();
  M5Cardputer.begin(cfg, true);
  M5Cardputer.Display.setRotation(1);
  canvas.createSprite(M5Cardputer.Display.width(), M5Cardputer.Display.height());
  canvas.setTextColor(GREEN);
  pinMode(dotPin, INPUT_PULLUP);
  pinMode(dashPin, INPUT_PULLUP);
  titleScreen();
  instructionScreen();
  promptWPM();
  promptInclusion();
  randomSeed(millis());
  newPrompt();
}

void loop() {
  M5Cardputer.update();
  dotState = digitalRead(dotPin) == LOW;
  dashState = digitalRead(dashPin) == LOW;
  if ((dotState || dashState) && !startedSending) {
    entryStartTime = millis();
    startedSending = true;
  }
  if (dotState) {
    strcat(morsestring, ".");
    playTone(1000, ditLen);
    delay(ditLen);
    lastInputTime = millis();
  } else if (dashState) {
    strcat(morsestring, "-");
    playTone(1000, ditLen * 3);
    lastInputTime = millis();
  }
  if (strlen(morsestring) >= 8 && strcmp(morsestring, "........") == 0) {
    gameOverScreen();
    return;
  }
  if (strlen(morsestring) > 0 && millis() - lastInputTime > ditLen * 5) {
    decodeMorse();
    calculateScore();
    showResultsScreen();
    if (roundsPlayed >= maxRounds) {
      gameOverScreen();
      return;
    }
    newPrompt();
  }
}

Credits

ProfessorSparks
1 project • 0 followers

Comments