Friday, May 1, 2026

Logging Ham Radio Repeater Usage with a Baofeng



-----
The Austin N5OAK Ham Radio Club Repeater seems to be pretty active.  That shouldn't surprise us much because the club has a lot of activities and a lot of enthusiastic members.  Still, we wondered just how active and opened the spare parts drawer to see if it held a solution.
-----
Turns out the spare parts drawer did; take a look at the wiring diagram at the top of the page.   We simply used a C3 Dev Board with an input pin wired to to the output speaker of a Baofeng HT tuned to the repeater frequency.  We monitor the C3 input pin and if it sees a signal the repeater in transmitting.  We push that data to AdaFruit IO at the top of each hour to graph the history.


The code also creates a small web server that lets us see the status of the C3 data collection in real time:


-----
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:
-----
//
// ESP32-C3 N5OAK=R Repeater TX TIME LOGGER + HOURLY ADAFRUIT IO
// Speaker output of Baofeng HT feed into uC IO pin via a diode to drop voltage.
//
// IDE Settings: ESP32-C3 Dev Module + USB CDC On Boot Enabled (needed for Serial Monitor)
// https://www.whiskeytangohotel.com/
// APRIL 2026

#include <Arduino.h>
#include <U8g2lib.h>
#include <Wire.h>
#include <WiFi.h>
#include "time.h"
#include <WebServer.h>

// ===================== ADAFRUIT IO =====================
#include "Adafruit_MQTT.h"
#include "Adafruit_MQTT_Client.h"

#define AIO_SERVER      "io.adafruit.com"
#define AIO_SERVERPORT  1883

#define AIO_USERNAME "UR_ADAFRUIT_USERNAM"
#define AIO_KEY      "UR_ADAFRUIT_PRIVATE_KEY"

WiFiClient client;

Adafruit_MQTT_Client mqtt(
  &client,
  AIO_SERVER,
  AIO_SERVERPORT,
  AIO_USERNAME,
  AIO_KEY
);

Adafruit_MQTT_Publish N5OAK_R(
  &mqtt,
  AIO_USERNAME "/feeds/N5OAK-R"
);

// ===================== WIFI  SETTINGS =====================
const char* ssid="UR_SSID";
const char* password="UR_WIFIPASSWORD";
const char* ntpServer="pool.ntp.org";

#define INPUT_PIN 3  // From BF HT Speaker via diode for voltage drop
#define LED_PIN   8  // On board LED ON when TX is detected
// =========================================================

// C3 On Board OLED
U8G2_SSD1306_72X40_ER_F_HW_I2C
u8g2(U8G2_R0,U8X8_PIN_NONE);

// WEB Server Setup to view stats from a browser
WebServer server(80);

// ==== DEFINE SOME VARS =====================
uint64_t highTimeMs      = 0;
uint64_t totalHighTimeMs = 0;

uint64_t lastIntegratorMs = 0;

unsigned long lastBlink  = 0;
unsigned long lastSerial = 0;

bool colonVisible = true;
bool pinState     = LOW;

String lastSendMsg="Waiting...";

int lastHour=-1;

// ===================== ADAFRUIT MQTT CONNECT =====================
void MQTT_connect(){

 if(mqtt.connected()) return;

 Serial.print("Connecting to Adafruit MQTT...");

 int8_t ret;

 while((ret=mqtt.connect())!=0){
   Serial.println(" retry...");
   mqtt.disconnect();
   delay(2000);
 }

 Serial.println(" connected");
}

// =========== PUBLISH TO ADAFRUIT =============
void publishToAdafruit(
 float minutesHigh,
 const char* source
){

 Serial.print(source);
 Serial.print(" -> N5OAK-R = ");
 Serial.println(minutesHigh);

 MQTT_connect();

 if(!N5OAK_R.publish(minutesHigh)){
   Serial.println("MQTT FAIL");

   lastSendMsg=
      String(source)+" FAILED";
 }
 else{

   Serial.println("MQTT OK");

   lastSendMsg=
      String(source)+
      ": "+
      String(minutesHigh,2)+
      " MINS SENT";
 }
}

// ====== HOW LONG HAS THE PROGRAM BEEN RUNNING =====================
String formatUptime(){
  uint32_t seconds = millis() / 1000;

  uint32_t days  = seconds / 86400;
  uint32_t hours = (seconds % 86400) / 3600;

  return String(days) + "d " + String(hours) + "h";
}

// ===================== SETUP =====================
void setup(){

 Serial.begin(115200);
 delay(1000);

 Serial.println("\nBOOT OK");

 Wire.begin(5,6);
 u8g2.begin();

 pinMode(INPUT_PIN,INPUT);
 pinMode(LED_PIN,OUTPUT);

 digitalWrite(LED_PIN,HIGH);

 lastIntegratorMs=millis();

 // OLED startup
 u8g2.clearBuffer();
 u8g2.setFont(u8g2_font_fur20_tf);
 u8g2.drawStr(8,30,".??");
 u8g2.sendBuffer();

 // WiFi
 WiFi.begin(ssid,password);

 Serial.print("WiFi connecting");

 while(WiFi.status()!=WL_CONNECTED){
   delay(500);
   Serial.print(".");
 }

 Serial.println("\nWiFi CONNECTED");
 Serial.println(WiFi.localIP());

 // Time
 configTzTime(
 "CST6CDT,M3.2.0/2,M11.1.0/2",
 ntpServer
 );

 setenv(
 "TZ",
 "CST6CDT,M3.2.0/2,M11.1.0/2",
 1
 );

 tzset();

// ======= PUBLISH TO WEB SERVER =================
server.on("/", [](){

  time_t now;
  time(&now);

  struct tm tm_info;
  localtime_r(&now,&tm_info);

  char tbuf[20];

  strftime(
    tbuf,
    sizeof(tbuf),
    "%H:%M:%S",
    &tm_info
  );

  // Get IP address
  String ip = WiFi.localIP().toString();

  String html =
  "<html><head>"
  "<meta http-equiv='refresh' content='3'>"
  "<title>N5OAK-R Monitor</title>"
  "</head><body><pre>";

  // ===== TITLE =====
  html += "<b style='font-size:18px;'>N5OAK-R TX DATA AT: ";
  html += ip;
  html += "</b>\n\n";

  // ===== DATA =====
  html += "LOCAL TIME: ";
  html += tbuf;
  html += "\n";

  html += "TX SENSE PIN: ";
  html += (pinState ? "HIGH" : "LOW");
  html += "\n";

  html += "CURRENT MINS THIS HOUR: ";
  html += String(highTimeMs/60000.0,2);
  html += "\n";

  html += "UPTIME: ";
  html += formatUptime();
  html += "\n";

  html += "DATA LAST SENT: ";
  html += lastSendMsg;
  html += "\n\n";

  // ===== LINK =====
  html += "<a href='https://io.adafruit.com/ironjungle/dashboards/n5oak-r-usage-by-whiskeytangohotel-dot-com?kiosk=true' target='_blank'>";
  html += "View Adafruit Dashboard";
  html += "</a>";

  html += "</pre></body></html>";

  server.send(200,"text/html",html);
});
 server.begin();

 // IP display on the C3 at boot
 String ip=WiFi.localIP().toString();
 String lastOctet="."+ip.substring(ip.lastIndexOf('.')+1);

 u8g2.clearBuffer();
 u8g2.setFont(u8g2_font_fur20_tf);

 int16_t x=(72-u8g2.getStrWidth(lastOctet.c_str()))/2;
 u8g2.drawStr(x,30,lastOctet.c_str());
 u8g2.sendBuffer();

 delay(3000);

 Serial.println("SYSTEM READY");
}

// ==========FOREVER  LOOP =====================
void loop(){

 uint64_t nowMs=millis();

 // ACCURATE INTEGRATOR
 uint64_t elapsed=nowMs-lastIntegratorMs;
 lastIntegratorMs=nowMs;

 pinState=digitalRead(INPUT_PIN);

 if(pinState){
   highTimeMs += elapsed;
   totalHighTimeMs += elapsed;
 }

 digitalWrite(LED_PIN,!pinState);

 // TIME
 time_t now;
 time(&now);

 struct tm tm_info;
 localtime_r(&now,&tm_info);

 int currentHour=tm_info.tm_hour;

 if(lastHour==-1) lastHour=currentHour;

 // TOP OF HOUR SO LONG TO ADAFRUIT
 if(currentHour!=lastHour){

   float minutesHigh=highTimeMs/60000.0;

   if(minutesHigh>0.0 && minutesHigh<1.0){
      minutesHigh=1.0;
   }

   publishToAdafruit(minutesHigh,"AUTO TOP OF HOUR");

   highTimeMs=0;
   lastHour=currentHour;
 }

 // HEARTBEAT OLED FLASH THE ":" TO SHOW IT'S RUNNING
 if(nowMs-lastBlink>=1000){
   lastBlink=nowMs;
   colonVisible=!colonVisible;
 }

 int displayMinutes=(highTimeMs/60000)%60;

 char buf[6];

 snprintf(
   buf,
   sizeof(buf),
   colonVisible ? ":%02d" : " %02d",
   displayMinutes
 );

 u8g2.clearBuffer();
 u8g2.setFont(u8g2_font_fur20_tf);

 int16_t x=(72-u8g2.getStrWidth(buf))/2;
 u8g2.drawStr(x,30,buf);
 u8g2.sendBuffer();


 // SERIAL MONITOR OUTPUT
 if(nowMs-lastSerial>=1000){

   lastSerial=nowMs;

   Serial.print("SENSE=");
   Serial.print(pinState?"H":"L");

   Serial.print(" | HR MIN=");
   Serial.print(highTimeMs/60000.0,2);

   Serial.print(" | UPTIME=");
   Serial.print(formatUptime());

   Serial.println();
 }

 server.handleClient();
 delay(20);
}
-----


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.

We also designed in a potentiometer to adjust DIT WPM speed, but it can be eliminated if you just want to hardcode the WPM speed into the code.
-----
/*
 * Bug Keyer Simulator with Smoothed WPM Control and Live Pot Adjustment
 
 * Arduino IDE Board Settings: ESP32 Dev Module, 240MHz (WiFi/BT), 921600

 * APRIL2026
 * Project details at: whiskeytangohotel.com 
*/

// Define C3 pins
const int ditPin = 2;     //  From key
const int dahPin = 3;     //  From key
const int keyOutPin = 8;  // To rig
const int potPin = A0;    // Bug Speed adjust

// Define some variables.  Feel free to adjust minWPM and maxWPM to suit needs
int wpm;
int targetWpm;
int ditLength;
int minWPM = 10;
int maxWPM = 40;
float smoothedWpm = 20;

void setup() {
  pinMode(ditPin, INPUT_PULLUP);
  pinMode(dahPin, INPUT_PULLUP);
  pinMode(keyOutPin, OUTPUT);
  digitalWrite(keyOutPin, LOW);
  ditLength = 1200 / wpm;
}

void loop() {
  // --- Read pot and update WPM (live knob response) ---
  updateWPM();

  bool ditPressed = digitalRead(ditPin) == LOW;
  bool dahPressed = digitalRead(dahPin) == LOW;

  // --- DIT side (automatic repeating like bug) ---
  if (ditPressed) {
    sendDit();
    delay(ditLength);  // spacing between dits
  }

  // --- DAH side (manual like real bug) ---
  if (dahPressed) {
    digitalWrite(keyOutPin, HIGH);
  } else if (!ditPressed) {
    digitalWrite(keyOutPin, LOW);
  }
}

// Smooth + map potentiometer to WPM
void updateWPM() {
  int potValue = analogRead(potPin);  // 0–1023
  targetWpm = map(potValue, 0, 1023, minWPM, maxWPM);

  // Exponential smoothing
  smoothedWpm = (0.8 * smoothedWpm) + (0.2 * targetWpm);
  wpm = (int)smoothedWpm;
  ditLength = 1200 / wpm;  // some standard I read on the internet
}

void sendDit() {
  digitalWrite(keyOutPin, HIGH);
  delay(ditLength);  
  digitalWrite(keyOutPin, LOW);
}
-----



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: 

 
-----
Now you're ready to use the Arduino IDE to upload the software sketch at the end of this post.  Basically the software polls the DS18B20 for a temperature reading every 60 seconds and posts it as a webpage. Our ESP32C3 is connected to our LAN at 192.168.1.67 so we see this in our web browser:
----
But wait, that's not all... We have Node Red running on a Raspberry PI and parse what this web page would look like every 60 seconds to graph the reading.  This isn't a Node Red tutorial, but the flow looks like this and we will post the flow below for you to import.
 

-----

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:

 
 
This timekeeping device is cheap and extremely accurate.  
-----

// 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.


 

-----
We find the data interesting.  If you do, then here is the source code:
 
# Calculate Average WPM at 30 mins into each hour
# 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....

     TCS34725

     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)));
}
-----