VAw4HBTsJIe15camAdLyxmr6Gko2NgDKdvrlkFP2

Kodingan Simple APRS Tracker ESP32 by YD2CLX

 <pre><code>

#include <math.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <TinyGPS++.h>

// Pin definitions
#define OUT_PIN 2      // AFSK output
#define LED_PIN 5      // PTT/LED transmit
#define ONBOARD_LED 2  // LED built-in ESP32 Dev Module

// Tone constants
#define _1200 1
#define _2200 0

#define _FLAG       0x7e
#define _CTRL_ID    0x03
#define _PID        0xf0
#define _DT_EXP     ','
#define _DT_STATUS  '>'
#define _DT_POS     '!'

#define _NORMAL     1
#define _BEACON     2
#define _FIXPOS     3
#define _STATUS     2
#define _FIXPOS_STATUS  1

bool nada = _2200;

const float baud_adj = 0.975f;
const float adj_1200 = 1.0f * baud_adj;
const float adj_2200 = 1.0f * baud_adj;
unsigned int tc1200 = (unsigned int)(0.5f * adj_1200 * 1000000.0f / 1200.0f);
unsigned int tc2200 = (unsigned int)(0.5f * adj_2200 * 1000000.0f / 2200.0f);

// Config variables
char mycall[10] = "YD2XXX"; //calsign anda
uint8_t myssid = 7;
char mystatus[100] = "Tracker Test Over ESP32 - Havid";
bool smartBeaconing = false;
unsigned long tx_interval = 60000UL;  // ms

const char *dest = "APRS";
const char *dest_beacon = "BEACON";

// === PATH DIGI YANG DIPERBAIKI (untuk terdigi MAKSIMAL) ===
char digi1[6] = "WIDE1";      // Local digipeater
uint8_t digissid1 = 1;
char digi2[6] = "WIDE2";      // Wide-area digipeater
uint8_t digissid2 = 2;
// ========================================================

char lat[9] = "0000.00N";
char lon[10] = "00000.00E";
const char sym_ovl = '/';
const char sym_tab = 'k';

char bit_stuff = 0;
unsigned short crc = 0xffff;

TinyGPSPlus gps;
HardwareSerial gpsSerial(1);

bool gps_valid = false;
bool gps_locked = false;

double last_lat = 0.0;
double last_lon = 0.0;
float last_speed = 0.0;

unsigned long last_tx_time = 0;

WebServer server(80);

char dummy_lat[9] = "0659.00S";
char dummy_lon[10] = "10649.00E";

Preferences prefs;  // Preferences object

// Additional for GPS status
String gps_connection_status = "Not Connected";
unsigned long last_gps_data_time = 0;
unsigned long last_status_print = 0;

void set_nada_1200() {
  digitalWrite(OUT_PIN, HIGH);
  delayMicroseconds(tc1200);
  digitalWrite(OUT_PIN, LOW);
  delayMicroseconds(tc1200);
}

void set_nada_2200() {
  digitalWrite(OUT_PIN, HIGH);
  delayMicroseconds(tc2200);
  digitalWrite(OUT_PIN, LOW);
  delayMicroseconds(tc2200);
  digitalWrite(OUT_PIN, HIGH);
  delayMicroseconds(tc2200);
  digitalWrite(OUT_PIN, LOW);
  delayMicroseconds(tc2200);
}

void set_nada(bool n) {
  if (n) set_nada_1200();
  else set_nada_2200();
}

void calc_crc(bool in_bit) {
  unsigned short xor_in = crc ^ in_bit;
  crc >>= 1;
  if (xor_in & 0x01) crc ^= 0x8408;
}

void send_crc() {
  unsigned char crc_lo = crc ^ 0xff;
  unsigned char crc_hi = (crc >> 8) ^ 0xff;
  send_char_NRZI(crc_lo, HIGH);
  send_char_NRZI(crc_hi, HIGH);
}

void send_header(char msg_type) {
  char temp;
  const char *dest_str = (msg_type == _NORMAL) ? dest : dest_beacon;
  temp = strlen(dest_str);
  for (int j = 0; j < temp; j++) send_char_NRZI(dest_str[j] << 1, HIGH);
  for (int j = temp; j < 6; j++) send_char_NRZI(' ' << 1, HIGH);
  send_char_NRZI('0' << 1, HIGH);

  temp = strlen(mycall);
  for (int j = 0; j < temp; j++) send_char_NRZI(mycall[j] << 1, HIGH);
  for (int j = temp; j < 6; j++) send_char_NRZI(' ' << 1, HIGH);
  send_char_NRZI((myssid + '0') << 1, HIGH);

  // === PATH DIGI 2 FIELD (WIDE1-1,WIDE2-2) - HANYA BAGIAN INI YANG DIPERBAIKI ===
  // Digi 1 (WIDE1-1) - extension bit = 0
  temp = strlen(digi1);
  for (int j = 0; j < temp; j++) send_char_NRZI(digi1[j] << 1, HIGH);
  for (int j = temp; j < 6; j++) send_char_NRZI(' ' << 1, HIGH);
  send_char_NRZI((digissid1 + '0') << 1, HIGH);

  // Digi 2 (WIDE2-2) - extension bit = 1 (last address)
  temp = strlen(digi2);
  for (int j = 0; j < temp; j++) send_char_NRZI(digi2[j] << 1, HIGH);
  for (int j = temp; j < 6; j++) send_char_NRZI(' ' << 1, HIGH);
  send_char_NRZI(((digissid2 + '0') << 1) + 1, HIGH);
  // =============================================================================

  send_char_NRZI(_CTRL_ID, HIGH);
  send_char_NRZI(_PID, HIGH);
}

void send_payload(char type, bool use_dummy = false) {
  const char *use_lat = use_dummy ? dummy_lat : lat;
  const char *use_lon = use_dummy ? dummy_lon : lon;

  if (type == _FIXPOS) {
    send_char_NRZI(_DT_POS, HIGH);
    send_string_len(use_lat, strlen(use_lat));
    send_char_NRZI(sym_ovl, HIGH);
    send_string_len(use_lon, strlen(use_lon));
    send_char_NRZI(sym_tab, HIGH);
  } else if (type == _STATUS) {
    send_char_NRZI(_DT_STATUS, HIGH);
    send_string_len(mystatus, strlen(mystatus));
  } else if (type == _FIXPOS_STATUS) {
    send_char_NRZI(_DT_POS, HIGH);
    send_string_len(use_lat, strlen(use_lat));
    send_char_NRZI(sym_ovl, HIGH);
    send_string_len(use_lon, strlen(use_lon));
    send_char_NRZI(sym_tab, HIGH);
    send_char_NRZI(' ', HIGH);
    send_string_len(mystatus, strlen(mystatus));
  }
}

void send_char_NRZI(unsigned char in_byte, bool enBitStuff) {
  bool bits;
  for (int i = 0; i < 8; i++) {
    bits = in_byte & 0x01;
    calc_crc(bits);
    if (bits) {
      set_nada(nada);
      bit_stuff++;
      if (enBitStuff && bit_stuff == 5) {
        nada ^= 1;
        set_nada(nada);
        bit_stuff = 0;
      }
    } else {
      nada ^= 1;
      set_nada(nada);
      bit_stuff = 0;
    }
    in_byte >>= 1;
  }
}

void send_string_len(const char *in_string, int len) {
  for (int j = 0; j < len; j++) send_char_NRZI(in_string[j], HIGH);
}

void send_flag(unsigned char flag_len) {
  for (int j = 0; j < flag_len; j++) send_char_NRZI(_FLAG, LOW);
}

void send_packet(char packet_type, char dest_type, bool use_dummy = false) {
  print_debug(packet_type, dest_type, use_dummy);

  digitalWrite(LED_PIN, HIGH);

  send_flag(100);
  crc = 0xffff;
  send_header(dest_type);
  send_payload(packet_type, use_dummy);
  send_crc();
  send_flag(3);

  digitalWrite(LED_PIN, LOW);
}

void print_debug(char type, char dest_type, bool use_dummy) {
  Serial.print(mycall);
  Serial.print('-');
  Serial.print(myssid, DEC);
  Serial.print('>');
  Serial.print((dest_type == _NORMAL) ? dest : dest_beacon);
  Serial.print(',');

  // === DEBUG PATH DITAMPILKAN BENAR (WIDE1-1,WIDE2-2) ===
  Serial.print(digi1);
  Serial.print('-');
  Serial.print(digissid1, DEC);
  Serial.print(',');
  Serial.print(digi2);
  Serial.print('-');
  Serial.print(digissid2, DEC);
  Serial.print(':');
  // ====================================================

  const char *use_lat = use_dummy ? dummy_lat : lat;
  const char *use_lon = use_dummy ? dummy_lon : lon;

  if (type == _FIXPOS) {
    Serial.print(_DT_POS);
    Serial.print(use_lat);
    Serial.print(sym_ovl);
    Serial.print(use_lon);
    Serial.print(sym_tab);
  } else if (type == _STATUS) {
    Serial.print(_DT_STATUS);
    Serial.print(mystatus);
  } else if (type == _FIXPOS_STATUS) {
    Serial.print(_DT_POS);
    Serial.print(use_lat);
    Serial.print(sym_ovl);
    Serial.print(use_lon);
    Serial.print(sym_tab);
    Serial.print(' ');
    Serial.print(mystatus);
  }
  Serial.println(' ');
}

void updateCoordinates() {
  char lat_str[9];
  char lon_str[10];

  double lat_val = gps.location.lat();
  char lat_dir = (lat_val < 0) ? 'S' : 'N';
  lat_val = fabs(lat_val);
  int lat_deg = (int)lat_val;
  double lat_min = (lat_val - lat_deg) * 60.0;
  sprintf(lat_str, "%02d%05.2f%c", lat_deg, lat_min, lat_dir);

  double lon_val = gps.location.lng();
  char lon_dir = (lon_val < 0) ? 'W' : 'E';
  lon_val = fabs(lon_val);
  int lon_deg = (int)lon_val;
  double lon_min = (lon_val - lon_deg) * 60.0;
  sprintf(lon_str, "%03d%05.2f%c", lon_deg, lon_min, lon_dir);

  strncpy(lat, lat_str, 8);
  lat[8] = '\0';
  strncpy(lon, lon_str, 9);
  lon[9] = '\0';

  Serial.print("Latitude: ");
  Serial.println(lat);
  Serial.print("Longitude: ");
  Serial.println(lon);
}

String getGPSStatus() {
  if (gps_locked) {
    return "Locked - Ready to Transmit";
  } else {
    return "Not Locked - Not Ready";
  }
}

String getRootHtml() {
  String gpsColor = gps_locked ? "green" : "red";
  String connColor = (gps_connection_status == "Locked") ? "green" : ((gps_connection_status == "Searching") ? "orange" : "red");
  String html = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APRS Tracker By YD2CLX</title>
<style>
  body {font-family: Arial, sans-serif; background-color: #f0f4f8; margin: 0; padding: 20px; display: flex; justify-content: center; align-items: center; min-height: 100vh; color: #333;}
  .container {background-color: #fff; padding: 30px; border-radius: 15px; box-shadow: 0 6px 15px rgba(0, 0, 0, 0.1); max-width: 450px; width: 100%;}
  h1 {text-align: center; color: #007bff; margin-bottom: 25px; font-size: 26px;}
  label {display: block; margin-bottom: 10px; font-weight: bold; color: #555;}
  input[type="text"], input[type="number"], select {width: 100%; padding: 12px; margin-bottom: 20px; border: 1px solid #ddd; border-radius: 8px; box-sizing: border-box; font-size: 16px;}
  input[type="checkbox"] {margin-right: 10px;}
  .checkbox-label {display: flex; align-items: center; margin-bottom: 20px; font-size: 16px;}
  button {width: 100%; padding: 14px; background-color: #28a745; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; margin-top: 10px; transition: background-color 0.3s;}
  button:hover {background-color: #218838;}
  .status {margin-top: 25px; padding: 15px; background-color: #e9ecef; border-radius: 8px; text-align: center;}
  .status p {margin: 8px 0; font-size: 16px;}
  #transmitBtn {background-color: #007bff;}
  #transmitBtn:hover {background-color: #0056b3;}
  #dummyTransmitBtn {background-color: #ffc107;}
  #dummyTransmitBtn:hover {background-color: #e0a800;}
  #gpsStatus {color: )rawliteral" + gpsColor + R"rawliteral(;}
  #gpsConnStatus {color: )rawliteral" + connColor + R"rawliteral(;}
  @media (max-width: 600px) {.container {padding: 20px;} h1 {font-size: 22px;}}
</style>
<script>
function manualTransmit() {
  var xhr = new XMLHttpRequest();
  xhr.open("POST", "/transmit", true);
  xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200) {
      alert(xhr.responseText);
    }
  };
  xhr.send();
}
function dummyTransmit() {
  var xhr = new XMLHttpRequest();
  xhr.open("POST", "/dummy_transmit", true);
  xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200) {
      alert(xhr.responseText);
    }
  };
  xhr.send();
}

let refreshTimer;
function startRefresh() {
  refreshTimer = setInterval(() => {
    if (!document.activeElement ||
        (document.activeElement.tagName !== 'INPUT' &&
         document.activeElement.tagName !== 'TEXTAREA' &&
         document.activeElement.tagName !== 'SELECT')) {
      location.reload();
    }
  }, 5000);
}

document.addEventListener('DOMContentLoaded', () => {
  const smartBeacon = document.getElementById('smartbeaconing');
  const intervalSelect = document.getElementById('interval');
  smartBeacon.addEventListener('change', () => {
    intervalSelect.disabled = smartBeacon.checked;
  });

  startRefresh();

  const inputs = document.querySelectorAll('input, select');
  inputs.forEach(input => {
    input.addEventListener('focus', () => { clearInterval(refreshTimer); });
    input.addEventListener('blur', () => { startRefresh(); });
  });
});
</script>
</head>
<body>
  <div class="container">
    <h1>APRS Tracker Configuration</h1>
    <form action='/save' method='POST'>
      <label for="callsign">Callsign:</label>
      <input type="text" id="callsign" name="callsign" value=")rawliteral" + String(mycall) + R"rawliteral(" maxlength="9">
      <label for="ssid">SSID:</label>
      <input type="number" id="ssid" name="ssid" value=")rawliteral" + String(myssid) + R"rawliteral(" min="0" max="15">
      <label for="comment">APRS Status/Comment:</label>
      <input type="text" id="comment" name="comment" value=")rawliteral" + String(mystatus) + R"rawliteral(" maxlength="99">
      <div class="checkbox-label">
        <input type="checkbox" id="smartbeaconing" name="smartbeaconing" )rawliteral" + (smartBeaconing ? "checked" : "") + R"rawliteral(>
        <label for="smartbeaconing" style="display: inline; font-weight: normal;">Enable Smart Beaconing</label>
      </div>
      <label for="interval">Transmit Interval (minutes):</label>
      <select id="interval" name="interval" )rawliteral" + (smartBeaconing ? "disabled" : "") + R"rawliteral(>
        <option value="1" )rawliteral" + (tx_interval == 60000 ? "selected" : "") + R"rawliteral(>1 Minute</option>
        <option value="2" )rawliteral" + (tx_interval == 120000 ? "selected" : "") + R"rawliteral(>2 Minutes</option>
        <option value="5" )rawliteral" + (tx_interval == 300000 ? "selected" : "") + R"rawliteral(>5 Minutes</option>
        <option value="10" )rawliteral" + (tx_interval == 600000 ? "selected" : "") + R"rawliteral(>10 Minutes</option>
      </select>
      <button type="submit">Save Settings</button>
    </form>
    <button id="transmitBtn" onclick="manualTransmit()">Manual Transmit</button>
    <button id="dummyTransmitBtn" onclick="dummyTransmit()">TX Dummy Data</button>
    <div class="status">
      <p>Latitude: <span>)rawliteral" + String(lat) + R"rawliteral(</span></p>
      <p>Longitude: <span>)rawliteral" + String(lon) + R"rawliteral(</span></p>
      <p>GPS Connection: <span id="gpsConnStatus">)rawliteral" + gps_connection_status + R"rawliteral(</span></p>
      <p>GPS Status: <span id="gpsStatus">)rawliteral" + getGPSStatus() + R"rawliteral(</span></p>
    </div>
  </div>
</body>
</html>
)rawliteral";
  return html;
}

void handleRoot() {
  server.send(200, "text/html", getRootHtml());
}

void handleSave() {
  if (server.hasArg("callsign")) {
    String s = server.arg("callsign");
    if (s.length() <= 9) {
      s.toCharArray(mycall, 10);
      mycall[9] = '\0';
      Serial.print("New callsign: "); Serial.println(mycall);
    }
  }
  if (server.hasArg("ssid")) {
    myssid = server.arg("ssid").toInt();
    Serial.print("New SSID: "); Serial.println(myssid);
  }
  if (server.hasArg("comment")) {
    String s = server.arg("comment");
    if (s.length() <= 99) {
      s.toCharArray(mystatus, 100);
      mystatus[99] = '\0';
      Serial.print("New status: "); Serial.println(mystatus);
    }
  }
  smartBeaconing = server.hasArg("smartbeaconing");
  Serial.print("Smart Beaconing: "); Serial.println(smartBeaconing ? "ON" : "OFF");

  if (server.hasArg("interval")) {
    int min = server.arg("interval").toInt();
    tx_interval = (unsigned long)min * 60000UL;
    Serial.print("New interval (ms): "); Serial.println(tx_interval);
  }

  prefs.begin("aprs_config", false);
  prefs.putString("callsign", mycall);
  prefs.putUChar("ssid", myssid);
  prefs.putString("status", mystatus);
  prefs.putBool("smartbeac", smartBeaconing);
  prefs.putULong("interval", tx_interval);
  prefs.end();

  Serial.println("Settings SAVED to Preferences!");

  server.sendHeader("Location", "/");
  server.send(303);
}

void handleTransmit() {
  if (gps_valid) {
    send_packet(_FIXPOS_STATUS, _NORMAL);
    server.send(200, "text/plain", "Packet transmitted");
  } else {
    server.send(400, "text/plain", "No valid GPS data");
  }
}

void handleDummyTransmit() {
  send_packet(_FIXPOS_STATUS, _NORMAL, true);
  server.send(200, "text/plain", "Dummy packet transmitted");
}

void setup() {
  pinMode(ONBOARD_LED, OUTPUT);
  pinMode(OUT_PIN, OUTPUT);
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);
  Serial.begin(115200);
  delay(500);
  Serial.println("\nAPRS Tracker ESP32 - Starting... (Path: WIDE1-1,WIDE2-2)");

  gpsSerial.begin(9600, SERIAL_8N1, 16, 17);

  prefs.begin("aprs_config", false);
  String call = prefs.getString("callsign", "YD2CLX");
  call.toCharArray(mycall, 10);
  myssid = prefs.getUChar("ssid", 7);
  String stat = prefs.getString("status", "Tracker Test Over ESP32 - Havid");
  stat.toCharArray(mystatus, 100);
  smartBeaconing = prefs.getBool("smartbeac", false);
  tx_interval = prefs.getULong("interval", 60000UL);
  prefs.end();

  Serial.print("Loaded callsign: "); Serial.println(mycall);
  Serial.print("Loaded SSID: "); Serial.println(myssid);
  Serial.print("Loaded status: "); Serial.println(mystatus);
  Serial.print("Loaded smartBeaconing: "); Serial.println(smartBeaconing);
  Serial.print("Loaded tx_interval: "); Serial.println(tx_interval);

  WiFi.softAP("APRS-Tracker", "12345678");
  Serial.print("AP IP: "); Serial.println(WiFi.softAPIP());

  server.on("/", HTTP_GET, handleRoot);
  server.on("/save", HTTP_POST, handleSave);
  server.on("/transmit", HTTP_POST, handleTransmit);
  server.on("/dummy_transmit", HTTP_POST, handleDummyTransmit);

  server.begin();
  Serial.println("Web server started");
}

void loop() {
  server.handleClient();

  bool data_received = false;
  while (gpsSerial.available() > 0) {
    data_received = true;
    gps.encode(gpsSerial.read());
  }

  if (data_received) {
    last_gps_data_time = millis();
  }

  unsigned long now = millis();
  if (now - last_gps_data_time > 2000) {
    gps_connection_status = "Not Connected";
  } else if (!gps_locked) {
    gps_connection_status = "Searching";
  } else {
    gps_connection_status = "Locked";
  }

  if (gps.location.isUpdated()) {
    updateCoordinates();
  }

  gps_locked = gps.location.isValid() && gps.satellites.value() >= 3;
  gps_valid = gps_locked;

  unsigned long effective_interval = tx_interval;
  if (smartBeaconing && gps_valid) {
    last_speed = gps.speed.kmph();
    if (last_speed > 50) effective_interval /= 4;
    else if (last_speed > 10) effective_interval /= 2;
    if (effective_interval < 30000) effective_interval = 30000;
  }

  if (gps_valid && (now - last_tx_time >= effective_interval)) {
    send_packet(_FIXPOS_STATUS, _NORMAL);
    last_tx_time = now;
  }

  if (now - last_status_print >= 5000) {
    Serial.print("GPS Connection Status: "); Serial.println(gps_connection_status);
    Serial.print("GPS Lock Status: "); Serial.println(gps_locked ? "Locked" : "Not Locked");
    Serial.print("Satellites: "); Serial.println(gps.satellites.value());
    last_status_print = now;
  }
}
</code></pre>



---- UPDATE 8 April 2026

Penyempurnaan Kode APRS Tracker by YF9UAG

  • pindah/geser pin I/O agar pin 2 tidak bentrok dengan LED onboard, 
  • modif tampilan web, 
  • Penambahan fitur "channel bussy" pakai sensor speaker out HT ( squelch tidak boleh dibuka full
  • Penyempurnaan User Interface 

skema channel bussy

SPK radio ----[100k]----+-----> GPIO34
                        |
                      [100k]
                        |
                       GND
<code><pre>
#include <math.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <TinyGPS++.h>
#include <ctype.h>

// ========================= PIN DEFINITIONS =========================
#define AFSK_PIN       25
#define PTT_PIN        26
#define STATUS_LED     2
#define GPS_RX_PIN     16
#define GPS_TX_PIN     17
#define BUSY_IN_PIN    34   // speaker audio detect via ADC

// ========================= APRS / AX.25 ============================
#define AX25_FLAG      0x7E
#define AX25_CTRL      0x03
#define AX25_PID       0xF0

#define DT_STATUS      '>'
#define DT_POS         '!'

#define PKT_STATUS        1
#define PKT_FIXPOS        2
#define PKT_FIXPOS_STATUS 3

// ========================= MODEM ============================
static bool nrziLevel = 0;
static uint8_t bitStuffCount = 0;
static uint16_t crc = 0xFFFF;

const float baud_adj = 0.985f;
const unsigned int tc1200 = (unsigned int)(0.5f * baud_adj * 1000000.0f / 1200.0f);
const unsigned int tc2200 = (unsigned int)(0.5f * baud_adj * 1000000.0f / 2200.0f);

// ========================= CONFIG ============================
char mycall[7] = "YD2XXX";
uint8_t myssid = 7;
char mystatus[100] = "Tracker Test Over ESP32 - Havid";

bool smartBeaconing = false;
unsigned long tx_interval = 60000UL;

char destCall[7] = "APRS";
uint8_t destSSID = 0;

char digi1[7] = "WIDE1";
uint8_t digissid1 = 1;
char digi2[7] = "WIDE2";
uint8_t digissid2 = 2;

char lat[9]  = "0000.00N";
char lon[10] = "00000.00E";

char dummy_lat[9]  = "0659.00S";
char dummy_lon[10] = "10649.00E";

char sym_ovl = '/';
char sym_tab = 'k';

// ========================= BUSY DETECT FROM SPEAKER AUDIO =========================
// 0 = OFF
// 1 = AUDIO SPEAKER DETECT (ADC)
uint8_t busyMode = 1;

// Threshold deviasi ADC terhadap center
uint16_t busyAdcThreshold = 140;

// durasi sampling audio untuk memutuskan BUSY
unsigned long busySampleWindowMs = 25;

// berapa lama status busy ditahan setelah burst audio hilang
unsigned long busyHangMs = 220;

// channel harus clear stabil selama ini sebelum TX
unsigned long busyClearHoldMs = 300;

// retry TX saat busy
unsigned long busyRetryMs = 5000;

// batas tunggu maksimum sebelum skip TX
unsigned long busyMaxWaitMs = 30000UL;

// jumlah sample untuk kalibrasi center ADC
uint16_t busyCenterCalSamples = 400;

// interval rekalibrasi center ketika channel clear
unsigned long busyRecalMs = 15000UL;

// ========================= RUNTIME ============================
bool gps_valid = false;
bool gps_locked = false;
float last_speed = 0.0f;

unsigned long last_tx_time = 0;
unsigned long pending_tx_since = 0;
bool pending_auto_tx = false;
bool pending_dummy_tx = false;
bool pending_manual_tx = false;

String gps_connection_status = "Not Connected";
String channel_busy_status = "UNKNOWN";

unsigned long last_gps_data_time = 0;
unsigned long last_status_print = 0;
unsigned long last_busy_retry_time = 0;
unsigned long lastBusyDetectedMs = 0;
unsigned long lastBusyCalMs = 0;

uint16_t busyAdcCenter = 2048;
uint16_t lastBusyPeak = 0;

// ========================= OBJECTS ============================
TinyGPSPlus gps;
HardwareSerial gpsSerial(1);
WebServer server(80);
Preferences prefs;

// ========================= UTIL ============================
void safeCopyUpper(char *dst, size_t dstSize, const String &src, size_t maxLen) {
  String s = src;
  s.trim();
  s.toUpperCase();
  if (s.length() > maxLen) s = s.substring(0, maxLen);
  s.toCharArray(dst, dstSize);
  dst[dstSize - 1] = '\0';
}

bool isValidBaseCall(const String &in) {
  if (in.length() < 1 || in.length() > 6) return false;
  for (size_t i = 0; i < in.length(); i++) {
    char c = in.charAt(i);
    if (!(isalnum((unsigned char)c))) return false;
  }
  return true;
}

bool isValidSSID(int v) {
  return v >= 0 && v <= 15;
}

bool isValidSymbolChar(char c) {
  return c >= 33 && c <= 126;
}

unsigned long getEffectiveInterval() {
  unsigned long effective_interval = tx_interval;

  if (smartBeaconing && gps_valid && gps.speed.isValid()) {
    last_speed = gps.speed.kmph();

    if (last_speed > 70.0f) effective_interval = tx_interval / 4;
    else if (last_speed > 30.0f) effective_interval = tx_interval / 2;
    else if (last_speed < 5.0f) effective_interval = tx_interval;

    if (effective_interval < 30000UL) effective_interval = 30000UL;
  }

  return effective_interval;
}

// ========================= AFSK TONES ============================
void tone1200() {
  digitalWrite(AFSK_PIN, HIGH);
  delayMicroseconds(tc1200);
  digitalWrite(AFSK_PIN, LOW);
  delayMicroseconds(tc1200);
}

void tone2200() {
  digitalWrite(AFSK_PIN, HIGH);
  delayMicroseconds(tc2200);
  digitalWrite(AFSK_PIN, LOW);
  delayMicroseconds(tc2200);
  digitalWrite(AFSK_PIN, HIGH);
  delayMicroseconds(tc2200);
  digitalWrite(AFSK_PIN, LOW);
  delayMicroseconds(tc2200);
}

void sendTone(bool level) {
  if (level) tone1200();
  else tone2200();
}

// ========================= CRC ============================
void crcReset() {
  crc = 0xFFFF;
}

void crcUpdate(bool bitIn) {
  unsigned short xor_in = crc ^ bitIn;
  crc >>= 1;
  if (xor_in & 0x01) crc ^= 0x8408;
}

void sendRawBit(bool bitVal, bool updateCrc, bool enableBitStuff) {
  if (updateCrc) crcUpdate(bitVal);

  if (bitVal) {
    sendTone(nrziLevel);
    if (enableBitStuff) {
      bitStuffCount++;
      if (bitStuffCount == 5) {
        nrziLevel ^= 1;
        sendTone(nrziLevel);
        bitStuffCount = 0;
      }
    }
  } else {
    nrziLevel ^= 1;
    sendTone(nrziLevel);
    bitStuffCount = 0;
  }
}

void sendByteNRZI(uint8_t b, bool updateCrc, bool enableBitStuff) {
  for (int i = 0; i < 8; i++) {
    bool bitVal = b & 0x01;
    sendRawBit(bitVal, updateCrc, enableBitStuff);
    b >>= 1;
  }
}

void sendFlag(uint8_t count) {
  for (uint8_t i = 0; i < count; i++) {
    sendByteNRZI(AX25_FLAG, false, false);
  }
}

void sendCRC() {
  uint8_t crc_lo = crc ^ 0xFF;
  uint8_t crc_hi = (crc >> 8) ^ 0xFF;
  sendByteNRZI(crc_lo, false, true);
  sendByteNRZI(crc_hi, false, true);
}

// ========================= AX.25 ADDRESS ============================
void sendAX25Address(const char *call, uint8_t ssid, bool last) {
  char padded[7] = {' ', ' ', ' ', ' ', ' ', ' ', '\0'};
  size_t len = strlen(call);
  if (len > 6) len = 6;

  for (size_t i = 0; i < len; i++) padded[i] = toupper((unsigned char)call[i]);

  for (int i = 0; i < 6; i++) {
    sendByteNRZI(((uint8_t)padded[i]) << 1, true, true);
  }

  uint8_t ssidByte = 0x60 | ((ssid & 0x0F) << 1);
  if (last) ssidByte |= 0x01;
  sendByteNRZI(ssidByte, true, true);
}

void sendHeader() {
  sendAX25Address(destCall, destSSID, false);
  sendAX25Address(mycall, myssid, false);
  sendAX25Address(digi1, digissid1, false);
  sendAX25Address(digi2, digissid2, true);
  sendByteNRZI(AX25_CTRL, true, true);
  sendByteNRZI(AX25_PID, true, true);
}

// ========================= PAYLOAD ============================
void sendStringAX25(const char *s) {
  while (*s) sendByteNRZI((uint8_t)*s++, true, true);
}

void sendPayload(uint8_t type, bool useDummy = false) {
  const char *useLat = useDummy ? dummy_lat : lat;
  const char *useLon = useDummy ? dummy_lon : lon;

  if (type == PKT_FIXPOS) {
    sendByteNRZI(DT_POS, true, true);
    sendStringAX25(useLat);
    sendByteNRZI(sym_ovl, true, true);
    sendStringAX25(useLon);
    sendByteNRZI(sym_tab, true, true);
  } else if (type == PKT_STATUS) {
    sendByteNRZI(DT_STATUS, true, true);
    sendStringAX25(mystatus);
  } else if (type == PKT_FIXPOS_STATUS) {
    sendByteNRZI(DT_POS, true, true);
    sendStringAX25(useLat);
    sendByteNRZI(sym_ovl, true, true);
    sendStringAX25(useLon);
    sendByteNRZI(sym_tab, true, true);
    sendByteNRZI(' ', true, true);
    sendStringAX25(mystatus);
  }
}

// ========================= DEBUG ============================
void printDebug(uint8_t type, bool useDummy = false) {
  const char *useLat = useDummy ? dummy_lat : lat;
  const char *useLon = useDummy ? dummy_lon : lon;

  Serial.print(mycall);
  Serial.print("-");
  Serial.print(myssid);
  Serial.print(">");
  Serial.print(destCall);
  Serial.print(",");
  Serial.print(digi1);
  Serial.print("-");
  Serial.print(digissid1);
  Serial.print(",");
  Serial.print(digi2);
  Serial.print("-");
  Serial.print(digissid2);
  Serial.print(":");

  if (type == PKT_FIXPOS) {
    Serial.print("!");
    Serial.print(useLat);
    Serial.print(sym_ovl);
    Serial.print(useLon);
    Serial.print(sym_tab);
  } else if (type == PKT_STATUS) {
    Serial.print(">");
    Serial.print(mystatus);
  } else if (type == PKT_FIXPOS_STATUS) {
    Serial.print("!");
    Serial.print(useLat);
    Serial.print(sym_ovl);
    Serial.print(useLon);
    Serial.print(sym_tab);
    Serial.print(" ");
    Serial.print(mystatus);
  }
  Serial.println();
}

// ========================= BUSY DETECT FROM SPEAKER AUDIO ============================
uint16_t readBusyPeak(unsigned long windowMs) {
  unsigned long start = millis();
  uint16_t peak = 0;

  while (millis() - start < windowMs) {
    int v = analogRead(BUSY_IN_PIN);
    int dev = abs(v - (int)busyAdcCenter);
    if ((uint16_t)dev > peak) peak = (uint16_t)dev;
    delayMicroseconds(200);
  }
  return peak;
}

void calibrateBusyCenter(uint16_t samples = 400) {
  uint32_t sum = 0;
  for (uint16_t i = 0; i < samples; i++) {
    sum += analogRead(BUSY_IN_PIN);
    delayMicroseconds(200);
  }
  busyAdcCenter = (uint16_t)(sum / samples);
}

bool isBusyAudioRaw() {
  lastBusyPeak = readBusyPeak(busySampleWindowMs);
  if (lastBusyPeak >= busyAdcThreshold) {
    lastBusyDetectedMs = millis();
    return true;
  }
  return false;
}

bool isChannelBusyRaw() {
  if (busyMode == 0) return false;

  if (isBusyAudioRaw()) return true;

  // hold/hang time supaya burst tidak langsung dianggap clear
  if (millis() - lastBusyDetectedMs < busyHangMs) return true;

  return false;
}

bool isChannelClearStable(unsigned long clearHoldMs) {
  unsigned long start = millis();
  while (millis() - start < clearHoldMs) {
    if (isChannelBusyRaw()) return false;
    delay(5);
  }
  return true;
}

bool canTransmitNow() {
  if (busyMode == 0) return true;
  if (isChannelBusyRaw()) return false;
  return isChannelClearStable(busyClearHoldMs);
}

void updateBusyStatusText() {
  if (busyMode == 0) {
    channel_busy_status = "DISABLED";
    return;
  }

  bool busy = isChannelBusyRaw();
  if (busy) channel_busy_status = "BUSY";
  else channel_busy_status = "CLEAR";
}

// ========================= TX ============================
void beginTX() {
  digitalWrite(STATUS_LED, HIGH);
  digitalWrite(PTT_PIN, HIGH);
  delay(120); // PTT attack delay
}

void endTX() {
  delay(50);  // tail delay
  digitalWrite(PTT_PIN, LOW);
  digitalWrite(STATUS_LED, LOW);
}

void sendPacket(uint8_t packetType, bool useDummy = false) {
  printDebug(packetType, useDummy);

  bitStuffCount = 0;
  nrziLevel = 0;

  beginTX();

  sendFlag(80);   // precarrier
  crcReset();
  sendHeader();
  sendPayload(packetType, useDummy);
  sendCRC();
  sendFlag(3);    // trailer

  endTX();
}

// ========================= GPS ============================
void updateCoordinates() {
  double lat_val = gps.location.lat();
  double lon_val = gps.location.lng();

  char lat_dir = (lat_val < 0) ? 'S' : 'N';
  char lon_dir = (lon_val < 0) ? 'W' : 'E';

  lat_val = fabs(lat_val);
  lon_val = fabs(lon_val);

  int lat_deg = (int)lat_val;
  int lon_deg = (int)lon_val;

  double lat_min = (lat_val - lat_deg) * 60.0;
  double lon_min = (lon_val - lon_deg) * 60.0;

  snprintf(lat, sizeof(lat), "%02d%05.2f%c", lat_deg, lat_min, lat_dir);
  snprintf(lon, sizeof(lon), "%03d%05.2f%c", lon_deg, lon_min, lon_dir);
}

String getGPSStatus() {
  if (gps_locked) return "Locked - Ready to Transmit";
  return "Not Locked - Not Ready";
}

// ========================= PREFERENCES ============================
void savePrefs() {
  prefs.begin("aprs_config", false);
  prefs.putString("callsign", mycall);
  prefs.putUChar("ssid", myssid);
  prefs.putString("status", mystatus);
  prefs.putBool("smartbeac", smartBeaconing);
  prefs.putULong("interval", tx_interval);
  prefs.putString("digi1", digi1);
  prefs.putUChar("digissid1", digissid1);
  prefs.putString("digi2", digi2);
  prefs.putUChar("digissid2", digissid2);
  prefs.putChar("symtab", sym_tab);
  prefs.putChar("symovl", sym_ovl);

  prefs.putUChar("busymode", busyMode);
  prefs.putUShort("busythr", busyAdcThreshold);
  prefs.putULong("busywin", busySampleWindowMs);
  prefs.putULong("busyhang", busyHangMs);
  prefs.putULong("busyhold", busyClearHoldMs);
  prefs.putULong("busyretry", busyRetryMs);
  prefs.putULong("busymaxw", busyMaxWaitMs);
  prefs.putUShort("busycents", busyCenterCalSamples);
  prefs.putULong("busyrecal", busyRecalMs);
  prefs.end();
}

void loadPrefs() {
  prefs.begin("aprs_config", true);

  String call = prefs.getString("callsign", "YD2XXX");
  safeCopyUpper(mycall, sizeof(mycall), call, 6);
  myssid = prefs.getUChar("ssid", 7);

  String stat = prefs.getString("status", "Tracker Test Over ESP32 - Havid");
  stat.toCharArray(mystatus, sizeof(mystatus));
  mystatus[sizeof(mystatus) - 1] = '\0';

  smartBeaconing = prefs.getBool("smartbeac", false);
  tx_interval = prefs.getULong("interval", 60000UL);

  String d1 = prefs.getString("digi1", "WIDE1");
  String d2 = prefs.getString("digi2", "WIDE2");
  safeCopyUpper(digi1, sizeof(digi1), d1, 6);
  safeCopyUpper(digi2, sizeof(digi2), d2, 6);

  digissid1 = prefs.getUChar("digissid1", 1);
  digissid2 = prefs.getUChar("digissid2", 2);

  sym_tab = prefs.getChar("symtab", 'k');
  sym_ovl = prefs.getChar("symovl", '/');

  busyMode = prefs.getUChar("busymode", 1);
  if (busyMode > 1) busyMode = 1;

  busyAdcThreshold = prefs.getUShort("busythr", 140);
  busySampleWindowMs = prefs.getULong("busywin", 25);
  busyHangMs = prefs.getULong("busyhang", 220);
  busyClearHoldMs = prefs.getULong("busyhold", 300);
  busyRetryMs = prefs.getULong("busyretry", 5000);
  busyMaxWaitMs = prefs.getULong("busymaxw", 30000UL);
  busyCenterCalSamples = prefs.getUShort("busycents", 400);
  busyRecalMs = prefs.getULong("busyrecal", 15000UL);

  prefs.end();
}

// ========================= WEB ============================
String getRootHtml() {
  String gpsColor = gps_locked ? "green" : "red";
  String connColor = (gps_connection_status == "Locked") ? "green" :
                     (gps_connection_status == "Searching") ? "orange" : "red";
  String busyColor = (channel_busy_status == "BUSY") ? "red" :
                     (channel_busy_status == "CLEAR") ? "green" : "gray";

  String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APRS Tracker ESP32</title>
<style>
body{font-family:Arial,sans-serif;background:#eef2f7;margin:0;padding:20px;color:#222}
.card{max-width:920px;margin:auto;background:#fff;border-radius:16px;padding:22px;box-shadow:0 8px 24px rgba(0,0,0,.08)}
h1{margin-top:0;color:#0b63ce}
h2{margin-top:24px;color:#0f172a;font-size:18px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:700px){.grid{grid-template-columns:1fr}}
label{display:block;font-weight:bold;margin:10px 0 6px}
input,select{width:100%;padding:11px;border:1px solid #d0d7e2;border-radius:10px;box-sizing:border-box}
button{padding:12px 16px;border:0;border-radius:10px;color:#fff;cursor:pointer;margin-top:12px;font-weight:bold}
.save{background:#16a34a}.tx{background:#2563eb}.dummy{background:#d97706}
.status{margin-top:18px;padding:16px;border-radius:12px;background:#f6f8fb}
small{color:#667;display:block;margin-top:4px}
</style>
<script>
function postAction(url){
  fetch(url,{method:'POST'})
    .then(r=>r.text().then(t=>({ok:r.ok,text:t})))
    .then(x=>alert(x.text))
    .catch(e=>alert('Error: '+e));
}
setInterval(()=>{
  const ae=document.activeElement;
  if(!ae || (ae.tagName!=='INPUT' && ae.tagName!=='SELECT' && ae.tagName!=='TEXTAREA')){
    location.reload();
  }
},5000);
</script>
</head>
<body>
<div class="card">
<h1>APRS Tracker ESP32</h1>

<form action="/save" method="POST">
<h2>APRS Settings</h2>
<div class="grid">
<div>
<label>Callsign Base</label>
<input name="callsign" maxlength="6" value=")rawliteral" + String(mycall) + R"rawliteral(">
</div>
<div>
<label>SSID</label>
<input name="ssid" type="number" min="0" max="15" value=")rawliteral" + String(myssid) + R"rawliteral(">
</div>

<div>
<label>Status / Comment</label>
<input name="comment" maxlength="99" value=")rawliteral" + String(mystatus) + R"rawliteral(">
</div>

<div>
<label>Interval (minutes)</label>
<select name="interval">
<option value="1" )rawliteral" + String(tx_interval == 60000UL ? "selected" : "") + R"rawliteral(>1</option>
<option value="2" )rawliteral" + String(tx_interval == 120000UL ? "selected" : "") + R"rawliteral(>2</option>
<option value="5" )rawliteral" + String(tx_interval == 300000UL ? "selected" : "") + R"rawliteral(>5</option>
<option value="10" )rawliteral" + String(tx_interval == 600000UL ? "selected" : "") + R"rawliteral(>10</option>
<option value="15" )rawliteral" + String(tx_interval == 900000UL ? "selected" : "") + R"rawliteral(>15</option>
</select>
</div>

<div>
<label>Digi 1</label>
<input name="digi1" maxlength="6" value=")rawliteral" + String(digi1) + R"rawliteral(">
</div>
<div>
<label>Digi1 SSID</label>
<input name="digissid1" type="number" min="0" max="15" value=")rawliteral" + String(digissid1) + R"rawliteral(">
</div>

<div>
<label>Digi 2</label>
<input name="digi2" maxlength="6" value=")rawliteral" + String(digi2) + R"rawliteral(">
</div>
<div>
<label>Digi2 SSID</label>
<input name="digissid2" type="number" min="0" max="15" value=")rawliteral" + String(digissid2) + R"rawliteral(">
</div>

<div>
<label>Symbol Table</label>
<input name="symtab" maxlength="1" value=")rawliteral" + String(sym_tab) + R"rawliteral(">
</div>
<div>
<label>Symbol Overlay / Table ID</label>
<input name="symovl" maxlength="1" value=")rawliteral" + String(sym_ovl) + R"rawliteral(">
</div>
</div>

<label style="margin-top:12px">
<input type="checkbox" name="smartbeaconing" )rawliteral" + String(smartBeaconing ? "checked" : "") + R"rawliteral(>
 Enable Smart Beaconing
</label>

<h2>Channel Busy dari Speaker Radio</h2>
<div class="grid">
<div>
<label>Busy Mode</label>
<select name="busymode">
<option value="0" )rawliteral" + String(busyMode == 0 ? "selected" : "") + R"rawliteral(>OFF</option>
<option value="1" )rawliteral" + String(busyMode == 1 ? "selected" : "") + R"rawliteral(>Speaker Audio Detect</option>
</select>
</div>

<div>
<label>ADC Threshold</label>
<input name="busythr" type="number" min="10" max="2000" value=")rawliteral" + String(busyAdcThreshold) + R"rawliteral(">
<small>Naikkan jika terlalu sensitif, turunkan jika tidak mendeteksi burst APRS</small>
</div>

<div>
<label>Sample Window (ms)</label>
<input name="busywin" type="number" min="5" max="200" value=")rawliteral" + String(busySampleWindowMs) + R"rawliteral(">
</div>

<div>
<label>Busy Hang (ms)</label>
<input name="busyhang" type="number" min="20" max="3000" value=")rawliteral" + String(busyHangMs) + R"rawliteral(">
</div>

<div>
<label>Clear Hold (ms)</label>
<input name="busyhold" type="number" min="50" max="5000" value=")rawliteral" + String(busyClearHoldMs) + R"rawliteral(">
</div>

<div>
<label>Retry Delay (ms)</label>
<input name="busyretry" type="number" min="500" max="60000" value=")rawliteral" + String(busyRetryMs) + R"rawliteral(">
</div>

<div>
<label>Max Wait Before Skip TX (ms)</label>
<input name="busymaxw" type="number" min="1000" max="300000" value=")rawliteral" + String(busyMaxWaitMs) + R"rawliteral(">
</div>

<div>
<label>Center Calibration Samples</label>
<input name="busycents" type="number" min="50" max="3000" value=")rawliteral" + String(busyCenterCalSamples) + R"rawliteral(">
</div>

<div>
<label>Recalibration Interval (ms)</label>
<input name="busyrecal" type="number" min="1000" max="120000" value=")rawliteral" + String(busyRecalMs) + R"rawliteral(">
</div>
</div>

<button class="save" type="submit">Save Settings</button>
</form>

<button class="tx" onclick="postAction('/transmit')">Manual Transmit</button>
<button class="dummy" onclick="postAction('/dummy_transmit')">TX Dummy Data</button>

<div class="status">
<p><b>Latitude:</b> )rawliteral" + String(lat) + R"rawliteral(</p>
<p><b>Longitude:</b> )rawliteral" + String(lon) + R"rawliteral(</p>
<p><b>GPS Connection:</b> <span style="color:)rawliteral" + connColor + R"rawliteral(;">)rawliteral" + gps_connection_status + R"rawliteral(</span></p>
<p><b>GPS Status:</b> <span style="color:)rawliteral" + gpsColor + R"rawliteral(;">)rawliteral" + getGPSStatus() + R"rawliteral(</span></p>
<p><b>Channel Busy:</b> <span style="color:)rawliteral" + busyColor + R"rawliteral(;">)rawliteral" + channel_busy_status + R"rawliteral(</span></p>
<p><b>ADC Center:</b> )rawliteral" + String(busyAdcCenter) + R"rawliteral(</p>
<p><b>Last Peak:</b> )rawliteral" + String(lastBusyPeak) + R"rawliteral(</p>
<p><b>Path:</b> )rawliteral" + String(digi1) + "-" + String(digissid1) + "," + String(digi2) + "-" + String(digissid2) + R"rawliteral(</p>
</div>
</div>
</body>
</html>
)rawliteral";
  return html;
}

void handleRoot() {
  updateBusyStatusText();
  server.send(200, "text/html", getRootHtml());
}

void handleSave() {
  if (server.hasArg("callsign")) {
    String s = server.arg("callsign");
    s.trim();
    s.toUpperCase();
    if (isValidBaseCall(s)) safeCopyUpper(mycall, sizeof(mycall), s, 6);
  }

  if (server.hasArg("ssid")) {
    int v = server.arg("ssid").toInt();
    if (isValidSSID(v)) myssid = v;
  }

  if (server.hasArg("comment")) {
    String s = server.arg("comment");
    if (s.length() > 99) s = s.substring(0, 99);
    s.toCharArray(mystatus, sizeof(mystatus));
    mystatus[sizeof(mystatus) - 1] = '\0';
  }

  if (server.hasArg("interval")) {
    int minVal = server.arg("interval").toInt();
    if (minVal >= 1 && minVal <= 60) tx_interval = (unsigned long)minVal * 60000UL;
  }

  if (server.hasArg("digi1")) {
    String s = server.arg("digi1");
    s.trim();
    s.toUpperCase();
    if (isValidBaseCall(s)) safeCopyUpper(digi1, sizeof(digi1), s, 6);
  }

  if (server.hasArg("digissid1")) {
    int v = server.arg("digissid1").toInt();
    if (isValidSSID(v)) digissid1 = v;
  }

  if (server.hasArg("digi2")) {
    String s = server.arg("digi2");
    s.trim();
    s.toUpperCase();
    if (isValidBaseCall(s)) safeCopyUpper(digi2, sizeof(digi2), s, 6);
  }

  if (server.hasArg("digissid2")) {
    int v = server.arg("digissid2").toInt();
    if (isValidSSID(v)) digissid2 = v;
  }

  if (server.hasArg("symtab")) {
    String s = server.arg("symtab");
    if (s.length() >= 1 && isValidSymbolChar(s.charAt(0))) sym_tab = s.charAt(0);
  }

  if (server.hasArg("symovl")) {
    String s = server.arg("symovl");
    if (s.length() >= 1 && isValidSymbolChar(s.charAt(0))) sym_ovl = s.charAt(0);
  }

  smartBeaconing = server.hasArg("smartbeaconing");

  if (server.hasArg("busymode")) {
    int v = server.arg("busymode").toInt();
    if (v >= 0 && v <= 1) busyMode = v;
  }

  if (server.hasArg("busythr")) {
    int v = server.arg("busythr").toInt();
    if (v >= 10 && v <= 2000) busyAdcThreshold = v;
  }

  if (server.hasArg("busywin")) {
    unsigned long v = strtoul(server.arg("busywin").c_str(), nullptr, 10);
    if (v >= 5 && v <= 200) busySampleWindowMs = v;
  }

  if (server.hasArg("busyhang")) {
    unsigned long v = strtoul(server.arg("busyhang").c_str(), nullptr, 10);
    if (v >= 20 && v <= 3000) busyHangMs = v;
  }

  if (server.hasArg("busyhold")) {
    unsigned long v = strtoul(server.arg("busyhold").c_str(), nullptr, 10);
    if (v >= 50 && v <= 5000) busyClearHoldMs = v;
  }

  if (server.hasArg("busyretry")) {
    unsigned long v = strtoul(server.arg("busyretry").c_str(), nullptr, 10);
    if (v >= 500 && v <= 60000) busyRetryMs = v;
  }

  if (server.hasArg("busymaxw")) {
    unsigned long v = strtoul(server.arg("busymaxw").c_str(), nullptr, 10);
    if (v >= 1000 && v <= 300000) busyMaxWaitMs = v;
  }

  if (server.hasArg("busycents")) {
    int v = server.arg("busycents").toInt();
    if (v >= 50 && v <= 3000) busyCenterCalSamples = v;
  }

  if (server.hasArg("busyrecal")) {
    unsigned long v = strtoul(server.arg("busyrecal").c_str(), nullptr, 10);
    if (v >= 1000 && v <= 120000) busyRecalMs = v;
  }

  savePrefs();
  calibrateBusyCenter(busyCenterCalSamples);

  server.sendHeader("Location", "/");
  server.send(303);
}

void handleTransmit() {
  if (!gps_valid) {
    server.send(400, "text/plain", "No valid GPS data");
    return;
  }

  if (canTransmitNow()) {
    sendPacket(PKT_FIXPOS_STATUS, false);
    last_tx_time = millis();
    pending_manual_tx = false;
    server.send(200, "text/plain", "Packet transmitted");
  } else {
    pending_manual_tx = true;
    pending_tx_since = millis();
    last_busy_retry_time = 0;
    server.send(200, "text/plain", "Channel busy from speaker audio, manual TX queued");
  }
}

void handleDummyTransmit() {
  if (canTransmitNow()) {
    sendPacket(PKT_FIXPOS_STATUS, true);
    last_tx_time = millis();
    pending_dummy_tx = false;
    server.send(200, "text/plain", "Dummy packet transmitted");
  } else {
    pending_dummy_tx = true;
    pending_tx_since = millis();
    last_busy_retry_time = 0;
    server.send(200, "text/plain", "Channel busy from speaker audio, dummy TX queued");
  }
}

// ========================= SETUP ============================
void setup() {
  pinMode(AFSK_PIN, OUTPUT);
  pinMode(PTT_PIN, OUTPUT);
  pinMode(STATUS_LED, OUTPUT);
  pinMode(BUSY_IN_PIN, INPUT);

  analogReadResolution(12);
  analogSetPinAttenuation(BUSY_IN_PIN, ADC_11db);

  digitalWrite(AFSK_PIN, LOW);
  digitalWrite(PTT_PIN, LOW);
  digitalWrite(STATUS_LED, LOW);

  Serial.begin(115200);
  delay(500);
  Serial.println();
  Serial.println("APRS Tracker ESP32 starting with speaker-audio busy detect...");

  gpsSerial.begin(9600, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);

  loadPrefs();
  calibrateBusyCenter(busyCenterCalSamples);
  lastBusyCalMs = millis();

  Serial.print("Callsign: "); Serial.println(mycall);
  Serial.print("SSID: "); Serial.println(myssid);
  Serial.print("Status: "); Serial.println(mystatus);
  Serial.print("Path: "); Serial.print(digi1); Serial.print("-"); Serial.print(digissid1);
  Serial.print(","); Serial.print(digi2); Serial.print("-"); Serial.println(digissid2);
  Serial.print("Busy Mode: "); Serial.println(busyMode);
  Serial.print("Busy Threshold: "); Serial.println(busyAdcThreshold);
  Serial.print("Busy ADC Center: "); Serial.println(busyAdcCenter);

  WiFi.softAP("APRS-Tracker", "12345678");
  Serial.print("AP IP: ");
  Serial.println(WiFi.softAPIP());

  server.on("/", HTTP_GET, handleRoot);
  server.on("/save", HTTP_POST, handleSave);
  server.on("/transmit", HTTP_POST, handleTransmit);
  server.on("/dummy_transmit", HTTP_POST, handleDummyTransmit);
  server.begin();

  Serial.println("Web server started");
}

// ========================= LOOP ============================
void loop() {
  server.handleClient();

  bool data_received = false;
  while (gpsSerial.available() > 0) {
    data_received = true;
    gps.encode(gpsSerial.read());
  }

  unsigned long now = millis();

  if (data_received) last_gps_data_time = now;

  if (now - last_gps_data_time > 3000) {
    gps_connection_status = "Not Connected";
  } else if (!gps_locked) {
    gps_connection_status = "Searching";
  } else {
    gps_connection_status = "Locked";
  }

  gps_locked = gps.location.isValid() && gps.satellites.isValid() && gps.satellites.value() >= 3;
  gps_valid = gps_locked;

  if (gps.location.isUpdated() && gps.location.isValid()) {
    updateCoordinates();
  }

  // Recalibrate center only when clear and not transmitting/queueing
  if (busyMode == 1 && (now - lastBusyCalMs >= busyRecalMs)) {
    bool noPending = !(pending_manual_tx || pending_dummy_tx || pending_auto_tx);
    if (noPending && !isChannelBusyRaw()) {
      calibrateBusyCenter(busyCenterCalSamples);
      lastBusyCalMs = now;
    }
  }

  updateBusyStatusText();

  // ================= AUTO TX WITH CHANNEL BUSY =================
  unsigned long effective_interval = getEffectiveInterval();

  if (gps_valid && !pending_auto_tx && (now - last_tx_time >= effective_interval)) {
    pending_auto_tx = true;
    pending_tx_since = now;
    last_busy_retry_time = 0;
  }

  // ================= PROCESS QUEUED TX =================
  bool havePending = pending_manual_tx || pending_dummy_tx || pending_auto_tx;

  if (havePending) {
    if (canTransmitNow()) {
      if (pending_dummy_tx) {
        sendPacket(PKT_FIXPOS_STATUS, true);
        pending_dummy_tx = false;
      } else if (pending_manual_tx) {
        sendPacket(PKT_FIXPOS_STATUS, false);
        pending_manual_tx = false;
      } else if (pending_auto_tx && gps_valid) {
        sendPacket(PKT_FIXPOS_STATUS, false);
        pending_auto_tx = false;
      }

      last_tx_time = millis();
      pending_tx_since = 0;
      last_busy_retry_time = 0;
    } else {
      if (now - pending_tx_since >= busyMaxWaitMs) {
        Serial.println("TX skipped: channel busy too long");
        pending_manual_tx = false;
        pending_dummy_tx = false;
        pending_auto_tx = false;
        pending_tx_since = 0;
        last_busy_retry_time = 0;
        last_tx_time = now;
      } else if (now - last_busy_retry_time >= busyRetryMs) {
        Serial.print("Channel busy from speaker audio, peak=");
        Serial.print(lastBusyPeak);
        Serial.print(", center=");
        Serial.println(busyAdcCenter);
        last_busy_retry_time = now;
      }
    }
  }

  // ================= STATUS PRINT =================
  if (now - last_status_print >= 5000) {
    Serial.print("GPS Connection: ");
    Serial.println(gps_connection_status);

    Serial.print("GPS Locked: ");
    Serial.println(gps_locked ? "YES" : "NO");

    Serial.print("Satellites: ");
    if (gps.satellites.isValid()) Serial.println(gps.satellites.value());
    else Serial.println("N/A");

    Serial.print("Lat/Lon: ");
    Serial.print(lat);
    Serial.print(" / ");
    Serial.println(lon);

    Serial.print("Channel Busy Status: ");
    Serial.println(channel_busy_status);

    Serial.print("ADC Center: ");
    Serial.println(busyAdcCenter);

    Serial.print("Last Peak: ");
    Serial.println(lastBusyPeak);

    last_status_print = now;
  }
}
</code></pre>

Post a Comment