Optional — Header files.
Skip →From blinking an LED to a component anyone can use.
01 — We know how to blink an LED.
Let’s write a MorseComponent.
Morse represents characters through short and long pulses of light or sound.
Short pulses → dots.
Long pulses → dashes — called “lines” in our function names.
No pulse → pause.
02 — Start with DECLARE: the timings.
First, give our four timings names. All values are in milliseconds.
A buffer is a short OFF interval around each dot or line.
Only the four timing constants
// DECLARE
const int DOT_LENGTH = 200;
const int LINE_LENGTH = 400;
const int BUFFER_LENGTH = 50;
const int PAUSE_LENGTH = 1000;These are our classroom timings. Standard Morse uses different timing ratios; the dot/dash character patterns stay the same.
03 — Choose the LED pin. Then INIT.
Now add the LED pin to DECLARE.
Add the LED pin
// Add to DECLARE
const int LED_PIN = LED_BUILTIN;Start Serial. Set the LED pin to OUTPUT. Initialize the LED OFF.
Our first setup()
// INIT
void setup() {
Serial.begin(9600);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW); // Initialize the LED OFF.
}04 — Write the three basic actions.
Line: OFF buffer → ON line → OFF buffer.
Dot: OFF buffer → ON dot → OFF buffer.
Pause: OFF buffer → OFF pause → OFF buffer.
blinkLine()
void blinkLine() {
digitalWrite(LED_PIN, LOW);
delay(BUFFER_LENGTH);
digitalWrite(LED_PIN, HIGH);
delay(LINE_LENGTH);
digitalWrite(LED_PIN, LOW);
delay(BUFFER_LENGTH);
}blinkDot()
void blinkDot() {
digitalWrite(LED_PIN, LOW);
delay(BUFFER_LENGTH);
digitalWrite(LED_PIN, HIGH);
delay(DOT_LENGTH);
digitalWrite(LED_PIN, LOW);
delay(BUFFER_LENGTH);
}blinkPause()
void blinkPause() {
digitalWrite(LED_PIN, LOW);
delay(BUFFER_LENGTH);
delay(PAUSE_LENGTH);
delay(BUFFER_LENGTH);
}4.1 — Could we organize this further? When to say “enough.”
Pause is different: the LED stays OFF. Dot and line do the same thing; only their ON time changes.
So we could keep one pause function and create one shared blink(duration). Pass it DOT_LENGTH or LINE_LENGTH.
Option 1: share the dot / line logic
void blink(int duration) {
digitalWrite(LED_PIN, LOW);
delay(BUFFER_LENGTH);
digitalWrite(LED_PIN, HIGH);
delay(duration);
digitalWrite(LED_PIN, LOW);
delay(BUFFER_LENGTH);
}
void blinkDot() {
blink(DOT_LENGTH);
}
void blinkLine() {
blink(LINE_LENGTH);
}
// Keep blinkPause() exactly as written above.We could go further: one function takes a duration and an ON/OFF flag. An if in the middle decides whether to light the LED.
Then our three named functions — dot, line, pause — each call that one function.
Option 2: one shared action, three named functions
void blink(int duration, bool turnOn) {
digitalWrite(LED_PIN, LOW);
delay(BUFFER_LENGTH);
if (turnOn) {
digitalWrite(LED_PIN, HIGH);
} else {
digitalWrite(LED_PIN, LOW);
}
delay(duration);
digitalWrite(LED_PIN, LOW);
delay(BUFFER_LENGTH);
}
void blinkDot() {
blink(DOT_LENGTH, true);
}
void blinkLine() {
blink(LINE_LENGTH, true);
}
void blinkPause() {
blink(PAUSE_LENGTH, false);
}These are alternative versions, not additions to paste together. Both preserve the original timings and behavior.
Is it logically correct? Definitely. Yes.
Avoid repeating logic. Consolidate shared work. Keep functions focused on one concept. These are useful principles.
Is there a NEED for this here? A big NO.
For this small teaching example, the extra organization adds parameters, a decision, and calls to follow. The most generalized version is longer, and understanding a simple blink now requires jumping between functions.
That is where organization can become overthinking, overcoding, and overcomplication. The extra structure is not worth it for us here.
There is a point where we should say: enough.
Stop when simplicity and understanding start to suffer just to uphold modularization and encapsulation rules.
Those rules should help us understand the program. For the rest of this lesson, we keep the three straightforward functions from step 4.
05 — Build the character functions.
A is dot, line.
B is line, dot, dot, dot.
Use those same actions to build all the letters and numbers.
The 26 letters
void blinkA() { // .-
blinkDot();
blinkLine();
}
void blinkB() { // -...
blinkLine();
blinkDot();
blinkDot();
blinkDot();
}
void blinkC() { // -.-.
blinkLine();
blinkDot();
blinkLine();
blinkDot();
}
void blinkD() { // -..
blinkLine();
blinkDot();
blinkDot();
}
void blinkE() { // .
blinkDot();
}
void blinkF() { // ..-.
blinkDot();
blinkDot();
blinkLine();
blinkDot();
}
void blinkG() { // --.
blinkLine();
blinkLine();
blinkDot();
}
void blinkH() { // ....
blinkDot();
blinkDot();
blinkDot();
blinkDot();
}
void blinkI() { // ..
blinkDot();
blinkDot();
}
void blinkJ() { // .---
blinkDot();
blinkLine();
blinkLine();
blinkLine();
}
void blinkK() { // -.-
blinkLine();
blinkDot();
blinkLine();
}
void blinkL() { // .-..
blinkDot();
blinkLine();
blinkDot();
blinkDot();
}
void blinkM() { // --
blinkLine();
blinkLine();
}
void blinkN() { // -.
blinkLine();
blinkDot();
}
void blinkO() { // ---
blinkLine();
blinkLine();
blinkLine();
}
void blinkP() { // .--.
blinkDot();
blinkLine();
blinkLine();
blinkDot();
}
void blinkQ() { // --.-
blinkLine();
blinkLine();
blinkDot();
blinkLine();
}
void blinkR() { // .-.
blinkDot();
blinkLine();
blinkDot();
}
void blinkS() { // ...
blinkDot();
blinkDot();
blinkDot();
}
void blinkT() { // -
blinkLine();
}
void blinkU() { // ..-
blinkDot();
blinkDot();
blinkLine();
}
void blinkV() { // ...-
blinkDot();
blinkDot();
blinkDot();
blinkLine();
}
void blinkW() { // .--
blinkDot();
blinkLine();
blinkLine();
}
void blinkX() { // -..-
blinkLine();
blinkDot();
blinkDot();
blinkLine();
}
void blinkY() { // -.--
blinkLine();
blinkDot();
blinkLine();
blinkLine();
}
void blinkZ() { // --..
blinkLine();
blinkLine();
blinkDot();
blinkDot();
}The 10 numbers
void blink0() { // -----
blinkLine();
blinkLine();
blinkLine();
blinkLine();
blinkLine();
}
void blink1() { // .----
blinkDot();
blinkLine();
blinkLine();
blinkLine();
blinkLine();
}
void blink2() { // ..---
blinkDot();
blinkDot();
blinkLine();
blinkLine();
blinkLine();
}
void blink3() { // ...--
blinkDot();
blinkDot();
blinkDot();
blinkLine();
blinkLine();
}
void blink4() { // ....-
blinkDot();
blinkDot();
blinkDot();
blinkDot();
blinkLine();
}
void blink5() { // .....
blinkDot();
blinkDot();
blinkDot();
blinkDot();
blinkDot();
}
void blink6() { // -....
blinkLine();
blinkDot();
blinkDot();
blinkDot();
blinkDot();
}
void blink7() { // --...
blinkLine();
blinkLine();
blinkDot();
blinkDot();
blinkDot();
}
void blink8() { // ---..
blinkLine();
blinkLine();
blinkLine();
blinkDot();
blinkDot();
}
void blink9() { // ----.
blinkLine();
blinkLine();
blinkLine();
blinkLine();
blinkDot();
}06 — Now add the PWM input and map.
26 letters + 10 numbers + 1 pause = 37 commands.
Give each command six values.
A = 0–5. B = 6–11. And so on.
0–255 contains 256 values, so 37 × 6 leaves 34 spare values at the end.
Now we need a signal input pin. Add these declarations, then add one line to the setup we already wrote.
Add the PWM pin and range constants
// Add to DECLARE
const int PWM_INPUT_PIN = 2;
const int VALUES_PER_CHAR = 6;
const int CHAR_COUNT = 37;Update setup() with this line
// Add inside the existing setup()
pinMode(PWM_INPUT_PIN, INPUT_PULLUP);INPUT_PULLUP is an input with a weak pull-up, so an unconnected wire stays at our idle HIGH level.
Divide the received value by six. Choose the corresponding character function.
The map: signal range → character
void mapPWMToChar(int signal) {
if (signal < 0 || signal >= CHAR_COUNT * VALUES_PER_CHAR) {
return; // Invalid or reserved value: do not blink.
}
int charIndex = signal / VALUES_PER_CHAR;
switch (charIndex) {
case 0: blinkA(); break; // 0-5: A
case 1: blinkB(); break; // 6-11: B
case 2: blinkC(); break; // 12-17: C
case 3: blinkD(); break; // 18-23: D
case 4: blinkE(); break; // 24-29: E
case 5: blinkF(); break; // 30-35: F
case 6: blinkG(); break; // 36-41: G
case 7: blinkH(); break; // 42-47: H
case 8: blinkI(); break; // 48-53: I
case 9: blinkJ(); break; // 54-59: J
case 10: blinkK(); break; // 60-65: K
case 11: blinkL(); break; // 66-71: L
case 12: blinkM(); break; // 72-77: M
case 13: blinkN(); break; // 78-83: N
case 14: blinkO(); break; // 84-89: O
case 15: blinkP(); break; // 90-95: P
case 16: blinkQ(); break; // 96-101: Q
case 17: blinkR(); break; // 102-107: R
case 18: blinkS(); break; // 108-113: S
case 19: blinkT(); break; // 114-119: T
case 20: blinkU(); break; // 120-125: U
case 21: blinkV(); break; // 126-131: V
case 22: blinkW(); break; // 132-137: W
case 23: blinkX(); break; // 138-143: X
case 24: blinkY(); break; // 144-149: Y
case 25: blinkZ(); break; // 150-155: Z
case 26: blink0(); break; // 156-161: 0
case 27: blink1(); break; // 162-167: 1
case 28: blink2(); break; // 168-173: 2
case 29: blink3(); break; // 174-179: 3
case 30: blink4(); break; // 180-185: 4
case 31: blink5(); break; // 186-191: 5
case 32: blink6(); break; // 192-197: 6
case 33: blink7(); break; // 198-203: 7
case 34: blink8(); break; // 204-209: 8
case 35: blink9(); break; // 210-215: 9
case 36: blinkPause(); break; // 216-221: space / pause
}
}07 — Read the signal. Pass it to the map.
Now connect the pieces: read PWM → map → blink.
The core read-and-blink flow
void readAndBlink() {
int signal = readPWM();
mapPWMToChar(signal);
}Call it from loop()
// RUN
void loop() {
readAndBlink();
}readPWM() measures the HIGH/LOW pulse timing and returns a 0–255 value. The complete example adds timeouts and command boundaries so repeated letters work. Those details are collected at the end.
08 — We made a standalone Morse component.
A little LED and a little programmed chip.
It gets 5V, GND, and a PWM signal, and knows how to translate that signal into Morse blinks.
The component owns the blinking work.
09 — But the user wants something simpler.
The user does not care about our code, PWM values, or component internals.
They want to write “A” and see it blink.
So we create a handler program.
It accepts a character or string and translates it into the component’s signal.
10 — A new file: declare and initialize the output.
Start a separate handler sketch. It only needs a PWM output to send the signal.
Handler: DECLARE
// DECLARE — in the new handler sketch
uint8_t pwmOutputPin = 9;
const int VALUES_PER_CHAR = 6;
const int IDLE_SIGNAL = 255;Handler: INIT
// INIT
void setup() {
pinMode(pwmOutputPin, OUTPUT);
analogWrite(pwmOutputPin, IDLE_SIGNAL);
}255 is our idle signal. The component and handler run on separate boards.
11 — blinkChar(): send one character.
First reverse the map: character → PWM value.
Send near the center of each six-value range.
Reverse the map
int charToPWM(char character) {
if (character >= 'a' && character <= 'z') {
character = character - 'a' + 'A';
}
int index;
if (character >= 'A' && character <= 'Z') {
index = character - 'A';
} else if (character >= '0' && character <= '9') {
index = 26 + character - '0';
} else if (character == ' ') {
index = 36;
} else {
return -1; // This component supports letters, digits, and spaces.
}
return index * VALUES_PER_CHAR + 3; // Send near the bin center.
}Then send that signal. An optional pause flag defaults to false.
Add blinkChar()
void blinkChar(char character, bool pause = false) {
int signal = charToPWM(character);
if (signal < 0) return;
sendPWMCommand(signal);
if (pause) sendPWMCommand(charToPWM(' '));
}blinkChar('A') → A.blinkChar('A', true) → A, then an extra pause.blinkChar(' ') → the pause character itself.
sendPWMCommand() sends the command and leaves time for the component to finish. Its timing implementation is at the end.
12 — blinkStr(): break the string into characters.
For each character, call blinkChar().
A space in the text sends a pause.
If the flag is true, add one extra pause at the end of the whole string.
Add blinkStr()
void blinkStr(const char* text, bool pause = false) {
if (text == nullptr) return;
for (int i = 0; text[i] != '\0'; i++) {
blinkChar(text[i], false);
}
if (pause) blinkChar(' ', false);
}The hidden zero character marks the end of a C string. This is why the loop knows when to stop.
13 — Give the user only the functions they need.
The user still does not need to see all that implementation.
List the useful functions — blinkChar and blinkStr — and bundle their declarations into a header file.
The header is the interface. The existing function bodies go in a matching .cpp file.
14 — Create MorseBlinker.h
Keep the two blink functions. Add beginMorse() for the initialization we already wrote.
The header file
#ifndef MORSE_BLINKER_H
#define MORSE_BLINKER_H
#include <Arduino.h>
// Uno R3: use the default PWM output D9 for this example.
void beginMorse(uint8_t pin = 9);
// pause adds one extra pause command after the character or whole string.
void blinkChar(char character, bool pause = false);
void blinkStr(const char* text, bool pause = false);
#endif
The default false arguments now live in the header. Remove those defaults from the function definitions in MorseBlinker.cpp.
The initialization moves into beginMorse(). The other function bodies stay together in the implementation file; we do not rewrite them here.
15 — Tie it together: hello world.
The user’s Arduino program includes our header, initializes the handler, and asks it to send a message.
The small user program
#include "MorseBlinker.h"
// DECLARE
const int MORSE_SIGNAL_PIN = 9;
// INIT
void setup() {
beginMorse(MORSE_SIGNAL_PIN);
blinkStr("hello world", true);
}
// RUN
void loop() {
// The message was sent once in setup().
}
That’s the user-facing program. The handler handles translation. The component handles blinking.
16 — Now look at Servo.h.
A servo is a pulse-controlled motor component, just as ours is a signal-controlled Morse LED component.
With Servo.h, we request an angle.
The implementation turns that request into servo command pulses.
With MorseBlinker.h, we request characters.
Our implementation turns them into the Morse component’s PWM commands.
The servo’s controller handles motor positioning and feedback.
Our Morse component handles blinking.
The motor itself spins. The LED itself lights.
User → handler → component.
Each has a specific job. The header gives the user an easy way to ask.
The analogy is the separation of jobs. The two components use different pulse protocols, and the header exposes functions whose implementation runs on the user’s Arduino.
Complete files and implementation details
The lesson above introduces one piece at a time. These are the supporting details for the complete two-board example.
One command, then time to blink
The sender holds each command for 80 ms, then holds the signal HIGH for 3000 ms. The receiver accepts a command once and re-arms after idle. This preserves repeated letters such as the two Ls in “hello.” The longest blink sequence takes 2500 ms.
Commands use six-value bins from 0 to 221; send their center values (3, 9, …, 219). Values 222–254 are unused; 255 is idle. Never send 0 for A: constant LOW has no pulse to measure.
Full PWM-reading helper
int readPWM() {
int total = 0;
int firstBucket = -1;
// Three consistent samples reduce measurement noise.
for (int sample = 0; sample < 3; sample++) {
unsigned long highTime = pulseIn(PWM_INPUT_PIN, HIGH, READ_TIMEOUT_US);
if (highTime == 0) {
// Our sender holds the line HIGH between commands.
return digitalRead(PWM_INPUT_PIN) == HIGH ? IDLE_SIGNAL : -1;
}
unsigned long lowTime = pulseIn(PWM_INPUT_PIN, LOW, READ_TIMEOUT_US);
if (lowTime == 0) return -1;
unsigned long period = highTime + lowTime;
// This example uses the Uno R3's default D9 PWM (~490 Hz).
if (period < 1500 || period > 2500) return -1;
int signal = (highTime * 255UL + period / 2) / period;
if (signal >= CHAR_COUNT * VALUES_PER_CHAR) return -1;
int bucket = signal / VALUES_PER_CHAR;
if (sample > 0 && bucket != firstBucket) return -1;
firstBucket = bucket;
total += signal;
}
return total / 3;
}Complete read-and-blink function with command boundaries
void readAndBlink() {
int signal = readPWM();
if (signal == IDLE_SIGNAL) {
readyForCommand = true; // Next frame may contain the same letter.
return;
}
if (signal < 0 || !readyForCommand) return;
readyForCommand = false; // One frame produces one character.
Serial.print("Received PWM value: ");
Serial.println(signal);
mapPWMToChar(signal);
}MorseBlinker.cpp: the assembled handler implementation
#include "MorseBlinker.h"
namespace {
const int VALUES_PER_CHAR = 6;
const int IDLE_SIGNAL = 255;
const unsigned long COMMAND_HOLD_MS = 80;
const unsigned long COMMAND_WAIT_MS = 3000;
uint8_t pwmOutputPin = 9; // PWM-capable Uno R3 pin.
int charToPWM(char character) {
if (character >= 'a' && character <= 'z') {
character = character - 'a' + 'A';
}
int index;
if (character >= 'A' && character <= 'Z') {
index = character - 'A';
} else if (character >= '0' && character <= '9') {
index = 26 + character - '0';
} else if (character == ' ') {
index = 36;
} else {
return -1; // This component supports letters, digits, and spaces.
}
return index * VALUES_PER_CHAR + 3; // Send near the bin center.
}
void sendPWMCommand(int signal) {
analogWrite(pwmOutputPin, signal);
delay(COMMAND_HOLD_MS);
analogWrite(pwmOutputPin, IDLE_SIGNAL);
// Longest character: 5 * (50 + 400 + 50) = 2500 ms.
// Give the component time to finish and detect idle before the next frame.
delay(COMMAND_WAIT_MS);
}
}
void beginMorse(uint8_t pin) {
pwmOutputPin = pin;
digitalWrite(pwmOutputPin, HIGH);
pinMode(pwmOutputPin, OUTPUT);
analogWrite(pwmOutputPin, IDLE_SIGNAL);
delay(100); // Establish idle before the first command.
}
void blinkChar(char character, bool pause) {
int signal = charToPWM(character);
if (signal < 0) return;
sendPWMCommand(signal);
if (pause) sendPWMCommand(charToPWM(' '));
}
void blinkStr(const char* text, bool pause) {
if (text == nullptr) return;
for (int i = 0; text[i] != '\0'; i++) {
blinkChar(text[i], false);
}
if (pause) blinkChar(' ', false);
}
Files and wiring
Receiver: MorseComponent/MorseComponent.ino.
Sender: HelloMorse.ino, MorseBlinker.h, and MorseBlinker.cpp in one HelloMorse folder.
Sender D9 → receiver D2. Join GND. Use two 5V Uno R3 boards with default timer settings. Power each from USB; leave their 5V pins unconnected. Upload the receiver first, then reset the sender. The message plays once.
Keep this sender’s D9 PWM separate from Servo.h, which uses Timer1 on the Uno. The example is deliberately slow and has no acknowledgment/retry.
Letters, digits, and spaces are supported; lowercase is accepted. Other characters are ignored. These are classroom blink timings, not standard Morse timing.
Download all complete examples · Wiring and usage notes
Arduino compilation and hardware behavior have not been verified.
Reference notes
End of the optional section.
Ready to return to the main lesson.