Problem Overview and Engineering Requirements
The challenge is straightforward: a Siemens S7-1200 (or S7-1500) holds a string such as 'ABCD' and must emit a Morse-code representation of each character one after the other on a single digital output. The naive approach - a long ladder of IF String[N] = 'A' THEN... blocks - fires every character's pattern simultaneously because all IF conditions evaluate every scan. What is required is a true sequential state machine with three responsibilities:
- Look up the Morse pattern for the current character from a constant data block.
- Drive the key output for the duration of the next element (dot or dash).
- Insert the correct inter-element, inter-character, and inter-word gap before advancing the index.
This reference implementation is built on TIA Portal V13 SP1 or later for the S7-1200 firmware 4.x family, and ports cleanly to S7-1500 firmware 2.x. All code is written in SCL (Structured Control Language) so it can be copy-pasted into a single FB and reused on any project that needs deterministic character-at-a-time serial output. The pattern generalises to any sequential code table: morse, Baudot, 7-segment scanning, LED matrix refresh, or one-wire protocols.
Prerequisites
| Item | Requirement |
|---|---|
| Engineering software | TIA Portal V13 SP1 Update 9 or newer (V15, V16, V17 also supported) |
| Controller | SIMATIC S7-1200 CPU 1212C / 1214C / 1215C / 1217C or any S7-1500 CPU |
| Firmware | S7-1200 firmware V4.2 or higher (required for full SCL features) |
| Language | SCL (Structured Control Language) |
| Data blocks | 1 global DB for the Morse lookup table (constant) |
| Function blocks | 1 FB for the sequencer (multi-instance capable) |
| Tags required | 1 BOOL start trigger, 1 BOOL key output, 1 STRING input |
| Cycle time | 10 ms or smaller recommended (timer resolution depends on OB1 period) |
System Architecture
The complete pattern uses three artefacts plus OB1 wiring:
The sequencer FB owns all mutable state (CurrentIndex, CodePtr, the two TON timers, and the active State). The DB holds only constants - this lets you put the Morse alphabet in a single block you can also expose read-only on an HMI for diagnostics.
Morse Code Data Block
Build a global DB called DB_MorseCodes with two constant arrays. The first stores the dot/dash pattern as a STRING so you can read it from a watch table, and the second stores the pattern length so the sequencer knows when to stop without calling LEN every cycle. The array indices match the uppercase ASCII offset: index 0 = 'A', index 25 = 'Z'.
Create the DB and paste the following SCL source view (right-click the DB > Properties > Attributes > uncheck 'Optimised block access' if you want classic DB access from HMI tags; for S7-1500 leave optimised on and use symbolic access only):
DATA_BLOCK "DB_MorseCodes"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
STRUCT
Codes : ARRAY[0..25] OF STRING[6] := [
'.-', // 0 A
'-...', // 1 B
'-.-.', // 2 C
'-..', // 3 D
'.', // 4 E
'..-.', // 5 F
'--.', // 6 G
'....', // 7 H
'..', // 8 I
'.---', // 9 J
'-.-', // 10 K
'.-..', // 11 L
'--', // 12 M
'-.', // 13 N
'---', // 14 O
'.--.', // 15 P
'--.-', // 16 Q
'.-.', // 17 R
'...', // 18 S
'-', // 19 T
'..-', // 20 U
'...-', // 21 V
'.--', // 22 W
'-..-', // 23 X
'-.--', // 24 Y
'--..' // 25 Z
];
CodeLengths : ARRAY[0..25] OF INT := [
2,4,4,3,1,4,3,4,2,4,3,4,2,2,3,4,4,3,3,1,3,4,3,4,4,4
];
END_STRUCT;
END_DATA_BLOCK
The complete international Morse alphabet is defined by the ITU-R M.1677 recommendation. The patterns above cover the 26 uppercase Latin letters. To extend to digits and punctuation, expand the array range and add the corresponding entries.
| Char | Code | Char | Code | Char | Code |
|---|---|---|---|---|---|
| A | .- | J | .--- | S | ... |
| B | -... | K | -.- | T | - |
| C | -.-. | L | .-.. | U | ..- |
| D | -.. | M | -- | V | ...- |
| E | . | N | -. | W | .-- |
| F | ..-. | O | --- | X | -..- |
| G | --. | P | .--. | Y | -.-- |
| H | .... | Q | --.- | Z | --.. |
| I | .. | R | .-. |
State Machine Design
The sequencer walks through five states. Each state owns one job and a single transition condition:
| State ID | Name | Action | Next state |
|---|---|---|---|
| 0 | IDLE | Wait for StartTrigger. When seen, copy next character from SourceString, reset CodePtr, raise busy. |
1 LOAD |
| 1 | LOAD | If pointer < code length, fetch element at CodePtr, load DotTimer with 1u (dot) or 3u (dash), drive key output. Else, transition to CHAR_GAP. |
2 KEY |
| 2 | KEY | Hold key output ON until DotTimer.Q. Then drop key, load GapTimer with 1u. |
3 GAP |
| 3 | GAP | Hold key output OFF until GapTimer.Q. Increment CodePtr, loop back to LOAD. |
1 LOAD |
| 4 | CHAR_GAP | Already in the gap; after 1u timer expires, load an additional 2u for inter-character spacing. If character was a space, load 4u more for inter-word. | 5 ADVANCE |
| 5 | ADVANCE | Increment CurrentIndex. If < LEN(SourceString), go to LOAD for next character. Else drop busy, raise done. |
0 IDLE |
The dot unit is defined by the DotDuration input. Dash = 3 * DotDuration. Inter-element gap = 1 * DotDuration. Inter-character gap = 3 * DotDuration. Inter-word gap = 7 * DotDuration. These ratios are mandated by ITU-R M.1677 and ensure the receiver can decode regardless of the absolute speed.
FB_MorseSequencer SCL Implementation
Create a new Function Block named FB_MorseSequencer. Paste the following code into the SCL editor. The block uses two TON timers in multi-instance form (no separate DB instance required - the FB's instance DB holds them). The SourceString input accepts any STRING up to 80 characters, which fits the default S7-1200 string capacity and avoids the 254-byte ceiling of older CPUs.
FUNCTION_BLOCK "FB_MorseSequencer"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 1.0
VAR_INPUT
iStart : BOOL; // Rising edge starts transmission
iReset : BOOL; // Force back to IDLE
sMessage : STRING[80]; // Text to transmit
tDotDuration : TIME := T#200ms; // 1 unit of Morse time
END_VAR
VAR_OUTPUT
qKey : BOOL; // Drive output to transmitter key
qBusy : BOOL; // Transmission in progress
qDone : BOOL; // Set for one cycle when whole string sent
qCharIndex : INT; // Diagnostic: current source index (0-based)
qState : INT; // Diagnostic: current state ID
END_VAR
VAR
State : INT := 0; // 0=IDLE 1=LOAD 2=KEY 3=GAP 4=CHAR_GAP 5=ADVANCE
CharIndex : INT := 0;
CodePtr : INT := 0;
CurrentChar : CHAR;
ElementIsDash : BOOL;
DotTimer : TON;
GapTimer : TON;
ExtraGapTimer : TON;
LastStart : BOOL;
EdgeStart : BOOL;
END_VAR
VAR CONSTANT
ST_IDLE : INT := 0;
ST_LOAD : INT := 1;
ST_KEY : INT := 2;
ST_GAP : INT := 3;
ST_CHAR_GAP : INT := 4;
ST_ADVANCE : INT := 5;
END_VAR
BEGIN
// ---- Edge detection on start trigger ----
EdgeStart := iStart AND NOT LastStart;
LastStart := iStart;
// ---- Reset has highest priority ----
IF iReset THEN
State := ST_IDLE;
CharIndex := 0;
CodePtr := 0;
DotTimer(IN := FALSE, PT := tDotDuration);
GapTimer(IN := FALSE, PT := tDotDuration);
ExtraGapTimer(IN := FALSE, PT := tDotDuration);
qKey := FALSE;
qBusy := FALSE;
qDone := FALSE;
qCharIndex := 0;
qState := ST_IDLE;
RETURN;
END_IF;
// ---- Main state machine ----
CASE State OF
ST_IDLE:
qKey := FALSE;
qBusy := FALSE;
DotTimer(IN := FALSE, PT := tDotDuration);
GapTimer(IN := FALSE, PT := tDotDuration);
ExtraGapTimer(IN := FALSE, PT := tDotDuration);
IF EdgeStart AND LEN(sMessage) > 0 THEN
CharIndex := 1; // S7-STRING is 1-based
CodePtr := 0;
qBusy := TRUE;
State := ST_LOAD;
END_IF;
ST_LOAD:
qKey := FALSE;
CurrentChar := sMessage[CharIndex];
// Normalise: only A-Z supported. Lowercase gets uppercased by offset.
IF CurrentChar >= 'a' AND CurrentChar <= 'z' THEN
CurrentChar := CHAR(ORD(CurrentChar) - 32);
END_IF;
IF CurrentChar >= 'A' AND CurrentChar <= 'Z' THEN
// Map 'A'..'Z' to index 0..25
IF CodePtr < "DB_MorseCodes".CodeLengths[ORD(CurrentChar) - ORD('A')] THEN
ElementIsDash := (MID("DB_MorseCodes".Codes[ORD(CurrentChar) - ORD('A')],
CodePtr + 1, 1) = '-');
IF ElementIsDash THEN
DotTimer(IN := TRUE, PT := tDotDuration * 3);
ELSE
DotTimer(IN := TRUE, PT := tDotDuration);
END_IF;
State := ST_KEY;
ELSE
// End of this character, schedule inter-character gap
IF CurrentChar = ' ' THEN
ExtraGapTimer(IN := TRUE, PT := tDotDuration * 4);
ELSE
ExtraGapTimer(IN := TRUE, PT := tDotDuration * 2);
END_IF;
State := ST_CHAR_GAP;
END_IF;
ELSE
// Non-alphabetic, non-space: skip with same gap as a space
ExtraGapTimer(IN := TRUE, PT := tDotDuration * 4);
State := ST_CHAR_GAP;
END_IF;
ST_KEY:
qKey := TRUE;
DotTimer(IN := TRUE);
IF DotTimer.Q THEN
DotTimer(IN := FALSE);
qKey := FALSE;
GapTimer(IN := TRUE, PT := tDotDuration);
State := ST_GAP;
END_IF;
ST_GAP:
GapTimer(IN := TRUE);
IF GapTimer.Q THEN
GapTimer(IN := FALSE);
CodePtr := CodePtr + 1;
State := ST_LOAD;
END_IF;
ST_CHAR_GAP:
ExtraGapTimer(IN := TRUE);
IF ExtraGapTimer.Q THEN
ExtraGapTimer(IN := FALSE);
State := ST_ADVANCE;
END_IF;
ST_ADVANCE:
CharIndex := CharIndex + 1;
CodePtr := 0;
IF CharIndex > LEN(sMessage) THEN
State := ST_IDLE;
qBusy := FALSE;
qDone := TRUE;
ELSE
State := ST_LOAD;
END_IF;
ELSE
State := ST_IDLE;
END_CASE;
// ---- Diagnostic outputs ----
qCharIndex := CharIndex;
qState := State;
// ---- One-shot done flag ----
IF qDone THEN
qDone := FALSE; // pulse for one cycle
END_IF;
END_FUNCTION_BLOCK
sMessage[CharIndex] where CharIndex = 1 refers to the first character. LEN() returns the actual length, not the capacity.
OB1 Main Program Wiring
From OB1, instantiate the FB in a global DB called IDB_Morse. The wiring shown below uses a tag from the default tag table (tag_StartBtn) and the string tag_MessageText as inputs. The output drives a digital output module channel wired to the Morse key relay or optocoupler.
// OB1 - Main cyclic program
"IDB_Morse"(iStart := "tag_StartBtn",
iReset := "tag_ResetBtn",
sMessage := "tag_MessageText",
tDotDuration := T#120ms, // ~20 WPM (PARIS method)
qKey => "%Q0.0",
qBusy => "tag_MorseBusy",
qDone => "tag_MorseDone",
qCharIndex => "tag_MorseCharIndex",
qState => "tag_MorseState");
Timing Parameters and the PARIS Standard
Morse speed is universally measured in words per minute using the PARIS benchmark (the word "PARIS" plus one space = 50 dot units). The conversion formula is:
DotDuration (seconds) = 1.2 / WPM
| Speed (WPM) | Dot duration | Dash duration | Char gap | Word gap | Typical use |
|---|---|---|---|---|---|
| 5 | 240 ms | 720 ms | 720 ms | 1680 ms | Beginner CW practice |
| 10 | 120 ms | 360 ms | 360 ms | 840 ms | Slow conversational |
| 15 | 80 ms | 240 ms | 240 ms | 560 ms | Standard CW QRP |
| 20 | 60 ms | 180 ms | 180 ms | 420 ms | Amateur radio standard |
| 25 | 48 ms | 144 ms | 144 ms | 336 ms | Experienced operators |
| 30 | 40 ms | 120 ms | 120 ms | 280 ms | Contest / high-speed CW |
For amateur-radio applications on HF (3.5 - 30 MHz), 20 WPM (60 ms dot) is the de facto default and matches the FB default of T#200ms when scaled. Tune tDotDuration at the OB1 call site rather than editing the FB so the same block can drive multiple keys at different speeds.
Verification Procedure
- Compile and download. Right-click the PLC in the project tree > Compile > Software (rebuild all). Download to the target CPU. Clear CPU to RUN.
-
Online watch. Open
IDB_Morsein the watch table. Add the static tagsState,CharIndex,CodePtr,CurrentChar,DotTimer.Q, andGapTimer.Qwith 'Monitor all' toggled on. -
Force iStart. Click on the
iStartoperand and use 'Modify to 1'. Confirm thatStateadvances 0 → 1 → 2 → 3 cyclically and thatqKeypulses high for the dot/dash interval. -
Scope the output. Connect an oscilloscope or logic analyser to
%Q0.0. Trigger on the rising edge. Measure the high-time of the first pulse: for an 'A' it should equalDotDuration(the dot), then a gap of equal duration, then another high-time of3 * DotDuration(the dash). -
End-to-end test. Set
tag_MessageText := 'SOS'. The scope should show three short, three long, three short pulses in the canonical distress pattern, followed by seven dot-units of silence before theqDonepulse. -
Edge case test. Load
'HI MOM'. Verify that the gap between 'M' and 'M' is exactly 3 dot units while the gap between 'M' and the next character across the space is 7 dot units. If the space gap is wrong, check theST_LOADbranch that comparesCurrentChar = ' '.
Troubleshooting Matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| All characters fire at once, output stuck high | Original ladder logic running in parallel; sequencer FB not actually driving %Q0.0
|
Delete the IF String[N] = 'A' THEN blocks and route qKey directly to the output |
| Output never goes high |
EdgeStart not detected because start tag held high continuously |
Use a momentary pushbutton or add a positive-edge helper flag in OB1 |
| First element missing, dashes only |
CodePtr starts at 1 instead of 0 (off-by-one in ST_LOAD) |
Confirm CodePtr := 0 in ST_IDLE and after ST_ADVANCE
|
| Dots and dashes at random durations | OB1 cycle time > 1/5 of DotDuration
|
|
| Compiler error "MID requires STRING variable" | DB array element not being indexed properly because DB is optimised and accessed by literal | Use symbolic access: "DB_MorseCodes".Codes[idx] with a variable index, not a literal |
| Compilation warning "Uninitialised variable" | Static tags declared without default; TIA Portal V13 SP1 is stricter than older versions | Add := 0 or := '' to every static declaration |
| Receiver decodes garbage at high speed | Inter-character gap only 1u instead of 3u because ExtraGapTimer was set to tDotDuration rather than tDotDuration * 2
|
|
| Transmission never finishes (qDone never set) | Empty string or zero-length DB array access at end | Guard ST_ADVANCE with CharIndex > LEN(sMessage) and skip lookup when CharIndex is past end |
| Lowercase letters corrupt output | Lowercase 'a' maps to ASCII 97, falling outside the 0..25 array range | Uppercase via ORD(c) - 32 in ST_LOAD as shown above |
Edge Cases and Field Notes
-
Re-triggering during transmission: Calling
iStartwhileqBusyis true is ignored. To re-trigger mid-message, raiseiResetfirst then pulseiStart. -
Multi-instance usage: The FB is multi-instance capable. Drop two instances -
IDB_MorseAandIDB_MorseB- and call them with differenttDotDurationvalues to drive two CW transmitters at different speeds from one CPU. -
PROFINET output module: If
%Q0.0lives on a remote ET200SP, account for PROFINET update time (typically 1 ms). The dot duration must be at least 10 ms to remain immune to update jitter. - Cycle time impact: Each FB call adds roughly 5 to 15 microseconds per instance on an S7-1214C. At 10 ms OB1 period this is negligible; at 1 ms cycle time ten instances will consume about 1 percent of CPU budget.
-
String 80 vs 254: S7-1200 strings default to 254 bytes max. Declaring
STRING[80]saves 174 bytes of instance memory per FB call. If you need longer messages, declareSTRING[254]and the rest of the code works unchanged. -
HMI text panel: To send text from a KTP700 Basic HMI, use a text field tied to
tag_MessageText(STRING). The HMI panel's variable pointer must be set to WString-compatible string type to avoid truncation at non-ASCII characters. -
Safety integration: If the key output drives a transmitter on a manned vessel or in an aircraft simulator, route
qKeythrough an F-CPU safety gate. The F-blockFDB_MorseKeySafecan wrap the output with a 100-ms discrepancy check against a second channel before the relay pulls in.
FAQ
What is the minimum TIA Portal version for this code?
TIA Portal V13 SP1 Update 9 with S7-1200 firmware 4.2 or newer is the minimum. The code uses only standard SCL features (CASE, TON, MID, STRING indexing) that have been present since V11, but V13 SP1 is required for the optimised DB syntax and the CHAR/ORD functions used in the lowercase-to-uppercase conversion.
How do I change the Morse speed without editing the FB?
Pass a different TIME constant at the OB1 call site: tDotDuration := T#60ms for 20 WPM, T#120ms for 10 WPM. The dot is the fundamental unit and every other interval (dash, gap, char gap, word gap) is derived as a multiple of it.
Why does my output stay high forever after the message finishes?
The most common cause is the sequencer stuck in ST_KEY because DotTimer.Q never goes true. Verify that DotTimer(IN := TRUE) is called every cycle in ST_KEY - omitting the call disables the running timer. Also check that tDotDuration is not zero.
Can the FB handle digits 0 to 9 and punctuation?
Yes. Extend DB_MorseCodes.Codes to ARRAY[0..35] (or 0..39 with punctuation), add the additional patterns, and add a second CASE branch in ST_LOAD for the '0'..'9' range. The mapping changes to ORD(CurrentChar) - ORD('0') + 26 for digits.
How do I reset the sequencer from an HMI?
Wire a boolean tag from the HMI button to the iReset input. Holding iReset for one cycle is enough to force the FB back to ST_IDLE, clear all timers, and drop qBusy. The next rising edge on iStart restarts from the first character.