Sunday, June 8, 2025

FlexRadio gets Rotary Phone Dial Interface

  

-----

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 application.  As helpful as this has been, there was still the common complaint that "Dammit, real radios have knobs!".   So, to assist again with the transition to Flex Radio we launched a new project.

-----

One fantastic feature Flex provides is access to the rig API.  This has allowed for many amazing third party programs to be developed and also allows any user to create their on applications.  This is exactly what we did here.    The Python script below monitors a FT232RL FTDI USB to RS232 Converter to detect pulses from the rotary dial phone.  Those pulse are interpreted as numbers to build a frequency for the FlexRadio to tune to.   After the frequency is "dial in" the Python program sends the API command to the rig to change to that frequency.   The hardware and software are pretty straight forward.

-----

Hook it up like this:

----- 

Run the script and this is what you get this:

A convenient, satisfying, and familiar analog input for your modern radio.

-----

#
#  FlexRadio Dial Up Interface
#  www.WhiskeyTangloHotel.Com
#  June 2025#
#
# Use an old Rotary Dial phone to change frequency on your FlexRadio.
# Program runs under Window with Python3.
#
# Uses FT232RL FTDI Module [USB to TTL serial] with a 10K resistor.
#
#                       +5V from FTDI VCC pin
#                             |
#                             |
#                         [ 10kΩ ]
#                             |
#                             +-------------------+
#                             |                   |
#                         FTDI CTS pin       Rotary Dial Switch
#                                                (Normally closed,
#                             |                  opens during pulse)
#                             |
#                            GND <-------------+
#                         (FTDI GND pin)       |
#                                              |
#                         GND <----------------+
#                   (Rotary phone body ground or second switch terminal)

import serial
import time
import socket
import sys
import msvcrt  

# >>>>> Rotary Pulse Detection Settings.  Set to your COM port <<<<<
ser = serial.Serial('COM21', baudrate=9600, timeout=1)
prev_cts = ser.cts
pulse_count = 0
number = ""
last_pulse_time = None
last_activity_time = None

# >>>>> FlexRadio Control Settings <<<<<
DEBUG = "OFF"   # Turn on to show vebose comments that could be helpful for debugging
IP_ADDRESS = "192.168.1.2"  # <<< Replace with your FlexRadio IP address
PORT = 4992
MODE = "CW"
BANDWIDTH_HZ = 2800
SLICE_INDEX = 0  # 0 = Slice A

print("Ready:  Dial in the frequency...")

def send_cmd(sock, cmd, sequence):
    sock.send(cmd.encode())
    sequence += 1
    time.sleep(0.5)
    response = sock.recv(4096).decode()
    return response, sequence

try:
    while True:
        cts = ser.cts
        current_time = time.time()

        # Detect falling edge pulse from rotary dial
        if prev_cts and not cts:
            pulse_count += 1
            last_pulse_time = current_time
            last_activity_time = current_time

        # Build freq number if input detected between 0.25 and 3 seconds
        if pulse_count > 0 and last_pulse_time:
            if 0.250 <= (current_time - last_pulse_time) <= 3:
                digit = pulse_count if pulse_count != 10 else 0
                number += str(digit)
                print(f"Digit entered: {digit} | Frequency so far: {number}")
                pulse_count = 0
                last_pulse_time = None

        # If >3 sec since last pulse, treat number as complete
        if number and last_activity_time and (current_time - last_activity_time) > 3.0:
            print(f"\nTuning to Frequency: {number}")

            # Format number for FlexRadio
            FrequencyString = number
            try:
                FREQUENCY_HZ = int(float(FrequencyString) / 1000 * 1_000_000)
                if DEBUG == "ON":
                    print(f"Prepared settings:")
                    print(f"  Frequency: {FREQUENCY_HZ / 1_000_000:.3f} MHz")
                    print(f"  Mode: {MODE}")
                    print(f"  Filter Bandwidth: {BANDWIDTH_HZ} Hz")
                    print(f"  Slice: {SLICE_INDEX}")
            except ValueError:
                print("Invalid frequency input. Expected format like '14114.000'.")
                number = ""
                continue

            # Send to FlexRadio automatically, no keyboard interaction
            sock = None
            client_handle = None
            sequence = 1
            try:
                sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                sock.settimeout(5)
                sock.connect((IP_ADDRESS, PORT))
                if DEBUG == "ON":
                    print(f"\nConnected to FlexRadio at {IP_ADDRESS}:{PORT}")
                response = sock.recv(4096).decode()
                for line in response.splitlines():
                    if line.startswith("H"):
                        client_handle = line[1:]
                        if DEBUG == "ON":
                            print(f"Client handle assigned: {client_handle}")
                if not client_handle and DEBUG == "ON":
                    print("Warning: No client handle received.")
            except Exception as e:
                print(f"Connection failed: {e}")
                sock = None

            if sock and client_handle:
                resp, sequence = send_cmd(sock, f"C{sequence}|slice list\n", sequence)
                if DEBUG == "ON":
                    print(f"Slice list response:\n{resp}")

                freq_mhz = FREQUENCY_HZ / 1_000_000
                send_cmd(sock, f"C{sequence}|slice tune {SLICE_INDEX} {freq_mhz:.6f}\n", sequence)
                send_cmd(sock, f"C{sequence}|filter set {SLICE_INDEX} low=0 high={BANDWIDTH_HZ}\n", sequence)
                send_cmd(sock, f"C{sequence}|transmit set slice={SLICE_INDEX}\n", sequence)
                send_cmd(sock, f"C{sequence}|client disconnect {client_handle}\n", sequence)

                sock.close()
                print("Radio settings applied successfully!!!")
                print(" ")
                print(" ")
                print("Ready to detect rotary dial pulses for frequency...")

            # Reset for next number
            number = ""
            pulse_count = 0
            last_pulse_time = None
            last_activity_time = None

        prev_cts = cts
        time.sleep(0.01)

except KeyboardInterrupt:
    print("\nBye for now...")

finally:
    ser.close()

-----

Wednesday, May 21, 2025

Is your Reverse Beacon (RBN) Spotting Station Up?

  

-----

The Reverse Beacon Network (RBN) is a system of volunteer-operated CW (Morse Code) skimmers that listen to ham radio bands and automatically report any CW CQ signals they hear.    These reports include callsign, signal strength, location, speed (WPM), etc. and are then relayed to a central server.  So who cares?  Well, if you are a ham radio CW operator this allows you to see where your signals are reaching, identify other operators, monitor band conditions, and a whole lot more.   See the RBN site for details.

-----

If you are running or monitoring a RBN skimmer/reporting station knowing if the system is reporting information (spots) to the central service is information you want.   Most of these stations are "just running happily in the background" and the operator running the station may not know it is down.  So...

-----

We created a Python script to send an email if a skimmer you own or monitor is down.   The code below will send an email alert if no spots have been reported in 60 minutes for a particular spotter's callsign.  It allows control of the bands, WPM, distance, and other filtering options through the RBN URL that the program interrogates.  

-----

# Python3 script to monitor a RBN CW Spotting Station
# and report with email if that station is down.
# Programs assume FireFox browser installed somewhere  
# Program assumes Gmail acct for sending email.

# WhiskeyTangoHotel.Com
# MAY 2025

import time
from selenium import webdriver
# Assumes Firefox is installed somewhere
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.firefox.service import Service
import smtplib
from email.mime.text import MIMEText

# --- CONFIGURATION and SETUP ---
CHECK_INTERVAL_MINUTES = 60    # How often to check
# Below is the RBN URL used.   You can change bands, etc.  Make certain the age is less than the CHECK_INTERVAL_MINUTES variable
# The call sign in the WEBPAGE_URL and the SEARCH_TERM variable must be the same!!!
WEBPAGE_URL = "https://www.reversebeacon.net/main.php?rows=100&max_age=45,minutes&bands=160,80,60,40,30,20,17,15,12,10,6&modes=cw&spotter_call=K5TR&hide=distance_km,mode,time"
SEARCH_TERM = "K5TR"   # What RBN Spotting station call sign do you want to monitor for uptime?

# Your email settings
EMAIL_FROM = "yourgmailname@gmail.com"
EMAIL_TO = ["rx_address_1@domain.com" , "rx_address_2@domail.net"]  # etc, etc for more email addresses
EMAIL_SUBJECT = "RBN Spot Alert by WhiskeyTangoHotel.Com"
EMAIL_BODY_TEMPLATE = "ALERT: Only {count} occurrences found on the Reverse Beacon Network."

# Gmail credentials
GMAIL_USERNAME = "yourgmailname@gmail.com"
GMAIL_APP_PASSWORD = "abcd efgh ijkl mnop"  # NEVER EVER let your GMAIL_APP_PASSWORD into the wild

def check_count():
    options = Options()
    options.add_argument("--headless")
    service = Service()
    driver = webdriver.Firefox(service=service, options=options)
    driver.get(WEBPAGE_URL)
    time.sleep(5)  # Don't shorten this
    page_source = driver.page_source
    driver.quit()
    return page_source.count(SEARCH_TERM)

def send_email(count):  # Gmail sending stuff
    msg = MIMEText(EMAIL_BODY_TEMPLATE.format(count=count))
    msg["Subject"] = EMAIL_SUBJECT
    msg["From"] = EMAIL_FROM
    msg["To"] = ", ".join(EMAIL_TO)  # Displayed in the email header

    with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp:  
        smtp.login(GMAIL_USERNAME, GMAIL_APP_PASSWORD)
        smtp.sendmail(EMAIL_FROM, EMAIL_TO, msg.as_string())

def main_loop():
    while True:  # Loop until the Dallas Cowboys win a Super Bowl
        count = check_count()
        timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
        print(f"[{timestamp}] Found {count} occurrence(s) of K5TR.")
        
        if count <= 1:   # Set to <=1 unless testing.  If this condition is met then send email.
            send_email(count)
            print("Email sent.")

        for minute in range(CHECK_INTERVAL_MINUTES, 0, -1):  # Print status message.
            time_now = time.strftime('%Y-%m-%d %H:%M:%S')
            print(f"[{time_now}] Next {SEARCH_TERM} RBN Spotter check in {minute} minutes...")
            #print(f"  Next check in {minute} minutes...")
            time.sleep(60)
        print()  # Move to next line after countdown

if __name__ == "__main__":
    main_loop()


-----

Monday, April 28, 2025

VBand interface to Physical Morse Key

-----

What is VBand?  It's a virtual CW Band where you can have a virtual Morse Code QSO as well as practice your CW skills.  It doesn't require a ham radio rig.  It doesn't require a ham radio license.   It's not ham radio, but it is popular and a great place for some none RF fun.

-----

First things first; this problem has been solved over and over.   Our frustration was finding a one stop site that provided clear instructions on how to interface a Morse Key to the VBand website.  Also, there are several VBand interfaces for sell that reports suggest "just don't work".   If you decide DIY is not for you then be safe and make your purchase directly from the VBand store.

----

Bill of Materials and Hookup (~$10USD):

-----
Of course, before anything works you will need to use the Arduino IDE to upload the software below to the Arduino Pro Micro.   
 
//This program acts a USB interface for a CW paddle or straight key for VBand Virtual CW Band.
//Program written by Tony Milluzzi, KD8RTT using Keyboard library example as starting point.

// WhiskeyTangoHotel.com notes APRIL 2025:
//       IDE Board Selection: Ardunio Micro (but my board is labeled Pro Micro)
//       The VBand web address is: https://hamradio.solutions/vband/
//       Select Speed, keyer type, etc. on the VBand website 'Settings" section

#include "Keyboard.h"

//declaring paddle input pins
const int dah = 3;          
const int dit = 2;

int previousdahState = HIGH;
int previousditState = HIGH;


void setup() {
  //declare the inputs as input_pullup
  pinMode(dah, INPUT_PULLUP);  
  pinMode(dit, INPUT_PULLUP);  
  Keyboard.begin();
}

void loop() {
  //checking the state of the inputs
  int dahState = digitalRead(dah);
  int ditState = digitalRead(dit);

 
 //replaces left paddle input with dit using "[" as defined by VBand website
  if (dahState == LOW && previousdahState == HIGH) {
      // and it's currently pressed:
    Keyboard.press(93);
    delay(50);
  }
  if (dahState == HIGH && previousdahState == LOW) {
      // and it's currently released:[[[[]]]]
    Keyboard.release(93);
    delay(50);
  }
 
 //replaces right paddle input with dah using "]" as defined by VBand website
  if (ditState == LOW && previousditState == HIGH) {
      // and it's currently pressed:
    Keyboard.press(91);
    delay(50);
  }
  if (ditState == HIGH && previousditState == LOW) {
      // and it's currently released:
    Keyboard.release(91);
    delay(50);
  }

  previousdahState = dahState;
  previousditState = ditState;
}
 
-----

At this point everything should "just work".  However, there are some VBand setting you can adjust. The settings are obvious, but we will highlight two here:

-----

Provided you have used the Arduino IDE before to upload code the whole project should take only a few minutes.   Enjoy VBand, practice up, and hope to see you on 'real' airwaves.

-----



Thursday, January 23, 2025

Easy Graphing of Temperatures with RasPI and NodeRed

-----

After hearing over and over how quick and easy the graphical programming environment for Node-Red was we decided to give it a try with an old Raspberry PI Model B that was 'resting' in a drawer.   Conclusion:  It's easy; it's really really easy.   Plus, there is a large user base that has created examples and libraries for just about any application you can dream up.

-----
Node-Red is free and the install is easy and well documented on their website.  For our Node-Red experiment we decide to graph the ambient air temperature as measured by a DS18B20 and the CPU core temperature of the Raspberry PI Model B.   Connect the DS18B20 sensor like this:

-----

It didn't take us long at all to 'noodle' up this:

-----

Then we went into the "Layout" tab to format what the graphs would look like:

----

All in all the project, including the Node-Red install; was probably under two hours.  That's pretty damn quick for going from zero knowledge to serving up nice graphs.

-----

Importing or Exporting code is easy as well.  It's basically "copy/past".  Here is our code for the project:

[
    {
        "id": "018abb4b7bfb7e86",
        "type": "exec",
        "z": "c0bb5756099d6dbc",
        "command": "vcgencmd measure_temp",
        "addpay": false,
        "append": "",
        "useSpawn": "false",
        "timer": "",
        "oldrc": false,
        "name": "Get CPU Temp",
        "x": 420,
        "y": 40,
        "wires": [
            [
                "05c0f7272e5bc7e0"
            ],
            [],
            []
        ]
    },
    {
        "id": "05c0f7272e5bc7e0",
        "type": "function",
        "z": "c0bb5756099d6dbc",
        "name": "Parse Temp",
        "func": "var tempC = parseFloat(msg.payload.replace('temp=', '').replace(\"'C\", ''));\nvar tempF = (tempC * 9/5) + 32;\nmsg.payload = tempF.toFixed(2);\nreturn msg;",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 630,
        "y": 40,
        "wires": [
            [
                "7dfbce89f48de845",
                "d97496ad6fb77af6"
            ]
        ]
    },
    {
        "id": "7dfbce89f48de845",
        "type": "ui_chart",
        "z": "c0bb5756099d6dbc",
        "name": "",
        "group": "7",
        "order": 8,
        "width": 11,
        "height": 5,
        "label": "CPU (°F)",
        "chartType": "line",
        "legend": "false",
        "xformat": "HH:mm",
        "interpolate": "linear",
        "nodata": "",
        "dot": false,
        "ymin": "",
        "ymax": "",
        "removeOlder": "7",
        "removeOlderPoints": "10080",
        "removeOlderUnit": "86400",
        "cutout": 0,
        "useOneColor": false,
        "useUTC": false,
        "colors": [
            "#00b500",
            "#e6e600",
            "#ca3838",
            "#000000",
            "#000000",
            "#000000",
            "#000000",
            "#000000",
            "#000000"
        ],
        "outputs": 1,
        "useDifferentColor": false,
        "className": "",
        "x": 880,
        "y": 40,
        "wires": [
            []
        ]
    },
    {
        "id": "d97496ad6fb77af6",
        "type": "ui_gauge",
        "z": "c0bb5756099d6dbc",
        "name": "",
        "group": "7",
        "order": 7,
        "width": 5,
        "height": 5,
        "gtype": "donut",
        "title": "CPU (°F)",
        "label": "°F",
        "format": "{{value}}",
        "min": "110",
        "max": "175",
        "colors": [
            "#00b500",
            "#e6e600",
            "#ca3838"
        ],
        "seg1": "",
        "seg2": "",
        "diff": false,
        "className": "",
        "x": 880,
        "y": 80,
        "wires": []
    },
    {
        "id": "3b8516632c6153ec",
        "type": "debug",
        "z": "c0bb5756099d6dbc",
        "name": "DC18B20 Temp",
        "active": false,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 900,
        "y": 260,
        "wires": []
    },
    {
        "id": "e19a60a2f08ac386",
        "type": "inject",
        "z": "c0bb5756099d6dbc",
        "name": "Every 60 secs",
        "props": [],
        "repeat": "60",
        "crontab": "",
        "once": true,
        "onceDelay": "1",
        "topic": "",
        "x": 160,
        "y": 40,
        "wires": [
            [
                "018abb4b7bfb7e86"
            ]
        ]
    },
    {
        "id": "a3b79e7f7975fa73",
        "type": "ds18b20",
        "z": "c0bb5756099d6dbc",
        "name": "DS18B20",
        "sensorid": "28-089bd445e089",
        "timer": "1",
        "x": 380,
        "y": 220,
        "wires": [
            [
                "3d28949a3fa611d3"
            ]
        ]
    },
    {
        "id": "3d28949a3fa611d3",
        "type": "function",
        "z": "c0bb5756099d6dbc",
        "name": "C_to_F",
        "func": "var tempc = msg.payload;\nvar tempf = tempc * 9/5 + 32;\ntempf = tempf.toFixed(2);\nmsg.payload = tempf;\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 600,
        "y": 220,
        "wires": [
            [
                "3b8516632c6153ec",
                "61cf75a9eeeb79be",
                "e146810a6d814e42"
            ]
        ]
    },
    {
        "id": "e146810a6d814e42",
        "type": "ui_chart",
        "z": "c0bb5756099d6dbc",
        "name": "",
        "group": "7",
        "order": 3,
        "width": 11,
        "height": 9,
        "label": "°F",
        "chartType": "line",
        "legend": "false",
        "xformat": "HH:mm",
        "interpolate": "linear",
        "nodata": "",
        "dot": false,
        "ymin": "",
        "ymax": "",
        "removeOlder": "7",
        "removeOlderPoints": "10080",
        "removeOlderUnit": "86400",
        "cutout": 0,
        "useOneColor": false,
        "useUTC": false,
        "colors": [
            "#00b500",
            "#e6e600",
            "#ca3838",
            "#000000",
            "#000000",
            "#000000",
            "#000000",
            "#000000",
            "#000000"
        ],
        "outputs": 1,
        "useDifferentColor": false,
        "className": "",
        "x": 870,
        "y": 180,
        "wires": [
            []
        ]
    },
    {
        "id": "61cf75a9eeeb79be",
        "type": "ui_gauge",
        "z": "c0bb5756099d6dbc",
        "name": "",
        "group": "7",
        "order": 2,
        "width": 5,
        "height": 9,
        "gtype": "donut",
        "title": "Ambient (°F)",
        "label": "°F",
        "format": "{{value}}",
        "min": "80",
        "max": "110",
        "colors": [
            "#00b500",
            "#e6e600",
            "#ca3838"
        ],
        "seg1": "",
        "seg2": "",
        "diff": false,
        "className": "",
        "x": 890,
        "y": 220,
        "wires": []
    },
    {
        "id": "cc4248afd8356380",
        "type": "debug",
        "z": "c0bb5756099d6dbc",
        "name": "debug 1",
        "active": false,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "false",
        "statusVal": "",
        "statusType": "auto",
        "x": 880,
        "y": 400,
        "wires": []
    },
    {
        "id": "1553124d816901d2",
        "type": "inject",
        "z": "c0bb5756099d6dbc",
        "name": "",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "1",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 170,
        "y": 360,
        "wires": [
            [
                "cc4248afd8356380",
                "6f3d2d25b6ad464e"
            ]
        ]
    },
    {
        "id": "6f3d2d25b6ad464e",
        "type": "ui_digital_clock",
        "z": "c0bb5756099d6dbc",
        "name": "",
        "group": "7",
        "order": 5,
        "width": 4,
        "height": 1,
        "x": 890,
        "y": 360,
        "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
    }
]

-----