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

-----