7.6 Morse Code
Every project so far has been a one-way conversation: the board talks, you watch. This one listens. You type a message into the console, the board looks up each letter in a table of dots and dashes, and blinks it out on a single LED, using the same code that carried messages down telegraph wires for a century before anyone had a computer.
The circuit is the simplest one in the kit: one LED, one resistor, three wires. All of the interest is in the program.
In this documentation you will learn:
- How to read text you type into the console with
input() - What a dictionary is, and why it beats a long chain of
ifstatements - How
get()looks something up without crashing when it is not there - How to write your own functions to keep a longer program readable
- How Morse code measures everything in units instead of milliseconds
Hardware required:
- 1x Soldered NULA MINI board
- 1x Breadboard
- 1x LED (any colour)
- 1x 330 Ω resistor (orange-orange-brown)
- 3x Jumper wires
- 1x USB-C cable
Putting the components together
This project uses a single pin: IO2, which sits in row 25 on the f–j side. The board body covers columns f–i, so j25 and j30 are the only holes you can actually get a wire into at that end. Everything else happens further along the same f–j side, well clear of the board.
IO2 on the f–j side but TX on the a–e side. Count the rows on the same side you are plugging into.1. Insert the NULA MINI board on the breadboard
Push the board into one end of the breadboard so that its two rows of pins sit on either side of the centre channel, with the chip facing down. It should occupy rows 25 to 30.

2. Make the ground bus and bring IO2 out
Two wires go in here, because they are the two ends of the circuit you are about to build between them.
First, a jumper from j30 (GND) across to the blue − rail. That rail becomes the shared ground line, exactly as in every other project in this kit.
Then a jumper from j25 (IO2) along to j15. This carries the Morse signal out from under the board to a row you can actually reach.

3. Add the 330 Ω resistor
Push the resistor in so that it bridges row 15 and row 13. Its bands read orange-orange-brown. A resistor has no polarity, so either way round is correct.

4. Add the LED
The LED does have a polarity. Its longer leg is the anode, and that one goes into row 13, alongside the resistor. The shorter leg goes into row 11.

5. Connect the LED to ground
One last jumper, from j11 to the blue − rail. That closes the loop: IO2 → resistor → LED → ground.

6. Connect the board to your computer
Plug the USB-C cable into the board and into your computer. The purple PWR LED comes on; the green LED stays dark, because nothing has been typed yet.

How the circuit works
Only three rows matter, and they sit in series, so the current has exactly one path to follow:
| Row | What is in it |
|---|---|
| 15 | the jumper from IO2, and one leg of the resistor |
| 13 | the other leg of the resistor, and the long leg of the LED |
| 11 | the short leg of the LED, and the jumper to the − rail |
All five holes in a row are joined inside the breadboard, which is what lets two different components meet in one row without touching each other. It is also why the exact row numbers do not matter: any three free rows work, as long as the order IO2 → resistor → LED → ground is preserved.
When the script calls led.value(1), IO2 is driven to 3.3 V, current flows down that chain and the LED lights. led.value(0) puts the pin back to 0 V and the LED goes out. That is the whole of the hardware. Everything else on this page is program.
How Morse code works
Morse does not measure its signals in milliseconds. It measures everything in units, where one unit is the length of a dot, and every other duration is a multiple of it:
| Length | |
|---|---|
| Dot | 1 unit of light |
| Dash | 3 units of light |
| Gap between the dots and dashes within a letter | 1 unit of silence |
| Gap between letters | 3 units of silence |
| Gap between words | 7 units of silence |
That is why the script contains only one real number. DOT_DURATION is 300 ms and everything else is calculated from it:
DOT_DURATION = 300
DASH_DURATION = DOT_DURATION * 3
SYMBOL_GAP = DOT_DURATION
LETTER_GAP = DOT_DURATION * 3
WORD_GAP = DOT_DURATION * 7
Change DOT_DURATION on its own and the whole transmission speeds up or slows down while staying correct Morse. Set it to 100 and you get a fast operator; set it to 600 and you get a slow one.
The table of letters, as a dictionary
The script needs to turn a character into a string of dots and dashes. It could do that with twenty-six if statements, but instead it uses a dictionary: a table of pairs where you look something up by naming it.
MORSE_TABLE = {
"A": ".-", "B": "-...", "C": "-.-.", "D": "-..", "E": ".",
...
" ": " ",
}
Each entry pairs a key, the character, with a value, the code that stands for it. Looking one up is as simple as naming the key, and adding a character of your own means adding one line here and changing nothing else. That is the whole advantage over a chain of if statements.
struct to hold each letter-and-code pair, an array of those structs, a count worked out with sizeof, and a loop to search through it. Python has all of that built in, which is why the MicroPython version of this project is fifteen lines of code shorter than the Arduino one.The last entry pairs a space with a space, which is how the script recognises the gap between two words.
Looking up safely with get()
def get_morse_code(character):
return MORSE_TABLE.get(character.upper(), "")
Two things happen in that one line.
upper() turns a lowercase letter into an uppercase one, so typing sos works just as well as SOS, because the table only holds capitals.
get() is the safe way to read from a dictionary. Writing MORSE_TABLE["#"] for a character that is not in the table raises a KeyError and stops the program. get() takes a second argument for exactly that case, and here it is empty text, so an unknown character quietly produces nothing, and the transmitting function skips over it.
get() the whole script would crash on the first one.Two loops over the same text
transmit_text() walks through your message twice. The first pass collects the translation and prints it so you can read along; the second pass blinks the same thing out on the LED. Splitting it in two is what lets the whole translation appear on screen immediately, before the LED has finished spelling out even the first letter.
codes = []
for character in text:
codes.append(get_morse_code(character))
print("Morse: ", " ".join(codes))
" ".join(codes) glues a list of pieces into one string with a space between each pair. It is the Python way of building a line out of parts, and it reads better than adding strings together one at a time.In the blinking pass, a space is not blinked at all. It is a pause. When the loop finds one it waits a word gap and uses continue to skip the rest of that pass. Every other character has its symbols blinked one by one, and then the loop waits a letter gap before moving on.
for symbol in code: walks through a string one character at a time, so a code of "..." gives three passes with symbol being . each time. Strings behave like lists of characters in Python, which saves indexing into them by hand.The gaps are slightly generous
There is one thing worth knowing before you start counting blinks. blink_symbol() ends every symbol with one unit of silence, and then transmit_text() adds LETTER_GAP on top of it. So the gap you actually see between two letters is 1 + 3 = 4 units, not 3, and between two words it is 1 + 3 + 7 = 11 units, not 7.
Real Morse replaces the symbol gap rather than adding to it. The difference does not make the message wrong. Every dot and dash is exactly the right length, and the gaps are still clearly distinguishable from one another; the transmission is just a little slower than the standard. It is worth knowing so that what you count matches what you expect.
Code
Below is the full example code for this project:
from machine import Pin
import time
# This is a variable to which we pass the number of pin that we had connected the LED to.
# The NULA board has a pin naming logic as follows: IO2, where 2 is the number that we give to the variable.
#
# Remember that the LED needs a 330 Ohm resistor in series with it. That resistor limits how much current flows, and
# without it the LED draws more than either it or the pin is built for, so both can be damaged.
LED_PIN = 2
# Morse code does not measure its signals in milliseconds but in units, and every other duration is a multiple of one
# unit. That is why only the first value below is a real number and the rest are calculated from it: change
# DOT_DURATION alone and the whole transmission speeds up or slows down while staying correct Morse.
DOT_DURATION = 300
DASH_DURATION = DOT_DURATION * 3
SYMBOL_GAP = DOT_DURATION
LETTER_GAP = DOT_DURATION * 3
WORD_GAP = DOT_DURATION * 7
# This is our lookup table: it pairs every character we can send with the dots and dashes that stand for it. In
# Python this kind of table is called a dictionary, and looking something up in it is as simple as naming the
# character you want.
# The last entry pairs a space with a space, which is how we recognise the gap between two words.
MORSE_TABLE = {
"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": "-----",
" ": " ",
}
# Here we create our Pin object for the LED. Pin.OUT tells the board that this pin should write a value instead of
# reading one. Right after that we write 0 to it, so the LED starts out switched off.
led = Pin(LED_PIN, Pin.OUT)
led.value(0)
def get_morse_code(character):
# This is a function we wrote ourselves. It takes a character and hands back the dots and dashes that stand for it.
# upper() turns a lowercase letter into an uppercase one, so that typing "sos" works just as well as "SOS".
# get() looks the character up in our table, and the second value we give it is what comes back when the
# character is not in the table at all. Here that is empty text, which the transmitting function simply skips.
return MORSE_TABLE.get(character.upper(), "")
def blink_symbol(symbol):
# This function blinks out a single dot or dash. It picks the right duration for the symbol it was given, switches
# the LED on for exactly that long, and then switches it off and waits one more unit, which is the silence that
# separates one symbol from the next.
if symbol == ".":
duration = DOT_DURATION
else:
duration = DASH_DURATION
led.value(1)
time.sleep_ms(duration)
led.value(0)
time.sleep_ms(SYMBOL_GAP)
def transmit_text(text):
# This function transmits a whole piece of text. It does the work twice over: first it prints the translation to
# the console so you can read along, and then it blinks the same thing out on the LED.
print()
print("--- TRANSMITTING ---")
print("Text: ", text)
# This first loop walks through the text one character at a time. For each of them we look up the code and
# collect it, and join() then glues the collected pieces together with a space between them.
codes = []
for character in text:
codes.append(get_morse_code(character))
print("Morse: ", " ".join(codes))
print("--------------------")
# This second loop walks through the very same text again, but this time it blinks instead of printing.
# A space is not blinked at all, it is a pause, so when we find one we wait a word gap and skip the rest of this
# pass with continue. Otherwise the inner loop blinks the symbols of the letter one by one, and once they are done
# we wait a letter gap before moving on to the next letter.
for character in text:
code = get_morse_code(character)
if code == " ":
time.sleep_ms(WORD_GAP)
continue
for symbol in code:
blink_symbol(symbol)
time.sleep_ms(LETTER_GAP)
print()
print("Transmission complete!")
print()
# Invite the user to type something. Type into the console of your editor and press Enter.
print("Enter text to send via Morse code:")
while True:
# input() waits until you type a line and press Enter, then hands us what you typed. This is the simplest way of
# getting information from the computer into the board, and it is the counterpart of the print() we have been
# using in the other direction all along.
text = input()
# strip() removes any stray spaces or leftover line endings from both ends, which is worth doing because
# different systems end their lines slightly differently.
text = text.strip()
# Finally we check that something is actually left after stripping, so that pressing Enter on an empty line does
# not start a transmission, and hand the text over to our own function above.
if len(text) > 0:
transmit_text(text)
What you should see
Press Run. The board greets you and then waits:
Enter text to send via Morse code:
Click into the Shell, type a message and press Enter. Type it in lowercase, as sos, to see something useful:
--- TRANSMITTING ---
Text: sos
Morse: ... --- ...
--------------------
Transmission complete!
The Text: line echoes back exactly what you typed, lowercase and all, but the Morse: line is correct anyway. That is upper() inside get_morse_code() doing its work: the table only holds capital letters, and every character is converted before it is looked up. The translation appears at once, all of it, and only then does the LED start blinking.
input() is waiting for a line from the console, and keystrokes typed into the editor pane go into your file instead. If nothing happens when you type, that is almost always why.The Transmission complete! line does not appear until the LED has finished. That is worth noticing: the whole of transmit_text() is built out of time.sleep_ms(), so while the message is going out the board does nothing else at all. You can type during a transmission, but nothing will happen until the current one ends.
Watch it once and the three letters separate themselves out:
| Letter | Code | What the LED does |
|---|---|---|
| S | ... | three short flashes, close together |
| O | --- | three long flashes, close together |
| S | ... | three short flashes again |
The flashes themselves are easy to tell apart once you know what to compare: a dash is lit for three times as long as a dot. The gaps do the rest of the work: a single unit of darkness between the symbols inside a letter, and four units between one letter and the next, which is long enough to see the rhythm break without counting anything.
SOS takes 9.9 seconds end to end, and you can work that out on paper. Three dots, three dashes and three dots is 15 units of light. Each of the nine symbols is followed by one unit of silence, and each of the three letters by three more, which is 18 units of silence. That is 33 units altogether, and 33 × 300 ms is 9.9 seconds.
Once Transmission complete! appears, the board is back to waiting and you can type something else. Try a word with a space in it, like HELLO WORLD, and watch for the long pause in the middle. That is the word gap, and it is by far the longest silence in any message.
hello. The full stop is not in the table, so get() hands back empty text and it is simply skipped. There is no error and no crash, and the rest of the message transmits normally.If the LED never lights, the script is almost certainly fine. Check the polarity of the LED first. Turn it around: its long leg belongs in row 13, with the resistor, and an LED fitted backwards conducts nothing at all, which looks exactly like a dead circuit. If the console prints the translation but the LED stays dark, the program is running correctly and the fault is definitely in the wiring, which narrows it down nicely.
Full example
Check out the full example code on the link below:
7.6_Morse_code_transmitter.py
Project that turns text you type into the console into Morse code and blinks it out on a single LED, using a dictionary as a lookup table and functions of its own.