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.

-----

Our method to determine this average 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 for display on a rolling 24 hour graph.

-----

Here is what a rolling 24 hour period in September 2025 looked like.  Overall it looks like the average is in the low 20WPM.  You can also see that for this 60 second sample the Min WPM was 12 and the Max WPM was 26.  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 by capturing the RBN
# stream at the top of each hour for 60 seconds and
# graphing the ave WPM on a rolling 24 hour graph.
#
#  https://www.whiskeytangohotel.com/
#  SEPT 2025
#


import telnetlib
import time
import sys
import re
from collections import deque
from datetime import datetime, timedelta

# For non-blocking key detection on Windows
if sys.platform.startswith('win'):
    import msvcrt
else:
    import sys, select, termios, tty

# Connection details
HOST = "telnet.reversebeacon.net"
PORT = 7000
CALLSIGN = "YOURCALLSIGNHERE"

# Maximum width of the bar graph in characters
MAX_BAR_WIDTH = 50
# Maximum expected WPM value for scaling
MAX_WPM = 40
# Number of hourly cycles to display (24 hours)
ROLLING_CYCLES = 24

# ANSI color codes
COLOR_YELLOW = "\033[93m"
COLOR_RESET = "\033[0m"

# Deques to keep rolling history
avg_history = deque(maxlen=ROLLING_CYCLES)
time_history = deque(maxlen=ROLLING_CYCLES)

def render_bar(value, color):
    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 print_rolling_graph():
    print("\nRolling Average WPM (last %d hours):" % len(avg_history))
    header_avg = "Avg".center(MAX_BAR_WIDTH)
    print("%-8s %s" % ("Time", header_avg))
    for t, avg in zip(time_history, avg_history):
        print("%-8s %s" % (t, render_bar(avg, COLOR_YELLOW)))

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

def main():
    if not sys.platform.startswith('win'):
        # Unix terminal setup
        old_settings = termios.tcgetattr(sys.stdin)
        tty.setcbreak(sys.stdin.fileno())

    try:
        while True:
            now = datetime.now()
            next_hour = (now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1))
            print("Next summary graph update scheduled for %s" % next_hour.strftime("%H:%M:%S"))
            print("Press SPACE BAR to update now.")

            while True:
                # Check for spacebar press
                if spacebar_pressed():
                    print("\nSpacebar pressed — updating now!")
                    break

                # Check if top of hour reached
                if datetime.now() >= next_hour:
                    print("\nTop-of-hour reached — updating now!")
                    break

                time.sleep(0.5)

            # Collect RBN stream for 60 seconds
            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

            timestamp = datetime.now().strftime("%H:%M:%S")
            avg_history.append(avg_wpm)
            time_history.append(timestamp)

            print("\nSummary for last 60s:")
            print("  CQ lines: %d" % cq_count)
            print("  Min WPM: %3d" % min_wpm)
            print("  Avg WPM: %3d" % int(avg_wpm))
            print("  Max WPM: %3d" % max_wpm)

            print_rolling_graph()
            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()
-----