The project is pretty straightforward and flexible. The Baofeng HT can be used to monitor any frequency in the ham bands for monitoring sat passes, simplex frequencies, etc. Below is the code that was, like all code today, written with the help of AI:
Friday, May 1, 2026
Logging Ham Radio Repeater Usage with a Baofeng
The project is pretty straightforward and flexible. The Baofeng HT can be used to monitor any frequency in the ham bands for monitoring sat passes, simplex frequencies, etc. Below is the code that was, like all code today, written with the help of AI:
Sunday, April 5, 2026
Bug Key Morse Code (CW) Simulator with ESP32
-----
When is comes to ham radio operators that use Morse code keys each has their favorite and none have just one. Morse keys can differ in many ways, but the bug key is typically the most expensive and the most difficult to learn. Real bugs mechanically (not electronically) "automate" the DITs while the DAHs are still produced manually. It's a throwback to the times when effortless high speed code was sent without the use of fancy electronics.
-----
That was then and this is now because with fancy electronics we can simulate things. BTW, if you are a SKCC member this isn't a 100% manual key so would not qualify as a bug for a Triple Key Award, but it gives a real bug key feel at price well under a six pack. This allows more OPs the experiment or practice with a bug key before having to lay out real folding money for the experience.
-----
We cobbled this together in a few hours with "stuff in the drawer". A C3 uController was used but any Arduino based device could work.
----
Video of our first try (we could benefit from a little bug practice):
Throw the whole enchilada in a 3D Printed case and it looks like this:
-----
To create your own, wire it up like this and upload the code below to the uController via the Arduino IDE. Presto, instant bug! Note; safety first: We used a PC817 to completely isolate and protect the ham radio rig from having any external voltages sent into it.
Tuesday, March 17, 2026
Poorman's Stream Deck for Flex Radio
-----
The Flex Radio 6400 is an amazing state of the art ham radio transceiver with so many modern bells and whistles than no normal human could ever get around to using them all. However, this can also put off many olde tyme hams that resist changes from the Golden Age of Radio. In an effort to ease this transition we had to create the Drifty Flex and Rotary Phone Interface application. As helpful as this has been, there was still the common complaint that "Dammit, real radios have knobs and buttons!". So, to assist again with the transition to Flex Radio we launched another project.
-----
The Stream Deck is a relatively common Flex Radio add on, but those things can cost over a hundred bucks and that money could be better spent on old J38 Morse keys, etc. In all seriousness, we don't miss the buttons and knobs at all on the Flex. However, we did find moving the mouse quickly to click the small RIT and XIT adjustment buttons in the Smart SDR interface a burden. After seeing this ESP32 Module with onboard 2.8 inch touchscreen 2.8 and WiFi (also called a CYD for "Cheap Yellow Display") for only $9 on AliExpress we got an idea.
-----
The code below splits the CYD touchscreen into four sections to adjust for RIT-, RIT+, XIT-, and XIT+. Adjustment are made in 10Hz steps. If no change is made for 60 minutes the values revert to 0. The rig connects to the Flex Radio over WiFi so only 5VDC power is needed. The IP address of the CYD is displayed in yellow in the bottom right.
-----
It works great. Here is a short video of the rig in action:
-----Want to build your own? It's easy and here is the code to make it happen. As with all programs written today AI both sped up and slowed down progress at times:
/*
* "CYD_StreamDeck" uses ESP32 with touchscreen "Cheap Yellow Display" to provide s
* RIT and XIT control to a FLEXRADIO. If no change for 60 both RIT and XIT
* reset to "0"; edit AUTO_RESET_TIMEOUT variable to adjust.
* Arduino IDE Board Setting: ESP32 Dev Module, 240MHz (WiFi/BT), 921600
* MAR2026
* Project details at: whiskeytangohotel.com
* Custom User_Setup.h for CYD library is:
//#define ILI9341_DRIVER
#define ILI9341_2_DRIVER // ← Uncomment / use this instead of above
#define TFT_WIDTH 240
#define TFT_HEIGHT 320
#define TFT_MISO 12
#define TFT_MOSI 13
#define TFT_SCLK 14
#define TFT_CS 15
#define TFT_DC 2
#define TFT_RST -1
#define TFT_BL 21 // Backlight Original = 21
#define TFT_BACKLIGHT_ON HIGH
#define SPI_FREQUENCY 40000000
#define LOAD_GLCD // Font 1. Original Adafruit 8 pixel font needs ~1820 bytes in FLASH
#define LOAD_FONT2 // Font 2. Small 16 pixel high font, needs ~3534 bytes in FLASH, 96 characters
#define LOAD_FONT4 // Font 4. Medium 26 pixel high font, needs ~5848 bytes in FLASH, 96 characters
#define LOAD_FONT6 // Font 6. Large 48 pixel font, needs ~2666 bytes in FLASH, only characters 1234567890:-.apm
#define LOAD_FONT7 // Font 7. 7 segment 48 pixel font, needs ~2438 bytes in FLASH, only characters 1234567890:.
#define LOAD_FONT8 // Font 8. Large 75 pixel font needs ~3256 bytes in FLASH, only characters 1234567890:-.
#define LOAD_GFXFF // FreeFonts. Include access to the 48 Adafruit_GFX free fonts FF1 to FF48
#define SMOOTH_FONT
*
*
*/
#include <WiFi.h>
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>
#include <SPI.h>
#include <esp_wifi.h>
// WiFi and FlexRadio settings
const char* ssid = "ur-ssid";
const char* password = "ur-wifi-passowrd";
const char* flexIP = "192.168.1.2";
const int flexPort = 4992;
// Touch pins
#define XPT2046_IRQ -1
#define XPT2046_MOSI 32
#define XPT2046_MISO 39
#define XPT2046_CLK 25
#define XPT2046_CS 33
SPIClass touchSPI(VSPI);
XPT2046_Touchscreen ts(XPT2046_CS, XPT2046_IRQ);
// Display
TFT_eSPI tft = TFT_eSPI();
// Flex connection
WiFiClient flexClient;
unsigned long seq = 0;
int current_rit = 0;
int current_xit = 0;
const int step = 10;
// Button layout
#define BUTTON_WIDTH 160
#define BUTTON_HEIGHT 120
// Debounce
unsigned long lastTouchTime = 0;
const unsigned long touchDebounce = 250;
// Auto-reset timers (moved to global scope so parseStatus can see them)
static unsigned long lastRitChange = 0;
static unsigned long lastXitChange = 0;
// Timeout for auto-reset RIT and XIT to "0" if not changed
const unsigned long AUTO_RESET_TIMEOUT = 60UL * 60 * 1000; // 60UL = 60 mins, 30UL = 30 mins, etc.
// --- Display offsets centered on x-axis, narrow boxes for 4 chars, + for positive ---
void drawOffsets() {
tft.setTextColor(TFT_CYAN);
tft.setTextSize(2);
tft.setTextDatum(MC_DATUM); // Middle-center alignment
// Narrow rectangle just for 4 chars
const int rectWidth = 48;
const int rectHeight = 30;
// Horizontal center of the screen
int centerX = tft.width() / 2; // 160
// RIT - same vertical center
int ritY = 16;
// Format RIT: add "+" if positive
String ritStr = (current_rit > 0) ? "+" + String(current_rit) : String(current_rit);
tft.fillRect(centerX - rectWidth/2, ritY - rectHeight/2, rectWidth, rectHeight, TFT_BLACK);
//tft.drawRect(centerX - rectWidth/2, ritY - rectHeight/2, rectWidth, rectHeight, TFT_WHITE);
tft.drawString(ritStr, centerX, ritY);
// XIT - same vertical center
int xitY = 136;
// Format XIT: add "+" if positive
String xitStr = (current_xit > 0) ? "+" + String(current_xit) : String(current_xit);
tft.fillRect(centerX - rectWidth/2, xitY - rectHeight/2, rectWidth, rectHeight, TFT_BLACK);
//tft.drawRect(centerX - rectWidth/2, xitY - rectHeight/2, rectWidth, rectHeight, TFT_WHITE);
tft.drawString(xitStr, centerX, xitY);
}
// --- Display IP address in bottom-right corner ---
void drawIP() {
String ipStr = WiFi.localIP().toString();
tft.setTextColor(TFT_YELLOW, TFT_BLACK);
tft.setTextSize(1); // small text
tft.setTextDatum(BR_DATUM); // align to bottom-right
// Clear previous IP area (110 pixels wide, 20 tall — adjust if text overflows)
tft.fillRect(tft.width() - 120, tft.height() - 30, 110, 20, TFT_BLACK);
// Draw new IP with 5px padding from edges
tft.drawString(ipStr, tft.width() - 5, tft.height() - 5);
}
void drawButtons();
void connectToFlex();
void sendCommand(String cmd);
void parseStatus(String line);
void setup() {
Serial.begin(115200);
delay(200);
pinMode(21, OUTPUT);
digitalWrite(21, HIGH);
tft.init();
tft.setRotation(1);
tft.fillScreen(TFT_BLACK);
delay(50);
tft.writecommand(ILI9341_GAMMASET);
tft.writedata(2);
tft.writecommand(ILI9341_GAMMASET);
tft.writedata(1);
// Touch init
touchSPI.begin(XPT2046_CLK, XPT2046_MISO, XPT2046_MOSI, XPT2046_CS);
ts.begin(touchSPI);
ts.setRotation(2);
tft.invertDisplay(true);
drawButtons();
// Show initial offsets
drawOffsets();
// WiFi connect
WiFi.begin(ssid, password);
esp_wifi_set_ps(WIFI_PS_NONE); // no power saving at all
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
// Show IP once connected
drawIP();
connectToFlex();
// Initialize change timers
lastRitChange = millis();
lastXitChange = millis();
}
void loop() {
if (flexClient.available()) {
String line = flexClient.readStringUntil('\n');
parseStatus(line);
}
if (ts.touched()) {
TS_Point p = ts.getPoint();
if (p.z > 900 && p.z < 3800 && millis() - lastTouchTime > touchDebounce) {
int x = map(p.x, 200, 3700, 0, 320);
int y = map(p.y, 300, 3800, 0, 240);
x = constrain(x, 0, 319);
y = constrain(y, 0, 239);
bool changed = false;
// -------- BUTTON MAPPING --------
if (x < 160 && y < 120) {
current_rit += step;
sendCommand("slice s 0 rit_on=1 rit_freq=" + String(current_rit));
Serial.println("RIT +");
lastRitChange = millis(); // update on change
changed = true;
drawOffsets();
}
else if (x >= 160 && y < 120) {
current_xit += step;
sendCommand("slice s 0 xit_on=1 xit_freq=" + String(current_xit));
Serial.println("XIT +");
lastXitChange = millis();
changed = true;
drawOffsets();
}
else if (x < 160 && y >= 120) {
current_rit -= step;
sendCommand("slice s 0 rit_on=1 rit_freq=" + String(current_rit));
Serial.println("RIT -");
lastRitChange = millis();
changed = true;
drawOffsets();
}
else {
current_xit -= step;
sendCommand("slice s 0 xit_on=1 xit_freq=" + String(current_xit));
Serial.println("XIT -");
lastXitChange = millis();
changed = true;
drawOffsets();
}
if (changed) {
lastTouchTime = millis();
}
}
}
// Auto-reset both RIT and XIT to zero after 60 min of no change
unsigned long now = millis();
if ((now - lastRitChange >= AUTO_RESET_TIMEOUT) &&
(now - lastXitChange >= AUTO_RESET_TIMEOUT)) {
if (current_rit != 0 || current_xit != 0) {
current_rit = 0;
current_xit = 0;
sendCommand("slice s 0 rit_on=0 rit_freq=0");
sendCommand("slice s 0 xit_on=0 xit_freq=0");
Serial.println("Auto-reset: RIT and XIT set to 0 (no change for 60 min)");
drawOffsets();
lastRitChange = now;
lastXitChange = now;
}
}
if (!flexClient.connected()) {
connectToFlex();
}
// Optional: refresh IP every 5 mins = 300 seconds
static unsigned long lastIPRefresh = 0;
if (millis() - lastIPRefresh > 300000) {
drawIP();
lastIPRefresh = millis();
}
}
void drawButtons() {
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_WHITE);
tft.setTextSize(4);
tft.setTextDatum(MC_DATUM);
tft.drawRect(0,0,160,120,TFT_WHITE);
tft.drawString("RIT-",80,60);
tft.drawRect(160,0,160,120,TFT_WHITE);
tft.drawString("RIT+",240,60);
tft.drawRect(0,120,160,120,TFT_WHITE);
tft.drawString("XIT-",80,180);
tft.drawRect(160,120,160,120,TFT_WHITE);
tft.drawString("XIT+",240,180);
}
void connectToFlex() {
if (flexClient.connect(flexIP, flexPort)) {
sendCommand("client program CYD_Control");
sendCommand("sub slice all");
} else {
delay(2000);
}
}
void sendCommand(String cmd) {
String full = "C" + String(seq++) + "|" + cmd + "\n";
flexClient.print(full);
Serial.println(full);
}
void parseStatus(String line) {
if (line.startsWith("S")) {
int pos = line.indexOf("slice 0");
if (pos != -1) {
int ritPos = line.indexOf("rit_freq=", pos);
if (ritPos != -1) {
int end = line.indexOf(" ", ritPos + 9);
if (end == -1) end = line.length();
int newRit = line.substring(ritPos + 9, end).toInt();
if (newRit != current_rit) {
current_rit = newRit;
lastRitChange = millis(); // reset timer on change
}
}
int xitPos = line.indexOf("xit_freq=", pos);
if (xitPos != -1) {
int end = line.indexOf(" ", xitPos + 9);
if (end == -1) end = line.length();
int newXit = line.substring(xitPos + 9, end).toInt();
if (newXit != current_xit) {
current_xit = newXit;
lastXitChange = millis();
}
}
drawOffsets();
}
}
}
-----
Monday, September 29, 2025
Plotting Temperature with the ESP32C3 Dev Module and Node Red
-----
Microcontrollers are getting really cheap. They were already cheap, but now they seem crazy cheap. Even with onboard WiFi, Bluetooth, and a small OLED display we picked up this ESP32C3 Dev Module for about ~$2 USD; so we had to get two. One turned into an extremely useful and accurate clock while this one will be a temperature logger.
-----
We used a DS18B20 temperature sensor. The simple connection of the sensor to the ESP32C3 looks like this:
-----
So, what do you get? A graph like this. Note that we are charting two temperatures on our chart. Your chart will only show the ESP32 line:
-----Now for the software code we promised. Here is the Node Red flow to import:
[
{
"id": "e19a60a2f08ac386",
"type": "inject",
"z": "c0bb5756099d6dbc",
"name": "Every 60 secs",
"props": [],
"repeat": "60",
"crontab": "",
"once": true,
"onceDelay": "1",
"topic": "",
"x": 160,
"y": 120,
"wires": [
[
"018abb4b7bfb7e86",
"41314d52d14f7623"
]
]
},
{
"id": "41314d52d14f7623",
"type": "http request",
"z": "c0bb5756099d6dbc",
"name": "",
"method": "GET",
"ret": "txt",
"paytoqs": "ignore",
"url": "http://192.168.1.67/",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 150,
"y": 180,
"wires": [
[
"2fb863c6f3188fd2"
]
]
},
{
"id": "2fb863c6f3188fd2",
"type": "function",
"z": "c0bb5756099d6dbc",
"name": "Parse ESP32 Temp",
"func": "var payload = msg.payload;\nvar match = payload.match(/Temperature is: ([0-9.]+)/);\n\nif (match) {\n msg.payload = parseFloat(match[1]); // Fahrenheit\n msg.topic = \"ESP32\"; // Add this line\n} else {\n msg.payload = null;\n}\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 430,
"y": 180,
"wires": [
[
"ab11f8582b84df82",
"e024d71190743b50",
"e146810a6d814e42"
]
]
},
{
"id": "ab11f8582b84df82",
"type": "debug",
"z": "c0bb5756099d6dbc",
"name": "ESP32 TempF",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 880,
"y": 180,
"wires": []
},
{
"id": "e024d71190743b50",
"type": "ui_gauge",
"z": "c0bb5756099d6dbc",
"name": "",
"group": "7",
"order": 5,
"width": 5,
"height": 4,
"gtype": "donut",
"title": "ESP32 (°F)",
"label": "°F",
"format": "{{value}}",
"min": "80",
"max": "110",
"colors": [
"#00b500",
"#e6e600",
"#ff0000"
],
"seg1": "",
"seg2": "",
"diff": false,
"className": "",
"x": 870,
"y": 220,
"wires": []
},
{
"id": "7",
"type": "ui_group",
"name": "RasPI-3B",
"tab": "6",
"order": 1,
"disp": true,
"width": 16,
"collapse": false,
"className": ""
},
{
"id": "6",
"type": "ui_tab",
"name": "Home",
"icon": "dashboard",
"order": 1
}
]
-----
And here is the Arduino Sketch for the ESP32C3:
// ESP32-C3 Dev Module + onboard OLED
// thermometer w/ DS18B20 data pin connected to GPIO 4
//
// OLED: Fahrenheit only (1 decimal place, no units)
// Serial Monitor: Celsius + Fahrenheit
// Web page: latest calibrated Fahrenheit reading with timestamp
//
// https://www.whiskeytangohotel.com/
// SEPT 2025
#include <Arduino.h>
#include <U8g2lib.h>
#include <Wire.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <WiFi.h>
#include "time.h"
#include <WebServer.h>
// WiFi credentials
const char* ssid = "ur-ssid";
const char* password = "ur-password";
// OLED setup
U8G2_SSD1306_72X40_ER_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);
// Global counter
int readingCount = 0;
// DS18B20 setup
#define ONE_WIRE_BUS 4
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);
// Timezone
const char* ntpServer = "pool.ntp.org";
// Latest reading
float latestTempF = 0;
time_t latestTime = 0;
// Web server
WebServer server(80);
void handleRoot() {
char timeBuf[30];
struct tm *tm_info = localtime(&latestTime);
strftime(timeBuf, sizeof(timeBuf), "%Y-%m-%d %H:%M:%S", tm_info);
String html = "<html><head><title>ESP32-C3 Temp</title></head><body><pre>";
html += timeBuf;
html += " - Temperature is: ";
html += String(latestTempF, 1);
html += "</pre></body></html>";
server.send(200, "text/html", html);
}
void setup() {
// I2C pins for ESP32-C3 OLED dev board
Wire.begin(5, 6);
Wire.setClock(100000);
delay(200);
u8g2.begin();
Serial.begin(115200);
delay(200);
sensors.begin();
// Startup screen
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_6x10_tr);
u8g2.drawStr(0, 15, "LAN IP is:"); // Could change this to a "Title Screen"
u8g2.sendBuffer();
delay(2000);
// Connect to WiFi
WiFi.begin(ssid, password);
u8g2.clearBuffer();
u8g2.drawStr(0, 15, "LAN IP is:");
u8g2.sendBuffer();
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
// NTP
configTzTime("CST6CDT,M3.2.0/2,M11.1.0/2", ntpServer);
// Start server
server.on("/", handleRoot);
server.begin();
Serial.print("HTTP server started at: ");
Serial.println(WiFi.localIP());
//Display last digits of IP address on OLED (.xxx) for easy ID
String lastOctet = "." + WiFi.localIP().toString().substring(WiFi.localIP().toString().lastIndexOf('.')+1);
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_fur20_tf);
int16_t x = (72 - u8g2.getStrWidth(lastOctet.c_str())) / 2; // center horizontally
u8g2.drawStr(x, 30, lastOctet.c_str());
u8g2.sendBuffer();
delay(5000);
}
void loop() {
sensors.requestTemperatures();
float tempC = sensors.getTempCByIndex(0);
float tempF = tempC * 9.0 / 5.0 + 32.0;
float calibrationOffsetF = 0.0;
tempF += calibrationOffsetF;
// Save latest reading
time(&latestTime);
latestTempF = tempF;
// Serial output
//readingCount++; // If reading count is desired
//Serial.print("#");
//Serial.print(readingCount);
char timeBuf[30];
struct tm *tm_info = localtime(&latestTime);
strftime(timeBuf, sizeof(timeBuf), "%Y-%m-%d %H:%M:%S", tm_info);
Serial.print(timeBuf);
Serial.print(" > Temperature is: ");
Serial.print(tempC);
Serial.print("°C | ");
Serial.print(tempF);
Serial.println("°F");
// OLED output
char buf[10];
snprintf(buf, sizeof(buf), "%.1f", tempF);
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_fur20_tf);
u8g2.drawStr(0, 30, buf);
u8g2.sendBuffer();
server.handleClient(); // handle web requests
delay(5000); // delay until next reading
}
-----
Wednesday, September 17, 2025
QRCode Clock with ESP32C3 Dev Module
Nothing is more frustrating than needing the time and not having a watch, but watches can be expensive and boring so we programed this ~$2.00 USD ESP32C3 Dev Module with on-board OLED to provide the time in a low cost and interesting way.
Oh, to make it work you also need a smart phone....
-----
After uploading the source code below you will get a QRCode on the OLED that conveniently provides a second by second account of the time which you can read from your smart phone camera. Here's the demo:
// QRCode Clock
// QRCode on OLED is updated each second
// time the time of day as HH:MM:SS in 24 hr format.
//
// Board (~$4) is ESP32C3 Dev Module with onboard OLED.
//
// Details at: https://www.whiskeytangohotel.com/
// SEPT 2025
#include <Arduino.h>
#include <U8g2lib.h>
#include <Wire.h>
#include <WiFi.h>
#include "time.h"
#include "QRCodeGenerator.h"
// WiFi credentials
const char* ssid = "YOURSSID";
const char* password = "YOURWIFIPASSWORD";
const char* ntpServer = "pool.ntp.org";
// A few Google searches led me to this:
U8G2_SSD1306_72X40_ER_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);
void setup() {
Wire.begin(5, 6); // I2C
Wire.setClock(100000); // slow for stability
delay(200); // power-up delay
u8g2.begin();
// Connecting WiFi status screen
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_6x10_tr);
u8g2.drawStr(0, 15, "Connecting...");
u8g2.sendBuffer();
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
configTzTime("CST6CDT,M3.2.0/2,M11.1.0/2", ntpServer); //Central Time
}
void loop() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
delay(2000);
return;
}
// Format time string (HH:MM:SS) 24 hour time
char timeStr[16];
strftime(timeStr, sizeof(timeStr), "%H:%M:%S", &timeinfo);
// Generate QR code
QRCode qrcode;
const uint8_t qrVersion = 3; // 29x29 QRCode
uint8_t qrcodeData[qrcode_getBufferSize(qrVersion)];
qrcode_initText(&qrcode, qrcodeData, qrVersion, 0, timeStr);
// Scale/Center QRCode to fit 64x32 OLED but remember QRCodes are square
int scale = 1; // keep modules square
int width = qrcode.size * scale;
int height = qrcode.size * scale;
int xOffset = (64 - width) / 2;
int yOffset = (32 - height) / 2;
// Draw QRCode to OLED
u8g2.clearBuffer();
for (uint8_t y = 0; y < qrcode.size; y++) {
for (uint8_t x = 0; x < qrcode.size; x++) {
if (qrcode_getModule(&qrcode, x, y)) {
u8g2.drawBox(xOffset + x * scale, yOffset + y * scale, scale, scale);
}
}
}
u8g2.sendBuffer();
delay(1000); // refresh once per second
}
-----
Friday, September 12, 2025
Graphing the Average Speed (WPM) of a Morse Code CQ
-----
For ham radio operators that use Morse Code (CW) the Reverse Beacon Network is an amazing tool that does many things. However, one thing we wanted to know was what is the average WPM of the CQ calls the RBN reports.
One comment about ham radio before we continue. It's really a hobby with a few thousand hobbies embedded into it. Lately around here it's been about writing SW. Getting a license is easy and the hobby is great fun. With that advertisement out of the way....
-----
Our method to determine this average Morse Code (CW) WPM was to use a Python script that:
- Logs into via telnet to RBN at the top of each hour.
- Capture the data stream of every CW CQ the RBN sees for 60 seconds then logout.
- Parse the captured data to determine the Max WPM and Min WPM, plus calculate the average WPM.
- Create a rolling 24 hour graph to chart these results in 1) a terminal window, 2) a webpage viewable on your LAN (ex: 192.168.1.73), and/or 3) a public facing webpage via io.adafruit.com which for our project can be viewed here.
-----
Example screenshots of the three outputs listed above We are curious to see how these speeds increase during a contest weekend.
# by capturing the RBN stream for 60 seconds
# and graphing the ave WPM on a rollong 24 hour graph
# to the screen terminal on the LAN and publically to
# https://io.adafruit.com/ironjungle/dashboards/rbn-stats-by-whiskeytangohotel-dot-com?kiosk=true
#
# https://www.whiskeytangohotel.com/2025/09/graphing-average-speed-wpm-of-morse.html
# SEPT 2025
#
import telnetlib
import time
import sys
import re
from collections import deque
from datetime import datetime, timedelta
import threading
from http.server import SimpleHTTPRequestHandler, HTTPServer
import socket
import select
import os
import requests # <-- Added for Adafruit IO uploads
# For non-blocking key detection on Windows
if sys.platform.startswith('win'):
import msvcrt
else:
import termios, tty
# RBN Connection details
HOST = "telnet.reversebeacon.net"
PORT = 7000
CALLSIGN = "CALLSIGN"
# Adafruit IO credentials
ADAFRUIT_IO_USERNAME = "ADAFRUIT_IO_USERNAME"
ADAFRUIT_IO_KEY = "ADAFRUIT_IO_KEY"
# Maximum width of the terminal bar graph in characters
MAX_BAR_WIDTH = 100
# Maximum expected WPM value for scaling
MAX_WPM = 50 # updated as requested
# Number of hourly cycles to display (24 hours)
ROLLING_CYCLES = 24
# ANSI color codes (used only for terminal output)
COLOR_YELLOW = "\033[93m"
COLOR_GREEN = "\033[92m"
COLOR_RESET = "\033[0m"
def send_to_adafruit(feed, value):
"""Send a numeric value to a given Adafruit IO feed."""
try:
url = "https://io.adafruit.com/api/v2/{}/feeds/{}/data".format(
ADAFRUIT_IO_USERNAME, feed)
headers = {
"X-AIO-Key": ADAFRUIT_IO_KEY,
"Content-Type": "application/json"
}
data = {"value": value}
r = requests.post(url, json=data, headers=headers, timeout=10)
if r.status_code != 200:
print("⚠️ Adafruit IO error for {}: {}".format(feed, r.text))
except Exception as e:
print("⚠️ Could not send to Adafruit IO: {}".format(str(e)))
# Deques to keep rolling history
avg_history = deque(maxlen=ROLLING_CYCLES)
time_history = deque(maxlen=ROLLING_CYCLES)
cq_history = deque(maxlen=ROLLING_CYCLES)
min_wpm_history = deque(maxlen=ROLLING_CYCLES)
max_wpm_history = deque(maxlen=ROLLING_CYCLES)
def render_bar(value, color):
"""Render a colored bar for terminal output."""
bar_length = int(value * MAX_BAR_WIDTH / MAX_WPM)
bar_str = "█" * max(bar_length, 1)
val_str = str(int(value))
mid_pos = len(bar_str) // 2
bar_str = bar_str[:mid_pos] + val_str + bar_str[mid_pos + len(val_str):]
return color + bar_str + COLOR_RESET
def render_bar_plain(value):
"""Render a plain bar for web output (to be colored in HTML)."""
bar_length = int(value * MAX_BAR_WIDTH / MAX_WPM)
bar_str = "█" * max(bar_length, 1)
val_str = str(int(value))
mid_pos = len(bar_str) // 2
bar_str = bar_str[:mid_pos] + val_str + bar_str[mid_pos + len(val_str):]
return bar_str
def print_and_save_graph(cq_count, min_wpm, avg_wpm, max_wpm, timestamp, next_update_time):
"""Print summary and rolling graph to terminal and save plain HTML for web."""
# Update rolling history
avg_history.append(avg_wpm)
time_history.append(timestamp)
cq_history.append(cq_count)
min_wpm_history.append(min_wpm)
max_wpm_history.append(max_wpm)
# Terminal output
output_lines = []
output_lines.append("\nSummary of last 60s collected at:")
output_lines.append(" Central Time: %s:00" % timestamp)
output_lines.append(" CQ lines: %d" % cq_count)
output_lines.append(" Min WPM: %3d" % min_wpm)
output_lines.append(" Avg WPM: %3d" % int(avg_wpm))
output_lines.append(" Max WPM: %3d" % max_wpm)
output_lines.append("\nRolling Average WPM")
header_avg = "WhiskeyTangoHotel.Com".center(MAX_BAR_WIDTH)
output_lines.append("%-12s %s" % ("Central Time", header_avg))
for t, avg in zip(time_history, avg_history):
output_lines.append("%-12s %s" % (t + ":00", render_bar(avg, COLOR_YELLOW)))
terminal_output = "\n".join(output_lines)
print(terminal_output)
# HTML output for web page
html_lines = []
html_lines.append("<html><head><title>WPM Average Graph</title>")
html_lines.append("<style>body{background:white;color:black;font-family:monospace;} .bar{color:blue;}</style></head><body>")
html_lines.append("<pre>")
html_lines.append("Summary of last 60s collected at:")
html_lines.append(" Central Time: %s:00" % timestamp)
html_lines.append(" CQ lines: %d" % cq_count)
html_lines.append(" Min WPM: %3d" % min_wpm)
html_lines.append(" Avg WPM: %3d" % int(avg_wpm))
html_lines.append(" Max WPM: %3d" % max_wpm)
html_lines.append("\nRolling Average WPM")
html_lines.append("%-12s %s" % ("Central Time", header_avg))
for t, avg in zip(time_history, avg_history):
bar = render_bar_plain(avg)
html_lines.append("%-12s <span class='bar'>%s</span>" % (t + ":00", bar))
html_lines.append("\nNext summary graph update scheduled for: %s" % next_update_time.strftime("%H:%M:%S"))
html_lines.append("</pre></body></html>")
with open("graph.txt", "w", encoding="utf-8") as f:
f.write("\n".join(html_lines))
def collect_rbn_data(duration_seconds=60):
"""Connect to RBN and collect data for a specified duration."""
cq_count = 0
wpm_values = []
try:
tn = telnetlib.Telnet(HOST, PORT, timeout=10)
tn.write(CALLSIGN.encode("utf-8") + b"\n")
print("Connected and logged in as %s" % CALLSIGN)
start_time = time.time()
while time.time() - start_time < duration_seconds:
raw = tn.read_very_eager()
if raw:
lines = raw.decode("utf-8", errors="replace").splitlines()
for line in lines:
sys.stdout.write(line + "\n")
sys.stdout.flush()
if "CQ" in line and "WPM" in line:
match = re.search(r"(\d+)\s*WPM", line)
if match:
wpm = int(match.group(1))
cq_count += 1
wpm_values.append(wpm)
time.sleep(0.1)
tn.close()
print("RBN stream data collected for %d seconds." % duration_seconds)
except Exception as e:
print("Error collecting RBN data: %s" % str(e))
return cq_count, wpm_values
def spacebar_pressed():
"""Return True if spacebar is pressed (non-blocking)."""
if sys.platform.startswith('win'):
return msvcrt.kbhit() and msvcrt.getch() == b' '
else:
dr, dw, de = select.select([sys.stdin], [], [], 0)
if dr:
c = sys.stdin.read(1)
return c == ' '
return False
# Web server section
class Handler(SimpleHTTPRequestHandler):
def do_GET(self):
if self.path == '/' or self.path == '/index.html':
self.send_response(200)
self.send_header("Content-type", "text/html; charset=utf-8")
self.end_headers()
try:
with open("graph.txt", "r", encoding="utf-8") as f:
self.wfile.write(f.read().encode("utf-8"))
except Exception:
self.wfile.write(b"No graph data yet.")
elif self.path == '/favicon.ico':
self.send_response(204)
self.end_headers()
else:
self.send_error(404)
def log_message(self, format, *args):
"""Override to suppress logging to terminal."""
pass
def get_local_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
except Exception:
ip = "127.0.0.1"
finally:
s.close()
return ip
def start_web_server():
HTTP_PORT = 8080
server = HTTPServer(('', HTTP_PORT), Handler)
print("\nWeb server running on http://%s:%d/" % (get_local_ip(), HTTP_PORT))
server.serve_forever()
# Start web server in background
web_thread = threading.Thread(target=start_web_server)
web_thread.setDaemon(True)
web_thread.start()
def main():
if not sys.platform.startswith('win'):
old_settings = termios.tcgetattr(sys.stdin)
tty.setcbreak(sys.stdin.fileno())
try:
while True:
now = datetime.now()
# --- Schedule ONLY :30 after each hour ---
if now.minute < 30:
next_update_time = now.replace(minute=30, second=0, microsecond=0)
else:
next_update_time = (now + timedelta(hours=1)).replace(minute=30, second=0, microsecond=0)
print("Next summary graph update scheduled for %s" % next_update_time.strftime("%H:%M:%S"))
print("Press SPACE BAR to update now.")
while True:
if spacebar_pressed():
print("\nSpacebar pressed — updating now!")
break
if datetime.now() >= next_update_time:
print("\n:30 minute mark reached — updating now!")
break
time.sleep(0.5)
# --- Use start of collection time (rounded to nearest minute) ---
start_time = datetime.now()
rounded_start = (start_time + timedelta(seconds=30)).replace(second=0, microsecond=0)
timestamp = rounded_start.strftime("%H:%M") # will append ":00" in output
cq_count, wpm_values = collect_rbn_data(duration_seconds=60)
if cq_count > 0:
min_wpm = min(wpm_values)
avg_wpm = sum(wpm_values)/float(len(wpm_values))
max_wpm = max(wpm_values)
else:
min_wpm = avg_wpm = max_wpm = 0
# Recompute next :30 AFTER collection so the webpage shows the true next scheduled :30
now_after = datetime.now()
if now_after.minute < 30:
next_update_time = now_after.replace(minute=30, second=0, microsecond=0)
else:
next_update_time = (now_after + timedelta(hours=1)).replace(minute=30, second=0, microsecond=0)
print_and_save_graph(cq_count, min_wpm, avg_wpm, max_wpm, timestamp, next_update_time)
# --- Send results to Adafruit IO ---
send_to_adafruit("average-wpm", avg_wpm)
send_to_adafruit("min-wpm", min_wpm)
send_to_adafruit("max-wpm", max_wpm)
send_to_adafruit("cq-count", cq_count)
print("\n--- Waiting for next update ---\n")
except KeyboardInterrupt:
print("\nProgram stopped by user.")
finally:
if not sys.platform.startswith('win'):
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
if __name__ == "__main__":
main()
Wednesday, June 25, 2025
TCS34725 Color Sensing Cube with Arduino
-----
Whatever color the TCS34725 sensor "sees" is reproduced on the orb on top of the cube. The switch in front is hardwired to the LED light on the front of the sensor because we found the LED annoying and most of the time unnecessary.
-----
Bill of Materials....
The orb is a plastic cover from a non functioning LED light bulb: An old Arduino Nano: A switch and a 3D Printed Box: -----Wire per the comments in the Arduino sketch below and you should get this:
-----
// Read Color Sensor. Mimic color on RGB LED sees
// https://www.whiskeytangohotel.com/
// JUNE 2026
// Arduino Nano but, must selected under Tools → Processor:
// ATmega328P (Old Bootloader) or suffer the avrdude error.
#include <Wire.h>
#include "Adafruit_TCS34725.h"
// TCS34725 SDA pin should be connected to A4
// TCS34725 SCL pin should be connected to A5
// TCS34725 GND to GND
// TCS34725 3.3V to 5V (Vin is No Connect)
// TCS34725 LED goes to hardwired switch
// Define pins for RGB LED
const int RED_PIN = 10;
const int GREEN_PIN = 9;
const int BLUE_PIN = 11;
// Define digital pin for TCS34725 LED control
const int SENSOR_LED_PIN = 6; // Not used, this LED is controlled with a hardwired switch
// Initialize the sensor
Adafruit_TCS34725 tcs = Adafruit_TCS34725(
TCS34725_INTEGRATIONTIME_50MS,
TCS34725_GAIN_4X
);
void setup() {
Serial.begin(9600); // We use the serial monitor for debug
// RGB LED pins
pinMode(RED_PIN, OUTPUT);
pinMode(GREEN_PIN, OUTPUT);
pinMode(BLUE_PIN, OUTPUT);
// Sensor LED control pin
pinMode(SENSOR_LED_PIN, OUTPUT);
digitalWrite(SENSOR_LED_PIN, LOW); // turn off sensor LED initially
if (tcs.begin()) {
Serial.println("TCS34725 sensor found");
// Self-test only if sensor found: cycle RGB LED through R, G, B
for (int i = 7; i > 0; i--) {
// Red
Serial.println("RED Self Test");
analogWrite(RED_PIN, 255);
analogWrite(GREEN_PIN, 0);
analogWrite(BLUE_PIN, 0);
delay(50 * i);
// Green
Serial.println("GREEN Self Test");
analogWrite(RED_PIN, 0);
analogWrite(GREEN_PIN, 255);
analogWrite(BLUE_PIN, 0);
delay(50 * i);
// Blue
Serial.println("BLUE Self Test");
analogWrite(RED_PIN, 0);
analogWrite(GREEN_PIN, 0);
analogWrite(BLUE_PIN, 255);
delay(50 * i);
}
} else {
Serial.println("No TCS34725 sensor found ... check wiring?");
// RED LED to show error
analogWrite(RED_PIN, 155);
analogWrite(GREEN_PIN, 0);
analogWrite(BLUE_PIN, 0);
while (1);
}
}
void loop() {
uint16_t r, g, b, c;
tcs.getRawData(&r, &g, &b, &c);
if (c < 5) {
// In near total darkness: cycle through rainbow
showRainbowCycle();
} else {
// Normal color mimic
uint16_t maxRaw = max(max(r, g), b);
if (maxRaw == 0) maxRaw = 1;
int redVal = (uint32_t)r * 255 / maxRaw;
int greenVal = (uint32_t)g * 255 / maxRaw;
int blueVal = (uint32_t)b * 255 / maxRaw;
redVal = constrain(redVal, 0, 255);
greenVal = constrain(greenVal, 0, 255);
blueVal = constrain(blueVal, 0, 255);
analogWrite(RED_PIN, gammaCorrect(redVal));
analogWrite(GREEN_PIN, gammaCorrect(greenVal));
analogWrite(BLUE_PIN, gammaCorrect(blueVal));
}
delay(50); // smooth update
}
int gammaCorrect(int val) { // makes it look "better"
float gamma = 2.2;
return pow(val / 255.0, gamma) * 255.0;
}
void showRainbowCycle() { // If full dark gentle cycle thru colors
static float hue = 0;
hue += 0.5; // Change speed here
if (hue > 360) hue = 0;
float r, g, b;
float s = 1.0;
float v = 1.0;
float h = hue;
int i = int(h / 60.0) % 6;
float f = h / 60.0 - i;
float p = v * (1 - s);
float q = v * (1 - f * s);
float t = v * (1 - (1 - f) * s);
switch(i) {
case 0: r = v, g = t, b = p; break;
case 1: r = q, g = v, b = p; break;
case 2: r = p, g = v, b = t; break;
case 3: r = p, g = q, b = v; break;
case 4: r = t, g = p, b = v; break;
case 5: r = v, g = p, b = q; break;
}
analogWrite(RED_PIN, gammaCorrect(int(r * 255)));
analogWrite(GREEN_PIN, gammaCorrect(int(g * 255)));
analogWrite(BLUE_PIN, gammaCorrect(int(b * 255)));
}
-----

















