/* Morse-Paddle-Adapter für den Morsetrainer von DL2TTA ===================================================== © 2026 Holger Behrens, DL2TTA – Lizenz CC BY-NC-SA 4.0 (siehe LIZENZ.md) Board: Seeed Studio XIAO nRF52840 Plus (läuft auch auf XIAO nRF52840 / Sense) Arduino: Boardverwalter-Paket „Seeed nRF52 Boards“ (NICHT „mbed-enabled“), Board „Seeed XIAO nRF52840“ Der Adapter meldet sich per Bluetooth als Tastatur (BLE HID) an – das verstehen iPhone, iPad, Android, Mac und PC ohne Treiber. Der Morsetrainer erwartet: Punkt-Hebel = Taste „[“ gedrückt halten (Tastenposition BracketLeft, auf deutscher Tastatur „ü“) Strich-Hebel = Taste „]“ gedrückt halten (BracketRight, deutsch „+“) Beim Loslassen wird „alle Tasten los“ gesendet. Der iambische Keyer läuft in der App. Keine Strg-Tasten: iOS deutet zweimal Strg als Kurzbefehl (z. B. Diktieren) und nimmt der App dann den Ton weg. Handtaste (Morsetaste) statt Paddle: einfach einstecken, im Morsetrainer „Handtaste“ wählen – die App nimmt „[“ dann als Taste. Ein Mono-Stecker verbindet den Ring (Strich) dauerhaft mit Masse. Das erkennt der Adapter (Strich beim Einschalten oder 15 s am Stück gedrückt) und schaltet den Strich-Eingang ab – sonst flösse dauernd Strom über den Pull-up und der Adapter würde nie schlafen. Bei jedem Punkt prüft er kurz, ob wieder ein Paddle steckt. Anschluss (siehe schaltplan.svg): D0 ── Punkt-Kontakt (Klinke: Spitze / Tip) D1 ── Strich-Kontakt (Klinke: Ring) GND ── gemeinsamer Kontakt (Klinke: Hülse / Sleeve) Keine Widerstände nötig – die internen Pull-ups sind eingeschaltet, ein Hebel zieht den Eingang auf GND. Bedienung: - Einschalten → blaue LED blinkt = wartet auf Verbindung. Kurzes grünes Blinken = verbunden. - Name in der Bluetooth-Liste: „Morsepaddle DL2TTA“ + 4 Zeichen, die jeden Adapter unterscheiden (z. B. 3F7A). - Drei Kanäle für drei Geräte (wie „Easy-Switch“ bei Logitech): Jeder Kanal funkt mit eigener Bluetooth-Adresse und steht als eigenes Gerät in der Liste („… 3F7A-1“, „-2“, „-3“). Jedes Gerät einmal mit „seinem“ Kanal koppeln. Wechseln: BEIDE Hebel 3 s halten, bis blau aufblitzt, dann loslassen → nächster Kanal; blau blinkt danach so oft wie die Kanalnummer, und das Gerät dieses Kanals verbindet sich von selbst. Der Kanal bleibt gespeichert. Mit Handtaste (Mono-Stecker): die Taste 5 s halten, bis blau aufblitzt, dann loslassen. - Kopplung löschen: beim Einschalten BEIDE Hebel gedrückt halten, bis die rote LED dreimal blinkt. - Firmware aufspielen im geschlossenen Gehäuse: USB-Kabel anstecken, BEIDE Hebel 10 s halten → grün 3× → das Laufwerk „XIAO-SENSE“ erscheint, die .uf2-Datei darauf ziehen. (Der Reset-Knopf ist nicht nötig.) Doch nichts aufspielen: USB-Kabel abziehen – der Bootloader startet dann wieder diese Firmware. - Nach 1 Minute ohne Hebeldruck trennt sich der Adapter (blaue LED aus): iOS blendet die Bildschirmtastatur aus, solange eine Bluetooth-Tastatur verbunden ist. Der nächste Hebeldruck verbindet wieder (ca. 1 s, gibt noch kein Zeichen). - Nach 15 Minuten ohne Benutzung (im Akkubetrieb) schläft der Adapter (< 1 µA, Spannungsteiler abgeschaltet). Aufwecken: Punkt-Hebel drücken – nach 1–2 Sekunden ist er wieder verbunden. - Akkustand: wird ans Handy gemeldet (iPhone: Batterie-Widget). - Tiefentladeschutz: Fällt die Akkuspannung unter AKKU_LEER_V, legt sich der Adapter schlafen (rot 5× blinken) und wacht erst wieder auf, wenn der Akku geladen ist. Wichtig für Modellbau-Akkus ohne eigene Schutzschaltung (z. B. RED POWER RD XT 650 S1). */ #include #include using namespace Adafruit_LittleFS_Namespace; // ---------------------------------------------------------------- Einstellungen const uint8_t PIN_PUNKT = D0; // Klinke Spitze const uint8_t PIN_STRICH = D1; // Klinke Ring const uint32_t STRICH_DAUER_MS = 15000; // so lange am Stück „Strich“ = Mono-Stecker einer Handtaste → Eingang aus const uint32_t ENTPRELLEN_MS = 4; // Kontakt muss so lange stabil sein const uint32_t TRENNEN_NACH_MS = 60000; // ohne Hebeldruck trennen → iPhone/iPad zeigen ihre Bildschirmtastatur wieder const uint32_t SCHLAFEN_NACH_MS = 15UL * 60UL * 1000UL; // nur im Akkubetrieb const uint32_t UPDATE_HALTEN_MS = 10000; // am USB beide Hebel so lange halten → Laufwerk zum Firmware-Aufspielen const uint32_t KANAL_HALTEN_MS = 3000; // beide Hebel so lange halten und loslassen → nächster Kanal const uint32_t KANAL_HAND_MS = 5000; // mit Handtaste (Mono-Stecker): Taste so lange halten → nächster Kanal const uint8_t KANAELE = 3; // Anzahl Kanäle (Geräte), höchstens 9 const char* NAME = "Morsepaddle DL2TTA"; // + 4 Zeichen aus der Bluetooth-Adresse, z. B. „… 3F7A“ const bool LADESTROM_100MA = true; // true: 100 mA (Akku ab ca. 150 mAh), false: 50 mA const float AKKU_LEER_V = 3.40f; // darunter schlafen (schützt Akkus ohne Schutzschaltung vor Tiefentladung) // HID-Tastencodes (USB-HID-Standard) const uint8_t TASTE_PUNKT = 0x2F; // [ const uint8_t TASTE_STRICH = 0x30; // ] // ---------------------------------------------------------------- Bluetooth-Dienste BLEDis bledis; // Geräte-Informationen BLEHidAdafruit blehid; // Tastatur BLEBas blebas; // Akkustand // ---------------------------------------------------------------- Zustand struct Kontakt { uint8_t pin; bool gedrueckt; // entprellter Zustand bool roh; // zuletzt gelesener Rohwert uint32_t seit; // seit wann ist der Rohwert so }; Kontakt punkt = { PIN_PUNKT, false, false, 0 }; Kontakt strich = { PIN_STRICH, false, false, 0 }; volatile uint32_t letzteAktivitaet = 0; // wird auch beim Verbinden gesetzt (Bluetooth-Task) bool strichAus = false; // Strich-Eingang abgeschaltet (Mono-Stecker) uint32_t beideSeit = 0; // seit wann beide Hebel gedrückt sind (0 = nicht beide) uint32_t letzteAkkuMeldung = 0; uint8_t kanal = 1; // 1 … KANAELE, gespeichert in KANAL_DATEI bool kanalWechsel = false; bool ruht = false; // selbst getrennt, keine Werbung – wartet auf einen Hebeldruck // beide Hebel lange genug gehalten → beim Loslassen wechseln const char* KANAL_DATEI = "/paddle_kanal"; // LEDs auf dem XIAO sind „aktiv LOW“ void led(uint8_t pin, bool an) { digitalWrite(pin, an ? LOW : HIGH); } void ledsAus() { led(LED_RED, false); led(LED_GREEN, false); led(LED_BLUE, false); } void blinken(uint8_t pin, int mal, int ms) { for (int i = 0; i < mal; i++) { led(pin, true); delay(ms); led(pin, false); delay(ms); } } bool usbAngeschlossen() { return NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk; } // Ladezustand: Das Ladegerät (BQ25101) zieht ~CHG (P0.17) auf LOW, solange es lädt – dieselbe Leitung steuert die // Lade-LED neben der USB-Buchse. Nur am USB-Kabel aussagekräftig. Arduino-Pin 23 = P0.17 (Index in variant.cpp; die // Kommentare „D32 is P0.17“ dort sind beim Plus falsch nummeriert). const uint8_t PIN_LADEN = 23; bool laedt() { return usbAngeschlossen() && digitalRead(PIN_LADEN) == LOW; } // ---------------------------------------------------------------- Akku void akkuVorbereiten() { #ifdef VBAT_ENABLE // Spannungsteiler für die Akkumessung einschalten. Laut Seeed NIE auf HIGH setzen, solange geladen wird – // sonst liegt am Messeingang P0.31 zu viel Spannung an. Deshalb bleibt er dauerhaft LOW. pinMode(VBAT_ENABLE, OUTPUT); digitalWrite(VBAT_ENABLE, LOW); #endif #ifdef PIN_CHARGING_CURRENT // Ladestrom (HICHG, P0.13): LOW = 100 mA, HIGH = 50 mA pinMode(PIN_CHARGING_CURRENT, OUTPUT); digitalWrite(PIN_CHARGING_CURRENT, LADESTROM_100MA ? LOW : HIGH); #endif } // Akkuspannung in Volt, 0 = nicht messbar float akkuVolt() { #ifdef PIN_VBAT analogReference(AR_INTERNAL_2_4); analogReadResolution(12); uint32_t summe = 0; for (int i = 0; i < 8; i++) summe += analogRead(PIN_VBAT); analogReference(AR_DEFAULT); return 2.961f * 2.4f * (summe / 8.0f) / 4096.0f; // Teiler 1 MΩ / 510 kΩ (Seeed-Schaltplan) #else return 0; #endif } // Akkustand in Prozent (0–100), 255 = nicht messbar uint8_t akkuProzent() { #ifdef PIN_VBAT float volt = akkuVolt(); // grobe Kennlinie eines LiPo-Akkus const float v[] = { 3.30f, 3.60f, 3.70f, 3.75f, 3.80f, 3.90f, 4.00f, 4.10f, 4.20f }; const uint8_t p[] = { 0, 5, 15, 30, 45, 65, 80, 92, 100 }; if (volt <= v[0]) return 0; for (int i = 1; i < 9; i++) { if (volt <= v[i]) return p[i - 1] + (uint8_t)((volt - v[i - 1]) / (v[i] - v[i - 1]) * (p[i] - p[i - 1])); } return 100; #else return 255; #endif } void akkuMelden() { uint8_t prozent = akkuProzent(); if (prozent <= 100) blebas.write(prozent); letzteAkkuMeldung = millis(); } // ---------------------------------------------------------------- Tastatur-Bericht void senden() { uint8_t tasten[6] = { 0, 0, 0, 0, 0, 0 }; uint8_t n = 0; if (punkt.gedrueckt) tasten[n++] = TASTE_PUNKT; if (strich.gedrueckt) tasten[n++] = TASTE_STRICH; if (Bluefruit.connected()) blehid.keyboardReport(0, tasten); } // Liefert true, wenn sich der entprellte Zustand geändert hat bool abfragen(Kontakt& k, uint32_t jetzt) { bool roh = digitalRead(k.pin) == LOW; // gedrückt = Kontakt nach GND if (roh != k.roh) { k.roh = roh; k.seit = jetzt; } if (k.roh != k.gedrueckt && jetzt - k.seit >= ENTPRELLEN_MS) { k.gedrueckt = k.roh; return true; } return false; } // ---------------------------------------------------------------- Verbindung void verbunden(uint16_t conn_handle) { (void)conn_handle; letzteAktivitaet = millis(); // die Minute bis zum Trennen zählt ab der Verbindung led(LED_BLUE, false); blinken(LED_GREEN, 2, 80); akkuMelden(); } void getrennt(uint16_t conn_handle, uint8_t reason) { (void)conn_handle; (void)reason; // Sicherheitsnetz: nichts bleibt „gedrückt“ hängen punkt.gedrueckt = strich.gedrueckt = false; } void werbungStarten() { Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE); Bluefruit.Advertising.addTxPower(); Bluefruit.Advertising.addAppearance(BLE_APPEARANCE_HID_KEYBOARD); Bluefruit.Advertising.addService(blehid); Bluefruit.Advertising.addService(blebas); Bluefruit.ScanResponse.addName(); Bluefruit.Advertising.restartOnDisconnect(true); Bluefruit.Advertising.setInterval(32, 244); // erst schnell (20 ms), dann sparsam (152,5 ms) Bluefruit.Advertising.setFastTimeout(30); // 30 s schnell Bluefruit.Advertising.start(0); // 0 = unbegrenzt } // ---------------------------------------------------------------- Kanäle // Jeder Kanal hat eine eigene Bluetooth-Adresse. Ein Gerät kennt nur die Adresse, mit der es gekoppelt wurde – die // anderen Geräte sehen den Adapter nach dem Wechsel nicht mehr und können ihn nicht wegschnappen. Kanal 1 behält die // Adresse aus dem Chip, damit bestehende Kopplungen erhalten bleiben. Die Kopplungsschlüssel speichert die // Bluefruit-Bibliothek je Gerät, unabhängig vom Kanal. uint8_t kanalLesen() { File f(InternalFS); uint8_t k = 1; if (f.open(KANAL_DATEI, FILE_O_READ)) { k = f.read(); f.close(); } return (k >= 1 && k <= KANAELE) ? k : 1; } void kanalSpeichern(uint8_t k) { InternalFS.remove(KANAL_DATEI); File f(InternalFS); if (f.open(KANAL_DATEI, FILE_O_WRITE)) { f.write(k); f.close(); } } // Adresse des Kanals: Chip-Adresse, unterstes Byte je Kanal verändert (die obersten Bits bleiben „random static“) void kanalAdresseSetzen() { if (kanal == 1) return; ble_gap_addr_t adr = Bluefruit.getAddr(); adr.addr[0] ^= (kanal - 1) * 0x55; adr.addr_type = BLE_GAP_ADDR_TYPE_RANDOM_STATIC; Bluefruit.setAddr(&adr); } // Nächster Kanal: speichern und neu starten – der Start setzt Adresse und Namen und wirbt neu void kanalWeiter() { punkt.gedrueckt = strich.gedrueckt = false; senden(); // „alle Tasten los“ kanalSpeichern(kanal % KANAELE + 1); delay(20); NVIC_SystemReset(); } // Ruhen: Verbindung trennen und nicht mehr werben, sonst verbindet sich das Gerät sofort wieder void ruhen() { ruht = true; Bluefruit.Advertising.restartOnDisconnect(false); Bluefruit.Advertising.stop(); Bluefruit.disconnect(Bluefruit.connHandle()); led(LED_BLUE, false); } void aufwachen() { ruht = false; Bluefruit.Advertising.restartOnDisconnect(true); Bluefruit.Advertising.start(0); } // ---------------------------------------------------------------- Schlafen void schlafen() { ledsAus(); blinken(LED_RED, 1, 150); // Nur ein Druck auf den Punkt-Hebel weckt (das Gerät startet dann neu und verbindet sich wieder) tiefschlaf(); } // Mono-Stecker (Handtaste): Strich-Eingang ganz abschalten – kein Pull-up, kein Eingangspuffer, kein Strom void strichAbschalten() { strichAus = true; strich.gedrueckt = false; nrf_gpio_cfg_default(g_ADigitalPinMap[PIN_STRICH]); } // Steckt wieder ein Paddle? Pull-up kurz einschalten und lesen: HIGH = Ring frei → Strich-Eingang wieder an. void strichPruefen() { pinMode(PIN_STRICH, INPUT_PULLUP); delayMicroseconds(50); if (digitalRead(PIN_STRICH) == HIGH) { strichAus = false; strich.roh = strich.gedrueckt = false; } else { nrf_gpio_cfg_default(g_ADigitalPinMap[PIN_STRICH]); } } // Tiefschlaf (System OFF): vorher den Spannungsteiler der Akkumessung abschalten – sonst fließen dauernd ca. 2,5 µA // über 1 MΩ + 510 kΩ nach Masse. VBAT_ENABLE hochohmig (nicht HIGH! – siehe akkuVorbereiten) trennt den Teiler; der // Messeingang hängt dann nur noch über 1 MΩ am Akku. Beim Aufwachen startet der XIAO neu und schaltet ihn wieder ein. // Im Schlaf braucht der Adapter so unter 1 µA – ein Schalter ist nicht nötig. void tiefschlaf() { #ifdef VBAT_ENABLE pinMode(VBAT_ENABLE, INPUT); #endif systemOff(PIN_PUNKT, LOW); // nur der Punkt-Hebel weckt } // Tiefentladeschutz: Akku leer (und kein USB) → schlafen. Der Punkt-Hebel weckt zwar, aber ist der Akku dann noch // leer, schläft der Adapter sofort wieder ein – erst nach dem Laden läuft er wieder. void akkuSchuetzen() { float v = akkuVolt(); if (v > 1.0f && v < AKKU_LEER_V && !usbAngeschlossen()) { ledsAus(); blinken(LED_RED, 5, 120); tiefschlaf(); } } // ---------------------------------------------------------------- Start void setup() { pinMode(LED_RED, OUTPUT); pinMode(LED_GREEN, OUTPUT); pinMode(LED_BLUE, OUTPUT); ledsAus(); pinMode(PIN_PUNKT, INPUT_PULLUP); pinMode(PIN_STRICH, INPUT_PULLUP); pinMode(PIN_LADEN, INPUT); Serial.begin(115200); // USB-Seriell nur für die Zustandsmeldung am Kabel akkuVorbereiten(); delay(5); akkuSchuetzen(); // vor allem anderen: leeren Akku nicht weiter entladen Bluefruit.begin(); Bluefruit.setTxPower(0); // Eigener Name je Adapter: Bei mehreren im Raum steht so jeder einzeln in der Bluetooth-Liste // (die 4 Zeichen am besten aufs Gehäuse schreiben). Die Adresse ist fest im Chip – gleiche Firmware für alle. uint8_t adr[6]; Bluefruit.getAddr(adr); // Chip-Adresse – vor kanalAdresseSetzen(), damit die 4 Zeichen in allen Kanälen gleich sind kanal = kanalLesen(); kanalAdresseSetzen(); char name[32]; snprintf(name, sizeof(name), "%s %02X%02X-%u", NAME, adr[1], adr[0], kanal); Bluefruit.setName(name); Bluefruit.autoConnLed(false); // Beide Hebel beim Einschalten gedrückt → alle gespeicherten Kopplungen löschen delay(50); if (digitalRead(PIN_PUNKT) == LOW && digitalRead(PIN_STRICH) == LOW) { Bluefruit.Periph.clearBonds(); blinken(LED_RED, 3, 200); } else if (digitalRead(PIN_STRICH) == LOW) { strichAbschalten(); // nur Strich „gedrückt“ = Mono-Stecker einer Handtaste } // Kurzes Verbindungsintervall (11,25–15 ms) – spürbar ohne Verzögerung, von Apple für Tastaturen zugelassen Bluefruit.Periph.setConnInterval(9, 12); Bluefruit.Periph.setConnectCallback(verbunden); Bluefruit.Periph.setDisconnectCallback(getrennt); bledis.setManufacturer("DL2TTA"); bledis.setModel("Morse-Paddle-Adapter"); bledis.begin(); blehid.begin(); blebas.begin(); blebas.write(100); werbungStarten(); blinken(LED_BLUE, kanal, 150); // Kanalnummer zeigen (die Werbung läuft schon – Aufwachen bleibt schnell) letzteAktivitaet = millis(); } // ---------------------------------------------------------------- Hauptschleife (ca. 1000-mal pro Sekunde) void loop() { uint32_t jetzt = millis(); bool geaendert = abfragen(punkt, jetzt); if (geaendert && punkt.gedrueckt && strichAus) strichPruefen(); if (!strichAus) { geaendert |= abfragen(strich, jetzt); if (strich.gedrueckt && jetzt - strich.seit > STRICH_DAUER_MS) { strichAbschalten(); geaendert = true; } } if (geaendert) { if (ruht && (punkt.gedrueckt || strich.gedrueckt)) aufwachen(); // dieser Druck verbindet nur senden(); letzteAktivitaet = jetzt; } // Eine Minute nichts gegeben → trennen, damit iPhone/iPad ihre Bildschirmtastatur wieder zeigen // (vorzeichenbehaftet: letzteAktivitaet kann vom Verbinden jünger sein als „jetzt“) int32_t ruhig = (int32_t)(jetzt - letzteAktivitaet); if (Bluefruit.connected() && !ruht && ruhig > (int32_t)TRENNEN_NACH_MS && !punkt.gedrueckt && !strich.gedrueckt) { ruhen(); } // Beide Hebel 3 s halten (Handtaste: 5 s) → blau blitzt → loslassen = nächster Kanal. // Am USB-Kabel beide Hebel weiter halten (10 s) → Bootloader mit Laufwerk (UF2) starten, dann kein Kanalwechsel. if (strichAus ? punkt.gedrueckt : punkt.gedrueckt && strich.gedrueckt) { if (!beideSeit) beideSeit = jetzt; if (!kanalWechsel && jetzt - beideSeit > (strichAus ? KANAL_HAND_MS : KANAL_HALTEN_MS)) { kanalWechsel = true; blinken(LED_BLUE, 1, 300); } if (!strichAus && usbAngeschlossen() && jetzt - beideSeit > UPDATE_HALTEN_MS) { punkt.gedrueckt = strich.gedrueckt = false; senden(); // „alle Tasten los“, bevor die Verbindung abreißt ledsAus(); blinken(LED_GREEN, 3, 150); enterUf2Dfu(); } } else { beideSeit = 0; if (kanalWechsel && !punkt.gedrueckt && !strich.gedrueckt) kanalWeiter(); // erst wenn beide Hebel los sind } // Wartet auf Verbindung: blaue LED blinkt kurz if (!Bluefruit.connected() && !ruht) led(LED_BLUE, (jetzt % 1000) < 60); // Am USB-Kabel: grün blitzt alle 2 s = lädt, grün leuchtet = Akku voll (ohne USB bleibt grün aus) static bool warUsb = false; bool usb = usbAngeschlossen(); if (usb) led(LED_GREEN, laedt() ? (jetzt % 2000) < 80 : true); else if (warUsb) led(LED_GREEN, false); warUsb = usb; // Am USB-Kabel alle 2 s den Zustand über die serielle Schnittstelle melden (Fehlersuche, z. B. mit dem // seriellen Monitor der Arduino IDE): Akkuspannung, Prozent, ob geladen wird static uint32_t letzteAusgabe = 0; if (usb && Serial && jetzt - letzteAusgabe > 2000) { letzteAusgabe = jetzt; Serial.print("Akku "); Serial.print(akkuVolt(), 2); Serial.print(" V ("); Serial.print(akkuProzent()); Serial.print(" %), USB, "); Serial.println(laedt() ? "laedt" : "laedt nicht (voll oder kein Akku)"); } // Akkustand jede Minute melden und prüfen if (jetzt - letzteAkkuMeldung > 60000UL) { if (Bluefruit.connected()) akkuMelden(); else letzteAkkuMeldung = jetzt; akkuSchuetzen(); } // Im Akkubetrieb nach langer Pause schlafen (am USB-Kabel bleibt er wach) if (!usbAngeschlossen() && ruhig > (int32_t)SCHLAFEN_NACH_MS && !punkt.gedrueckt && !strich.gedrueckt) { schlafen(); } delay(1); }