Tuesday, May 26, 2026

Flex Radio RIT/XIT Control via Encoder Knobs

This is a variant of the Poor Man's Stream Deck project project we did to allow for adjustment of the RIT and XIT values on a Flex Radio.   Turns out not having to point the mouse at the somewhat small RIT and XIT buttons in SSDR makes using these controls a lot more efficient.  The CYD Poor Man's Stream Deck works great, but lacks tactical feedback.   We fixed that by using EC11 Encoders.

-----
The project is cheap and easy to duplicate; nothing special.  We used separate encoders for RIT and XIT.   Pressing in the button resets that encoder value to 0.   If the rig is idle for 2 hours the values also get set to 0.  The encoders are connected to an ESP32 like so:
-----
Here is a short video demo of the rig in action after being put in a 3D printed project box.
-----
And below is the simple software for the ESP32.  Upload it to your uC via the Arduino IDE.

/*
  ESP32 + FLEX Radio RIT/XIT Controller
  -------------------------------------
 * Arduino IDE Board Setting: ESP32 Dev Module, 240MHz (WiFi/BT), 921600

 * MAY2026
 * Project details at: whiskeytangohotel.com

  Encoder 1 = RIT
  Encoder 2 = XIT

  ClockWise  = +10
  CCWise = -10
  Pushbutton = Reset to 0

  Sends commands directly to FLEX over LAN.

  Serial Monitor displays:
  - RIT value
  - XIT value
  - ESP32 IP Address
*/

#include <WiFi.h>

const char* ssid     = "Virus-2.4";
const char* password = "zenakelsocats";

// =====================================================
//                 FLEX RADIO SETTINGS
// =====================================================

const char* flexIP = "192.168.1.2";
const int   flexPort = 4992;

WiFiClient flexClient;

unsigned long sequenceNumber = 0;

// =====================================================
//              FLEX KEEPALIVE SETTINGS
// =====================================================

unsigned long lastKeepAlive = 0;

const unsigned long KEEPALIVE_INTERVAL = 30000; // 30 seconds

// =====================================================
//       AUTO ZERO SETTING AFTER XX MINUTES W/O USE
// =====================================================

const unsigned long AUTO_ZERO_MINUTES = 240;

unsigned long lastRitActivity = 0;
unsigned long lastXitActivity = 0;

// =====================================================
//  ENCODER PIN DEFINES
// =====================================================

// Encoder 1 = RIT
#define ENC1_A   32
#define ENC1_B   33
#define ENC1_SW  25

// Encoder 2 = XIT
#define ENC2_A   26
#define ENC2_B   27
#define ENC2_SW  14

int ritValue = 0;
int xitValue = 0;

// Previous states
int lastState1A;
int lastState2A;

// Button debounce timers
unsigned long lastButton1 = 10;
unsigned long lastButton2 = 10;

// =====================================================
//                  PRINT STATUS
// =====================================================

void printValues() {

  Serial.print("IP: ");
  Serial.print(WiFi.localIP());

  Serial.print("    RIT: ");
  Serial.print(ritValue);

  Serial.print("    XIT: ");
  Serial.println(xitValue);
}

// =====================================================
//                SEND FLEX COMMAND
// =====================================================

void sendFlexCommand(String cmd) {

  if (!flexClient.connected()) return;

  String fullCommand =
    "C" + String(sequenceNumber++) +
    "|" + cmd + "\n";

  flexClient.print(fullCommand);
}

// =====================================================
//                UPDATE FLEX RADIO
// =====================================================

void updateRIT() {

  sendFlexCommand(
    "slice s 0 rit_on=1 rit_freq=" + String(ritValue)
  );
}

void updateXIT() {

  sendFlexCommand(
    "slice s 0 xit_on=1 xit_freq=" + String(xitValue)
  );
}

// =====================================================
//               CONNECT TO FLEX
// =====================================================

void connectToFlex() {

  Serial.println();
  Serial.println("Connecting to FLEX...");

  if (flexClient.connect(flexIP, flexPort)) {

    Serial.println("Connected to FLEX.");

    sendFlexCommand("client program ESP32_RIT");
    sendFlexCommand("sub slice all");

  } else {

    Serial.println("FLEX connection FAILED.");
  }
}

// =====================================================
//                       SETUP
// =====================================================

void setup() {

  Serial.begin(115200);

  delay(1000);

  Serial.println();
  Serial.println("ESP32 FLEX Controller Starting...");
  Serial.println();

  pinMode(ENC1_A, INPUT_PULLUP);
  pinMode(ENC1_B, INPUT_PULLUP);
  pinMode(ENC1_SW, INPUT_PULLUP);

  pinMode(ENC2_A, INPUT_PULLUP);
  pinMode(ENC2_B, INPUT_PULLUP);
  pinMode(ENC2_SW, INPUT_PULLUP);

  lastState1A = digitalRead(ENC1_A);
  lastState2A = digitalRead(ENC2_A);

  // WiFi
  Serial.print("Connecting to WiFi");

  WiFi.begin(ssid, password);

  WiFi.setSleep(false);

  while (WiFi.status() != WL_CONNECTED) {

    delay(500);
    Serial.print(".");
  }

  Serial.println();
  Serial.println("WiFi Connected.");

  Serial.print("ESP32 IP Address: ");
  Serial.println(WiFi.localIP());

  connectToFlex();

  printValues();

  // ============================
  // INIT AUTO ZERO TIMERS
  // ============================
  lastRitActivity = millis();
  lastXitActivity = millis();
}

// =====================================================
//                        LOOP
// =====================================================

void loop() {

  if (!flexClient.connected()) {
    connectToFlex();
    delay(2000);
  }

  unsigned long now = millis();
  unsigned long timeout = AUTO_ZERO_MINUTES * 60UL * 1000UL;

  // ==========================================
  // ENCODER 1 = RIT
  // ==========================================

  int currentState1A = digitalRead(ENC1_A);

  if (lastState1A == HIGH && currentState1A == LOW) {

    if (digitalRead(ENC1_B) == currentState1A) {
      ritValue += 10;
    } else {
      ritValue -= 10;
    }

    updateRIT();
    printValues();

    lastRitActivity = now;
  }

  lastState1A = currentState1A;

  // PUSHBUTTON 1
  if (digitalRead(ENC1_SW) == LOW) {

    if (millis() - lastButton1 > 250) {

      ritValue = 0;

      sendFlexCommand("slice s 0 rit_on=0 rit_freq=0");

      printValues();

      lastButton1 = millis();

      lastRitActivity = now;
    }
  }

  // ==========================================
  // ENCODER 2 = XIT
  // ==========================================

  int currentState2A = digitalRead(ENC2_A);

  if (lastState2A == HIGH && currentState2A == LOW) {

    if (digitalRead(ENC2_B) == currentState2A) {
      xitValue += 10;
    } else {
      xitValue -= 10;
    }

    updateXIT();
    printValues();

    lastXitActivity = now;
  }

  lastState2A = currentState2A;

  // PUSHBUTTON 2
  if (digitalRead(ENC2_SW) == LOW) {

    if (millis() - lastButton2 > 250) {

      xitValue = 0;

      sendFlexCommand("slice s 0 xit_on=0 xit_freq=0");

      printValues();

      lastButton2 = millis();

      lastXitActivity = now;
    }
  }

  // ==========================================
  // AUTO ZERO LOGIC
  // ==========================================

  if ((now - lastRitActivity > timeout) &&
      (now - lastXitActivity > timeout)) {

    if (ritValue != 0 || xitValue != 0) {

      ritValue = 0;
      xitValue = 0;

      sendFlexCommand("slice s 0 rit_on=0 rit_freq=0");
      sendFlexCommand("slice s 0 xit_on=0 xit_freq=0");

      Serial.println("AUTO-ZERO triggered");

      printValues();

      lastRitActivity = now;
      lastXitActivity = now;
    }
  }

  // ==========================================
  // FLEX KEEPALIVE
  // ==========================================

  if (millis() - lastKeepAlive > KEEPALIVE_INTERVAL) {

    sendFlexCommand("ping");

    lastKeepAlive = millis();
  }

  // ==========================================
  // REBOOT IF BOTH BUTTONS PRESSED
  // ==========================================

  if (digitalRead(ENC1_SW) == LOW &&
      digitalRead(ENC2_SW) == LOW) {

    Serial.println("REBOOTING ESP32...");

    delay(1000);

    ESP.restart();
  }
}
-----

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