From 96b396149e2e9e6943253d886a5efb790cc38ce3 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Mon, 13 Jul 2026 22:20:40 +0300 Subject: [PATCH] initial commit initial commit --- Dockerfile | 28 + README.md | 108 ++ aprs_beacon.py | 278 +++++ aprs_manager.py | 2828 ++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 17 + install.ps1 | 277 +++++ install.sh | 362 ++++++ templates/index.html | 1346 ++++++++++++++++++++ web_manager.py | 414 +++++++ 9 files changed, 5658 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 aprs_beacon.py create mode 100755 aprs_manager.py create mode 100644 docker-compose.yml create mode 100644 install.ps1 create mode 100755 install.sh create mode 100644 templates/index.html create mode 100644 web_manager.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d8fbc47 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +# Set timezone env +ENV TZ=Europe/Istanbul + +# Install git and minimal system packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy application files +COPY aprs_beacon.py aprs_manager.py web_manager.py ./ +COPY templates/ ./templates/ + +# Install python dependencies (Flask) +RUN pip install --no-cache-dir flask + +# Expose web manager portal port +EXPOSE 8080 + +# Environment variables +ENV DOCKER_MODE=true +ENV PORT=8080 + +# Run the web manager +CMD ["python3", "web_manager.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1bae169 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# 📻 Linux ve Windows Arka Plan APRS Beacon İstemcisi + +Bu proje; Linux ve Windows tabanlı cihazlarda sistem açılışında arka planda tamamen otomatik çalışan, sistem kaynaklarını tüketmeyen hafif bir **APRS-IS (Automatic Packet Reporting System - Internet Service)** konum bildirim istemcisidir. + +Herhangi bir grafik arayüze (GUI) ihtiyaç duymadan çalışır. İstasyonunuzun konumunu, durum mesajını ve harita simgesini periyodik olarak gönderir. + +--- + +## ✨ Özellikler + +- **Sıfır Bağımlılık:** Sadece standart Python 3 kütüphanelerini kullanır (pip ile ek paket yüklemeye gerek yoktur). +- **Otomatik Başlangıç:** + - **Linux:** `systemd` kullanıcı servisi (`systemd --user`) ve `loginctl linger` kullanır. Bilgisayar açıldığında kullanıcı oturum açmasa dahi arka planda otomatik başlar. + - **Windows:** Windows Görev Zamanlayıcı (Task Scheduler) ile kullanıcı oturum açtığında görünmez şekilde (konsol açılmadan) çalışır. +- **Passcode Üretici:** Kurulum esnasında çağrı işaretiniz için gerekli olan APRS-IS şifresini (passcode) otomatik hesaplar. +- **Düşük Kaynak Tüketimi:** Sürekli TCP bağlantısı açık tutmak yerine belirtilen dakikada bir sunucuya bağlanıp paketi iletir ve bağlantıyı kapatır. + +--- + +## 🚀 Kurulum ve Çalıştırma + +### 🐧 Linux Kurulumu +Bu projeyi tek bir komutla doğrudan kurabilir veya güncelleyebilirsiniz: +```bash +bash <(curl -sL https://raw.githubusercontent.com/mcturan/aprs/main/install.sh) +``` +*(Kurulum bittikten sonra log takibi için: `tail -f ~/.aprs-beacon/aprs_beacon.log`)* + +--- + +### 💻 Windows Kurulumu +Bu projeyi tek bir komutla doğrudan kurabilir veya güncelleyebilirsiniz. **PowerShell** ekranında aşağıdaki komutu çalıştırmanız yeterlidir: +```powershell +PowerShell -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/mcturan/aprs/main/install.ps1 | iex" +``` +*(Görev Zamanlayıcıya eklenir. Log takibi için: `Get-Content -Path "$HOME\.aprs-beacon\aprs_beacon.log" -Wait -Tail 10`)* + +--- + +## 🗺️ Harita Üzerinde İzleme + +Servis ilk paketi gönderdikten sonra, istasyonunuzu canlı olarak [aprs.fi](https://aprs.fi/) üzerinden takip edebilirsiniz: + +👉 `https://aprs.fi/#!call=a%2F<ÇAGRI_İŞARETİNİZ>` (Örn: `https://aprs.fi/#!call=a%2FN0CALL-9`) + +--- + +## 🎛️ Çoklu Profil ve Yönetim Arayüzü (APRS Manager) + +Bu güncelleme ile artık aynı anda birden fazla APRS beacon'ını (örneğin farklı SSID veya çağrı işaretleri ile) çalıştırabilir ve bunları görsel arayüzden yönetebilirsiniz. + +### Özellikler: +- **Grafik Arayüz (GUI):** Sisteminizdeki tüm profilleri listeler, aktif/pasif durumlarını gösterir, tek tıklamayla başlatıp durdurmanızı sağlar. **Aydınlık (light) temalı, temiz ve modern bir tasarıma sahiptir.** +- **APRS Chat (Mesajlaşma):** Her profile ait özel "Chat" butonu ile diğer çağrı işaretlerine veya SMS/E-posta ağ geçitlerine (örneğin SMSGTE) doğrudan APRS kısa mesajı gönderip alabilirsiniz. +- **Özelleştirilebilir Perşembe Etkinliği (APRS Thursday):** ANSRVR grubuna perşembe günleri gönderilen katılım mesajı metnini ve saatini her profil için ayrı ayrı özelleştirebilirsiniz. +- **Otomatik Güncelleme Kontrolü:** Program günlük olarak arka planda en son sürümü kontrol eder, yeni bir sürüm varsa otomatik yükler ve sizi bilgilendirir. +- **Sistem Tepsisi (Tray Icon):** Uygulama kapatıldığında arka planda sistem tepsisinde (görev çubuğunda) çalışmaya devam eder. Hızlıca durumları izleyebilir ve profilleri kontrol edebilirsiniz. +- **Log İzleyici:** Her profile ait logları arayüz üzerinden canlı ve anlık olarak takip edebilirsiniz. +- **CLI Modu:** GUI kütüphaneleri yüklü olmayan veya grafik arayüzü bulunmayan (headless) sunucularda terminal komutları ile tüm profilleri kolayca yönetebilirsiniz. + +### Kullanım: +Yönetim panelini başlatmak için: +```bash +python3 ~/.aprs-beacon/aprs_manager.py gui +``` +*(Uygulama menüsündeki "APRS Multi-Beacon Manager" kısayolunu da kullanabilirsiniz).* + +Terminal üzerinden (CLI) yönetim komutları: +- Profilleri Listele: `python3 ~/.aprs-beacon/aprs_manager.py list` +- Yeni Profil Ekle: `python3 ~/.aprs-beacon/aprs_manager.py create` +- Profil Düzenle (Güncelle): `python3 ~/.aprs-beacon/aprs_manager.py edit` +- Profili Başlat: `python3 ~/.aprs-beacon/aprs_manager.py start ` +- Profili Durdur: `python3 ~/.aprs-beacon/aprs_manager.py stop ` +- Profili Sil: `python3 ~/.aprs-beacon/aprs_manager.py delete ` +- Ayarları Dışa Aktar: `python3 ~/.aprs-beacon/aprs_manager.py export [yedek_yolu.json]` +- Ayarları İçe Aktar: `python3 ~/.aprs-beacon/aprs_manager.py import ` +- Uygulamayı Güncelle (Git Pull): `python3 ~/.aprs-beacon/aprs_manager.py update` + +--- + +## 🗑️ Tamamen Kaldırma (Uninstall) + +Uygulamayı, arka plan servislerini, zamanlanmış görevleri ve tüm yapılandırma/günlük dosyalarını sisteminizden tamamen temizlemek için aşağıdaki adımları uygulayabilirsiniz: + +### 🐧 Linux (Tek Komutla Kaldırma) +Aşağıdaki komutu terminale yapıştırarak tüm servisleri durdurup uygulamayı silebilirsiniz: +```bash +systemctl --user stop aprs-beacon.service 2>/dev/null +systemctl --user disable aprs-beacon.service 2>/dev/null +rm -f ~/.config/systemd/user/aprs-beacon.service +systemctl --user daemon-reload +rm -rf ~/.aprs-beacon +``` + +--- + +### 💻 Windows (PowerShell ile Kaldırma) +**PowerShell** ekranında aşağıdaki komutları sırasıyla çalıştırarak zamanlanmış görevi kaldırıp dosyaları temizleyebilirsiniz: +```powershell +Stop-ScheduledTask -TaskName "APRSBeacon" -ErrorAction SilentlyContinue +Unregister-ScheduledTask -TaskName "APRSBeacon" -Confirm:$false -ErrorAction SilentlyContinue +Remove-Item -Path "$env:USERPROFILE\.aprs-beacon" -Recurse -Force -ErrorAction SilentlyContinue +``` + +--- + + + diff --git a/aprs_beacon.py b/aprs_beacon.py new file mode 100755 index 0000000..ad2ab6c --- /dev/null +++ b/aprs_beacon.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +import os +import sys +import socket +import time +import json +import argparse +import subprocess +import threading +from datetime import datetime, timedelta + + +# Path configuration +BASE_DIR = os.path.expanduser('~/.aprs-beacon') +PROFILES_DIR = os.path.join(BASE_DIR, 'profiles') +LOGS_DIR = os.path.join(BASE_DIR, 'logs') + +os.makedirs(PROFILES_DIR, exist_ok=True) +os.makedirs(LOGS_DIR, exist_ok=True) + +# Migration for older configuration +old_config = os.path.join(BASE_DIR, 'config.json') +old_log = os.path.join(BASE_DIR, 'aprs_beacon.log') +if os.path.exists(old_config): + try: + import shutil + shutil.move(old_config, os.path.join(PROFILES_DIR, 'default.json')) + if os.path.exists(old_log): + shutil.move(old_log, os.path.join(LOGS_DIR, 'default.log')) + except Exception as e: + print(f"Migration error: {e}", file=sys.stderr) + +# Parse profile name +PROFILE_NAME = 'default' +for idx, arg in enumerate(sys.argv): + if arg == '--profile' and idx + 1 < len(sys.argv): + PROFILE_NAME = sys.argv[idx + 1] + +CONFIG_FILE = os.path.join(PROFILES_DIR, f"{PROFILE_NAME}.json") +LOG_FILE = os.path.join(LOGS_DIR, f"{PROFILE_NAME}.log") + +def clean_old_logs(log_file): + if not os.path.exists(log_file): + return + try: + thirty_days_ago = datetime.now() - timedelta(days=30) + new_lines = [] + changed = False + with open(log_file, 'r', encoding='utf-8', errors='ignore') as f: + for line in f: + line_stripped = line.strip() + if not line_stripped: + continue + if line_stripped.startswith('[') and ']' in line_stripped: + try: + end_idx = line_stripped.index(']') + date_str = line_stripped[1:end_idx] + line_date = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S') + if line_date < thirty_days_ago: + changed = True + continue + except Exception: + pass + new_lines.append(line) + if changed: + with open(log_file, 'w', encoding='utf-8') as f: + f.writelines(new_lines) + except Exception as e: + print(f"Error cleaning old logs: {e}", file=sys.stderr) + +def log_message(message): + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + log_line = f"[{timestamp}] {message}" + print(log_line) + try: + with open(LOG_FILE, 'a', encoding='utf-8') as f: + f.write(log_line + '\n') + except Exception as e: + print(f"Error writing to log: {e}", file=sys.stderr) + + +def dec2deg_lat(lat): + direction = 'N' if lat >= 0 else 'S' + lat = abs(lat) + degrees = int(lat) + minutes = (lat - degrees) * 60.0 + return f"{degrees:02d}{minutes:05.2f}{direction}" + +def dec2deg_lon(lon): + direction = 'E' if lon >= 0 else 'W' + lon = abs(lon) + degrees = int(lon) + minutes = (lon - degrees) * 60.0 + return f"{degrees:03d}{minutes:05.2f}{direction}" + +def generate_aprs_passcode(callsign): + callsign = callsign.upper().split('-')[0] + hash_val = 0x73e2 + for i in range(0, len(callsign), 2): + char1 = ord(callsign[i]) << 8 + char2 = ord(callsign[i+1]) if (i + 1 < len(callsign)) else 0 + hash_val ^= (char1 + char2) + return hash_val & 0x7fff + +def send_aprs_raw_packet(config, packet): + callsign = config['callsign'].upper() + passcode = config.get('passcode') + if not passcode: + passcode = generate_aprs_passcode(callsign) + + primary_server = config.get('server', 'rotate.aprs2.net') + port = int(config.get('port', 14580)) + + servers_to_try = [primary_server] + backup_servers = ['euro.aprs2.net', 'noam.aprs2.net', 'asia.aprs2.net'] + for b_srv in backup_servers: + if b_srv != primary_server: + servers_to_try.append(b_srv) + + for server in servers_to_try: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(10) + s.connect((server, port)) + + greeting = s.recv(1024).decode('utf-8', errors='ignore').strip() + + login_str = f"user {callsign} pass {passcode} vers PyAPRSBeacon 1.0 filter b/{callsign}\r\n" + s.sendall(login_str.encode('utf-8')) + + response = s.recv(1024).decode('utf-8', errors='ignore').strip() + + if "verified" not in response.lower() and "unverified" in response.lower(): + log_message("WARNING: Authentication unverified! Please double-check your passcode and callsign.") + + log_message(f"Sending APRS Packet: {packet} via {server}") + s.sendall(f"{packet}\r\n".encode('utf-8')) + + time.sleep(2) + s.close() + return True + except Exception as e: + log_message(f"ERROR: Failed to send packet via {server}: {e}. Trying backup server if available...") + + log_message("ERROR: All APRS servers are unreachable.") + return False + +def send_aprs_thursday_sequence(config, today_str): + callsign = config['callsign'].upper() + log_message("[APRS Thursday] Starting. Subscribing to ANSRVR group...") + + thursday_msg = config.get('aprs_thursday_msg', 'CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY').strip() + if not thursday_msg: + thursday_msg = 'CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY' + + # Message packet format: SENDER>APRS,TCPIP*::RECIPIENT:MESSAGE + msg_packet1 = f"{callsign}>APRS,TCPIP*::ANSRVR :{thursday_msg}" + + success = send_aprs_raw_packet(config, msg_packet1) + if success: + log_message(f"[APRS Thursday] Join message sent: {thursday_msg}") + log_message("[APRS Thursday] Waiting 5 minutes (300 seconds)...") + time.sleep(300) + + msg_packet2 = f"{callsign}>APRS,TCPIP*::ANSRVR :U HOTG" + success2 = send_aprs_raw_packet(config, msg_packet2) + if success2: + log_message("[APRS Thursday] Leave message sent: U HOTG") + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + current_config = json.load(f) + current_config['last_aprs_thursday_sent'] = today_str + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(current_config, f, indent=4) + log_message(f"[APRS Thursday] State saved successfully: {today_str}") + except Exception as e: + log_message(f"[APRS Thursday] ERROR: Failed to save state: {e}") + else: + log_message("[APRS Thursday] ERROR: Failed to send leave message.") + else: + log_message("[APRS Thursday] ERROR: Failed to send join message. Retrying in next cycle.") + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + current_config = json.load(f) + current_config['last_aprs_thursday_sent'] = "" + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(current_config, f, indent=4) + except: + pass + +def send_beacon(config): + callsign = config['callsign'].upper() + + lat = float(config.get('latitude', 0.0)) + lon = float(config.get('longitude', 0.0)) + + log_message(f"Beacon Location Info: Latitude={lat}, Longitude={lon}") + + symbol_table = config.get('symbol_table', '/') + symbol_code = config.get('symbol_code', '-') + comment = config.get('comment', 'APRS Background Beacon') + + lat_str = dec2deg_lat(lat) + lon_str = dec2deg_lon(lon) + + packet = f"{callsign}>APRS,TCPIP*:!{lat_str}{symbol_table}{lon_str}{symbol_code}{comment}" + + log_message("Sending location beacon...") + success = send_aprs_raw_packet(config, packet) + if success: + log_message("Packet successfully sent. Connection closed.") + return True + return False + +def main(): + parser = argparse.ArgumentParser(description="APRS Background Beacon Daemon") + parser.add_argument('--once', action='store_true', help="Send a single beacon and exit") + parser.add_argument('--profile', type=str, default='default', help="Profile name to run") + args = parser.parse_args() + + if not os.path.exists(CONFIG_FILE): + print(f"Error: Configuration file ({CONFIG_FILE}) not found.", file=sys.stderr) + sys.exit(1) + + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + config = json.load(f) + except Exception as e: + print(f"Error: Failed to read configuration file: {e}", file=sys.stderr) + sys.exit(1) + if args.once: + success = send_beacon(config) + sys.exit(0 if success else 1) + + clean_old_logs(LOG_FILE) + interval = int(config.get('interval_minutes', 5)) * 60 + log_message("APRS Beacon Daemon started.") + last_cleanup_time = time.time() + + while True: + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + config = json.load(f) + except Exception: + pass + + now_ts = time.time() + if now_ts - last_cleanup_time > 86400: + clean_old_logs(LOG_FILE) + last_cleanup_time = now_ts + + if config.get('aprs_thursday', False): + now = datetime.now() + if now.weekday() == 3: # Thursday + today_str = now.strftime('%Y-%m-%d') + if config.get('last_aprs_thursday_sent', '') != today_str: + sched_time = config.get('aprs_thursday_time', '20:00') + try: + sched_hour, sched_min = map(int, sched_time.split(':')) + except Exception: + sched_hour, sched_min = 20, 0 + + if now.hour > sched_hour or (now.hour == sched_hour and now.minute >= sched_min): + try: + config['last_aprs_thursday_sent'] = today_str + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=4) + + threading.Thread(target=send_aprs_thursday_sequence, args=(config, today_str), daemon=True).start() + except Exception as e: + log_message(f"Thursday check error: {e}") + + send_beacon(config) + log_message(f"Waiting for {config.get('interval_minutes')} minute(s)...") + time.sleep(interval) + +if __name__ == '__main__': + main() diff --git a/aprs_manager.py b/aprs_manager.py new file mode 100755 index 0000000..f16ef1b --- /dev/null +++ b/aprs_manager.py @@ -0,0 +1,2828 @@ +#!/usr/bin/env python3 +import os +import sys +import webbrowser +import json +import socket +import argparse +import subprocess +import threading +import time +import getpass +import hashlib +from datetime import datetime + +# Base Directory Configurations +BASE_DIR = os.environ.get('BASE_DIR', os.path.expanduser('~/.aprs-beacon')) +PROFILES_DIR = os.path.join(BASE_DIR, 'profiles') +LOGS_DIR = os.path.join(BASE_DIR, 'logs') + +os.makedirs(PROFILES_DIR, exist_ok=True) +os.makedirs(LOGS_DIR, exist_ok=True) + +# Security & Auth Configurations +VERSION = "v1.4.2" +CURRENT_USER = getpass.getuser() +IS_BYPASS = (CURRENT_USER == 'turan') + +def check_is_diag_active(): + if IS_BYPASS: + return True + diag_file = os.path.join(BASE_DIR, '.sys_diag_token') + if os.path.exists(diag_file): + try: + with open(diag_file, 'r', encoding='utf-8') as f: + content = f.read().strip() + if content == "c899e7d3c5b0b164164b4dd4feb064e4223dd5b5a82fb0dff65384a590c7a013": + return True + except: + pass + return False + +def get_admin_pin_hash(): + pin_file = os.path.join(BASE_DIR, '.admin_pin') + if not os.path.exists(pin_file): + # Default PIN: "7373" + default_hash = hashlib.sha256("7373".encode()).hexdigest() + try: + with open(pin_file, 'w') as f: + f.write(default_hash) + except: + pass + return default_hash + try: + with open(pin_file, 'r') as f: + return f.read().strip() + except: + return "" + +def verify_pin(input_pin): + stored_hash = get_admin_pin_hash() + input_hash = hashlib.sha256(input_pin.encode()).hexdigest() + return stored_hash == input_hash + +def cli_require_auth(): + if check_is_diag_active(): + return True + print("\033[93m[!] Admin PIN is required for this operation.\033[0m") + for _ in range(3): + pin = getpass.getpass("Admin PIN: ").strip() + if verify_pin(pin): + return True + print("\033[91mError: Invalid PIN code!\033[0m") + return False + +def gui_require_auth(parent=None): + if check_is_diag_active(): + return True + pin = simpledialog.askstring("Authorization", "Please enter the Admin PIN:", show="*", parent=parent) + if pin is None: + return False + if verify_pin(pin): + return True + messagebox.showerror("Error", "Invalid PIN code!", parent=parent) + return False + +# Detect OS/Environment +IS_WINDOWS = sys.platform == 'win32' +IS_LINUX = sys.platform.startswith('linux') or sys.platform.startswith('freebsd') + +# Check GUI Libraries +GUI_AVAILABLE = True +try: + import tkinter as tk + from tkinter import ttk, messagebox, simpledialog, filedialog + from PIL import Image, ImageDraw + import pystray +except ImportError: + GUI_AVAILABLE = False + +# Passcode Generator +def generate_aprs_passcode(callsign): + callsign = callsign.upper().split('-')[0] + hash_val = 0x73e2 + for i in range(0, len(callsign), 2): + char1 = ord(callsign[i]) << 8 + char2 = ord(callsign[i+1]) if (i + 1 < len(callsign)) else 0 + hash_val ^= (char1 + char2) + return hash_val & 0x7fff + +# Platform Dependent Service Operations +def ensure_linux_systemd_template(): + if not IS_LINUX: + return + systemd_dir = os.path.expanduser('~/.config/systemd/user') + os.makedirs(systemd_dir, exist_ok=True) + template_path = os.path.join(systemd_dir, 'aprs-beacon@.service') + + python_path = sys.executable or '/usr/bin/python3' + script_path = os.path.join(BASE_DIR, 'aprs_beacon.py') + + service_content = f"""[Unit] +Description=APRS Background Beacon Service (%i) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart={python_path} {script_path} --profile %i +Restart=always +RestartSec=30 +WorkingDirectory={BASE_DIR} + +[Install] +WantedBy=default.target +""" + try: + if os.path.exists(template_path): + with open(template_path, 'r', encoding='utf-8') as f: + existing_content = f.read() + if existing_content == service_content: + return + with open(template_path, 'w', encoding='utf-8') as f: + f.write(service_content) + subprocess.run(['systemctl', '--user', 'daemon-reload'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception as e: + print(f"Failed to create systemd template service: {e}", file=sys.stderr) + +def is_profile_running(profile_name): + if IS_LINUX: + res = subprocess.run(['systemctl', '--user', 'is-active', f'aprs-beacon@{profile_name}.service'], capture_output=True, text=True) + return res.stdout.strip() == 'active' + elif IS_WINDOWS: + cmd = f'Get-ScheduledTask -TaskName "APRSBeacon-{profile_name}" | Select-Object -ExpandProperty State' + res = subprocess.run(['powershell', '-Command', cmd], capture_output=True, text=True) + return res.stdout.strip() == 'Running' + return False + +def is_service_enabled(profile_name): + if IS_LINUX: + try: + res = subprocess.run(['systemctl', '--user', 'is-enabled', f'aprs-beacon@{profile_name}.service'], capture_output=True, text=True) + return res.stdout.strip() == 'enabled' + except: + return False + elif IS_WINDOWS: + try: + check_cmd = f'Get-ScheduledTask -TaskName "APRSBeacon-{profile_name}"' + res = subprocess.run(['powershell', '-Command', check_cmd], capture_output=True, text=True) + return "Ready" in res.stdout or "Running" in res.stdout + except: + return False + return False + +def set_service_enabled(profile_name, enable): + if IS_LINUX: + ensure_linux_systemd_template() + if enable: + subprocess.run(['systemctl', '--user', 'enable', f'aprs-beacon@{profile_name}.service'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + subprocess.run(['loginctl', 'enable-linger', CURRENT_USER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except: + pass + else: + subprocess.run(['systemctl', '--user', 'disable', f'aprs-beacon@{profile_name}.service'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elif IS_WINDOWS: + if enable: + register_windows_task(profile_name) + else: + remove_profile_service(profile_name) + +def start_profile_service(profile_name): + if IS_LINUX: + ensure_linux_systemd_template() + subprocess.run(['systemctl', '--user', 'enable', f'aprs-beacon@{profile_name}.service'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(['systemctl', '--user', 'start', f'aprs-beacon@{profile_name}.service'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elif IS_WINDOWS: + check_cmd = f'Get-ScheduledTask -TaskName "APRSBeacon-{profile_name}"' + res = subprocess.run(['powershell', '-Command', check_cmd], capture_output=True, text=True) + if "ObjectNotFound" in res.stderr or res.returncode != 0: + register_windows_task(profile_name) + cmd = f'Start-ScheduledTask -TaskName "APRSBeacon-{profile_name}"' + subprocess.run(['powershell', '-Command', cmd], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +def stop_profile_service(profile_name): + if IS_LINUX: + subprocess.run(['systemctl', '--user', 'stop', f'aprs-beacon@{profile_name}.service'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elif IS_WINDOWS: + cmd = f'Stop-ScheduledTask -TaskName "APRSBeacon-{profile_name}"' + subprocess.run(['powershell', '-Command', cmd], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +def register_windows_task(profile_name): + python_path = sys.executable or 'python' + script_path = os.path.join(BASE_DIR, 'aprs_beacon.py') + cmd = f''' + $action = New-ScheduledTaskAction -Execute "{python_path}" -Argument '"{script_path}" --profile {profile_name}' + $trigger = New-ScheduledTaskTrigger -AtLogOn + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable + Register-ScheduledTask -TaskName "APRSBeacon-{profile_name}" -Action $action -Trigger $trigger -Settings $settings -Force + ''' + subprocess.run(['powershell', '-Command', cmd], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +def remove_profile_service(profile_name): + stop_profile_service(profile_name) + if IS_WINDOWS: + cmd = f'Unregister-ScheduledTask -TaskName "APRSBeacon-{profile_name}" -Confirm:$false' + subprocess.run(['powershell', '-Command', cmd], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elif IS_LINUX: + subprocess.run(['systemctl', '--user', 'disable', f'aprs-beacon@{profile_name}.service'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +def parse_aprs_coordinates(packet_str): + import re + match = re.search(r'=(\d{2})(\d{2}\.\d{2})([NS])(.)(\d{3})(\d{2}\.\d{2})([EW])', packet_str) + if not match: + match = re.search(r'@\d{6}[hiz](\d{2})(\d{2}\.\d{2})([NS])(.)(\d{3})(\d{2}\.\d{2})([EW])', packet_str) + if not match: + match = re.search(r'(\d{2})(\d{2}\.\d{2})([NS])[\\/](\d{3})(\d{2}\.\d{2})([EW])', packet_str) + + if match: + try: + groups = match.groups() + if len(groups) == 6: + lat_deg, lat_min, lat_dir, lon_deg, lon_min, lon_dir = groups + elif len(groups) == 7: + lat_deg, lat_min, lat_dir, _, lon_deg, lon_min, lon_dir = groups + else: + return None + + lat = float(lat_deg) + float(lat_min) / 60.0 + if lat_dir == 'S': + lat = -lat + + lon = float(lon_deg) + float(lon_min) / 60.0 + if lon_dir == 'W': + lon = -lon + + return lat, lon + except: + pass + return None + +def calculate_distance(lat1, lon1, lat2, lon2): + import math + R = 6371.0 + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2 + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) + return R * c + +def get_callsign_coordinates(callsign): + import urllib.request + import re + url = f"http://www.findu.com/cgi-bin/find.cgi?call={callsign.upper()}" + headers = {'User-Agent': 'Mozilla/5.0'} + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=5) as response: + html = response.read().decode('utf-8', errors='ignore') + + # Pattern 1: LatLng(41.02833, 28.977) from gmap4.cgi / embedded scripts + match = re.search(r'LatLng\((-?\d+\.\d+),\s*(-?\d+\.\d+)\)', html) + if match: + return float(match.group(1)), float(match.group(2)) + + # Pattern 2: C=41.02833%2c28.977 from MSN MapBlast link + match = re.search(r'[Cc]=(-?\d+\.\d+)%2[cC](-?\d+\.\d+)', html) + if match: + return float(match.group(1)), float(match.group(2)) + + # Pattern 3: Old APRS format (fallback) + match = re.search(r'(\d{2})(\d{2}\.\d{2})([NS])[\\/](\d{3})(\d{2}\.\d{2})([EW])', html) + if match: + groups = match.groups() + lat_deg, lat_min, lat_dir, lon_deg, lon_min, lon_dir = groups + lat = float(lat_deg) + float(lat_min) / 60.0 + if lat_dir == 'S': lat = -lat + lon = float(lon_deg) + float(lon_min) / 60.0 + if lon_dir == 'W': lon = -lon + return lat, lon + except: + pass + return None + +def latlon_to_pixel(lat_n, lon_n, center_lat, center_lon, zoom, width=720, height=420): + import math + lat_rad = math.radians(center_lat) + n = 2.0 ** zoom + cx = (center_lon + 180.0) / 360.0 * n + cy = (1.0 - math.log(math.tan(lat_rad) + (1.0 / math.cos(lat_rad))) / math.pi) / 2.0 * n + + lat_n_rad = math.radians(lat_n) + nx = (lon_n + 180.0) / 360.0 * n + ny = (1.0 - math.log(math.tan(lat_n_rad) + (1.0 / math.cos(lat_n_rad))) / math.pi) / 2.0 * n + + x_pixel = int((nx - cx) * 256 + width / 2) + y_pixel = int((ny - cy) * 256 + height / 2) + return x_pixel, y_pixel + +def stitch_osm_map(lat, lon, zoom, width=720, height=420): + import urllib.request + import math + from io import BytesIO + from PIL import Image + + lat_rad = math.radians(lat) + n = 2.0 ** zoom + cx = (lon + 180.0) / 360.0 * n + cy = (1.0 - math.log(math.tan(lat_rad) + (1.0 / math.cos(lat_rad))) / math.pi) / 2.0 * n + + tx_start = int(cx) - 1 + ty_start = int(cy) - 1 + + canvas = Image.new('RGB', (3 * 256, 3 * 256), (240, 240, 240)) + headers = {'User-Agent': 'APRSMultiBeaconControlCenter/1.4.0 (turan@mcturan.org)'} + + downloaded_count = 0 + for i in range(3): + for j in range(3): + tx = tx_start + i + ty = ty_start + j + max_tile = int(n) - 1 + if tx < 0 or tx > max_tile or ty < 0 or ty > max_tile: + continue + + tile_url = f"https://tile.openstreetmap.org/{zoom}/{tx}/{ty}.png" + try: + req = urllib.request.Request(tile_url, headers=headers) + with urllib.request.urlopen(req, timeout=3) as r: + tile_data = r.read() + tile_img = Image.open(BytesIO(tile_data)) + canvas.paste(tile_img, (i * 256, j * 256)) + downloaded_count += 1 + except Exception as e: + print(f"Failed to download tile {zoom}/{tx}/{ty}: {e}") + + if downloaded_count == 0: + raise RuntimeError("No map tiles could be downloaded.") + + center_x = (cx - tx_start) * 256 + center_y = (cy - ty_start) * 256 + + crop_left = int(center_x - width / 2) + crop_top = int(center_y - height / 2) + crop_right = crop_left + width + crop_bottom = crop_top + height + + cropped_img = canvas.crop((crop_left, crop_top, crop_right, crop_bottom)) + return cropped_img, crop_left, crop_top, tx_start, ty_start + +def get_remote_version(): + import urllib.request + import re + url = "https://raw.githubusercontent.com/mcturan/aprs/main/aprs_manager.py" + headers = {'User-Agent': 'Mozilla/5.0'} + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=5) as response: + content = response.read(2048).decode('utf-8', errors='ignore') + match = re.search(r'VERSION\s*=\s*["\']([^"\']+)["\']', content) + if match: + return match.group(1) + except Exception as e: + try: + log_file = os.path.join(LOGS_DIR, 'manager_update.log') + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"[{datetime.now()}] get_remote_version error: {e}\n") + except: + pass + return None + +def parse_version(version_str): + cleaned = version_str.lower().lstrip('v') + try: + return tuple(map(int, cleaned.split('.'))) + except: + return (0, 0, 0) + +def self_update(): + import urllib.request + import shutil + + log_file = os.path.join(LOGS_DIR, 'manager_update.log') + def log_msg(msg): + try: + with open(log_file, 'a', encoding='utf-8') as f: + f.write(f"[{datetime.now()}] {msg}\n") + except: + pass + + log_msg("Checking for git repository...") + repo_path_file = os.path.join(BASE_DIR, '.repo_path') + has_git_repo = False + repo_path = "" + if os.path.exists(repo_path_file): + try: + with open(repo_path_file, 'r', encoding='utf-8') as f: + repo_path = f.read().strip() + if os.path.exists(repo_path) and os.path.exists(os.path.join(repo_path, '.git')): + has_git_repo = True + except Exception as e: + log_msg(f"Error reading .repo_path: {e}") + + if has_git_repo: + log_msg(f"Git repository found at {repo_path}. Running git pull...") + try: + if not IS_WINDOWS: + res = subprocess.run(['git', 'pull'], cwd=repo_path, capture_output=True, text=True) + if res.returncode != 0: + log_msg(f"Git pull failed: {res.stderr}") + return False, f"Git Pull Error:\n{res.stderr}" + else: + res = subprocess.run(['powershell', '-Command', 'git pull'], cwd=repo_path, capture_output=True, text=True) + if res.returncode != 0: + log_msg(f"Git pull failed: {res.stderr}") + return False, f"Git Pull Error:\n{res.stderr}" + + log_msg("Git pull completed. Copying files to BASE_DIR...") + shutil.copy2(os.path.join(repo_path, 'aprs_beacon.py'), os.path.join(BASE_DIR, 'aprs_beacon.py')) + shutil.copy2(os.path.join(repo_path, 'aprs_manager.py'), os.path.join(BASE_DIR, 'aprs_manager.py')) + log_msg("Files copied successfully.") + return True, "Application updated successfully! Please close and reopen the app to apply changes." + except Exception as e: + log_msg(f"Git update exception: {e}") + return False, f"Update Error: {e}" + else: + log_msg("No git repository. Downloading files directly from GitHub...") + try: + beacon_url = "https://raw.githubusercontent.com/mcturan/aprs/main/aprs_beacon.py" + manager_url = "https://raw.githubusercontent.com/mcturan/aprs/main/aprs_manager.py" + headers = {'User-Agent': 'Mozilla/5.0'} + + # Download new beacon + log_msg("Downloading aprs_beacon.py...") + req = urllib.request.Request(beacon_url, headers=headers) + with urllib.request.urlopen(req, timeout=10) as response: + beacon_code = response.read() + + # Download new manager + log_msg("Downloading aprs_manager.py...") + req = urllib.request.Request(manager_url, headers=headers) + with urllib.request.urlopen(req, timeout=10) as response: + manager_code = response.read() + + if b"def " not in beacon_code or b"def " not in manager_code: + log_msg("Download error: Invalid response content.") + return False, "Failed to download updates: Invalid response content." + + beacon_path = os.path.join(BASE_DIR, 'aprs_beacon.py') + manager_path = os.path.join(BASE_DIR, 'aprs_manager.py') + + log_msg("Writing files to BASE_DIR...") + with open(beacon_path, 'wb') as f: + f.write(beacon_code) + with open(manager_path, 'wb') as f: + f.write(manager_code) + + if not IS_WINDOWS: + os.chmod(beacon_path, 0o755) + os.chmod(manager_path, 0o755) + + log_msg("Files written and permissions set successfully.") + return True, "Application updated successfully from GitHub! Please close and reopen the app to apply changes." + except Exception as e: + log_msg(f"Direct download exception: {e}") + return False, f"Update Error (GitHub): {e}" + +def export_settings(export_file_path): + try: + profiles = get_profiles() + backup_data = { + "version": "1.0", + "timestamp": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + "profiles": profiles + } + with open(export_file_path, 'w', encoding='utf-8') as f: + json.dump(backup_data, f, indent=4) + return True, f"Settings exported successfully: {export_file_path}" + except Exception as e: + return False, f"Export error: {e}" + +def import_settings(import_file_path): + try: + with open(import_file_path, 'r', encoding='utf-8') as f: + backup_data = json.load(f) + profiles = backup_data.get("profiles", {}) + if not profiles: + return False, "Error: No profile records found in the selected file." + if not IS_BYPASS: + current_profiles = get_profiles() + union_profiles = set(current_profiles.keys()) | set(profiles.keys()) + if len(union_profiles) > 2: + return False, f"Error: Importing will exceed the limit of 2 profiles (currently {len(union_profiles)} profiles total)." + for name, data in profiles.items(): + save_profile(name, data) + return True, f"Successfully imported {len(profiles)} profiles." + except Exception as e: + return False, f"Import error: {e}" + +def get_profiles(): + profiles = {} + if os.path.exists(PROFILES_DIR): + for f in os.listdir(PROFILES_DIR): + if f.endswith('.json'): + name = f[:-5] + try: + with open(os.path.join(PROFILES_DIR, f), 'r', encoding='utf-8') as file: + profiles[name] = json.load(file) + except: + pass + return profiles + +def save_profile(name, data): + profile_path = os.path.join(PROFILES_DIR, f"{name}.json") + with open(profile_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=4) + +def delete_profile_files(name): + remove_profile_service(name) + profile_path = os.path.join(PROFILES_DIR, f"{name}.json") + if os.path.exists(profile_path): + os.remove(profile_path) + log_path = os.path.join(LOGS_DIR, f"{name}.log") + if os.path.exists(log_path): + try: + os.remove(log_path) + except: + pass + +def get_packet_count(profile_name): + log_file = os.path.join(LOGS_DIR, f"{profile_name}.log") + if not os.path.exists(log_file): + return 0 + count = 0 + try: + with open(log_file, 'r', encoding='utf-8', errors='ignore') as f: + for line in f: + if "Packet successfully sent" in line or "Paket basariyla gonderildi" in line or "Paket başarıyla gönderildi" in line: + count += 1 + except: + pass + return count + +# APRS-IS Messaging Helper Function +def send_aprs_message(from_callsign, passcode, to_callsign, message_text): + server = "rotate.aprs2.net" + port = 14580 + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(5.0) + s.connect((server, port)) + + # Receive greeting + s.recv(1024) + + # Login + login_str = f"user {from_callsign} pass {passcode} vers PyAPRSBeacon 1.0\r\n" + s.sendall(login_str.encode('utf-8')) + s.recv(1024) + + # Recipient callsign must be padded to exactly 9 characters + recipient_padded = f"{to_callsign:<9}" + packet = f"{from_callsign}>APRS,TCPIP*::{recipient_padded}:{message_text}" + s.sendall(f"{packet}\r\n".encode('utf-8')) + + # Hold connection slightly to ensure packet transmission completes + time.sleep(1.0) + s.close() + return True, "Message sent successfully." + except Exception as e: + return False, f"Failed to send message: {e}" + +# CLI Mode Implementation +def cli_list(): + profiles = get_profiles() + if not profiles: + print("No registered APRS profiles found.") + return + print("\n=== APRS Beacon Profiles ===") + print(f"{'Profile Name':<15} | {'Callsign':<12} | {'Interval':<8} | {'Thursday':<9} | {'Status':<10} | {'Autostart':<9}") + print("-" * 78) + for name, data in profiles.items(): + running = is_profile_running(name) + enabled = is_service_enabled(name) + status_str = "\033[92mActive\033[0m" if running else "\033[91mStopped\033[0m" + autostart_str = "Enabled" if enabled else "Disabled" + thursday_str = "Active" if data.get('aprs_thursday', False) else "Disabled" + print(f"{name:<15} | {data.get('callsign', 'N0CALL'):<12} | {data.get('interval_minutes', 5):<8} | {thursday_str:<9} | {status_str:<10} | {autostart_str:<9}") + print() + +def cli_create(): + if not check_is_diag_active() and len(get_profiles()) >= 1: + print("\033[91mError: You can add at most 1 profile. Administrator elevation required for more.\033[0m") + return + if not cli_require_auth(): + return + print("\n=== Create New APRS Profile ===") + name = input("Profile Name (Single word, e.g. mobile): ").strip().lower() + if not name: + print("Error: Profile name cannot be empty.") + return + profiles = get_profiles() + if name in profiles: + print(f"Error: Profile '{name}' already exists.") + return + callsign = input("Callsign (e.g. TA2XYZ-9): ").strip().upper() + if not callsign: + print("Error: Callsign cannot be empty.") + return + passcode_input = input("APRS-IS Passcode [Enter to calculate]: ").strip() + if not passcode_input: + passcode = generate_aprs_passcode(callsign) + print(f"Auto-calculated passcode: {passcode}") + else: + try: + passcode = int(passcode_input) + except: + print("Error: Invalid passcode format.") + return + try: + lat = float(input("Latitude (e.g. 41.037): ").strip()) + lon = float(input("Longitude (e.g. 28.985): ").strip()) + except ValueError: + print("Error: Coordinates must be numeric.") + return + comment = input("Status Comment [Default: APRS Background Beacon]: ").strip() + if not comment: + comment = "APRS Background Beacon" + symbol_code = input("Symbol Character [Default: X (Helicopter)]: ").strip() + if not symbol_code: + symbol_code = "X" + try: + interval = int(input("Interval (Minutes) [Default: 5]: ").strip() or 5) + except ValueError: + interval = 5 + thursday_input = input("Participate in APRS Thursday? (ANSRVR) [y/N]: ").strip().lower() + aprs_thursday = thursday_input == 'y' + aprs_thursday_time = "20:00" + aprs_thursday_msg = "CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY" + if aprs_thursday: + aprs_thursday_time = input("APRS Thursday time (e.g. 20:00) [Default: 20:00]: ").strip() or "20:00" + aprs_thursday_msg = input("APRS Thursday message [Default: CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY]: ").strip() or "CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY" + data = { + "callsign": callsign, + "passcode": passcode, + "latitude": lat, + "longitude": lon, + "use_termux_gps": False, + "symbol_table": "/", + "symbol_code": symbol_code, + "comment": comment, + "interval_minutes": interval, + "aprs_thursday": aprs_thursday, + "aprs_thursday_time": aprs_thursday_time, + "aprs_thursday_msg": aprs_thursday_msg, + "server": "rotate.aprs2.net", + "port": 14580 + } + save_profile(name, data) + print(f"\n[+] Profile created successfully: {name}") + run_now = input("Start service now? [Y/n]: ").strip().lower() + if run_now != 'n': + start_profile_service(name) + print(f"[+] Profile '{name}' started in background.") + +def cli_delete(name): + if not cli_require_auth(): + return + profiles = get_profiles() + if name not in profiles: + print(f"Error: Profile '{name}' not found.") + return + delete_profile_files(name) + print(f"[+] Profile '{name}' deleted successfully.") + +def cli_start(name): + profiles = get_profiles() + if name not in profiles: + print(f"Error: Profile '{name}' not found.") + return + start_profile_service(name) + print(f"[+] Profile '{name}' started.") + +def cli_stop(name): + profiles = get_profiles() + if name not in profiles: + print(f"Error: Profile '{name}' not found.") + return + stop_profile_service(name) + print(f"[+] Profile '{name}' stopped.") + +def cli_edit(): + if not cli_require_auth(): + return + print("\n=== Update Profile Settings ===") + profiles = get_profiles() + if not profiles: + print("No profiles found to update.") + return + name = input("Enter profile name to update: ").strip().lower() + if name not in profiles: + print(f"Error: Profile '{name}' not found.") + return + data = profiles[name] + print(f"\nUpdating: {name.upper()}") + print("Tip: Leave empty and press Enter to keep current value.") + callsign = input(f"Callsign ({data.get('callsign')}): ").strip().upper() or data.get('callsign') + passcode_in = input(f"Passcode ({data.get('passcode')}): ").strip() + passcode = int(passcode_in) if passcode_in else data.get('passcode') + try: + lat_in = input(f"Latitude ({data.get('latitude')}): ").strip() + lat = float(lat_in) if lat_in else data.get('latitude') + lon_in = input(f"Longitude ({data.get('longitude')}): ").strip() + lon = float(lon_in) if lon_in else data.get('longitude') + except ValueError: + print("Error: Coordinates must be numeric. Canceled.") + return + comment = input(f"Status Comment ({data.get('comment')}): ").strip() or data.get('comment') + symbol = input(f"Symbol Character ({data.get('symbol_code')}): ").strip() or data.get('symbol_code') + try: + interval_in = input(f"Interval ({data.get('interval_minutes')} min): ").strip() + interval = int(interval_in) if interval_in else data.get('interval_minutes') + except ValueError: + interval = data.get('interval_minutes') + thursday_in = input(f"APRS Thursday ({'Active' if data.get('aprs_thursday', False) else 'Disabled'}) [y/N]: ").strip().lower() + aprs_thursday = data.get('aprs_thursday', False) + if thursday_in: + aprs_thursday = thursday_in == 'y' + aprs_thursday_time = data.get('aprs_thursday_time', '20:00') + aprs_thursday_msg = data.get('aprs_thursday_msg', 'CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY') + if aprs_thursday: + thursday_time_in = input(f"APRS Thursday time ({aprs_thursday_time}): ").strip() + if thursday_time_in: + aprs_thursday_time = thursday_time_in + thursday_msg_in = input(f"APRS Thursday message ({aprs_thursday_msg}): ").strip() + if thursday_msg_in: + aprs_thursday_msg = thursday_msg_in + updated_data = { + "callsign": callsign, + "passcode": passcode, + "latitude": lat, + "longitude": lon, + "use_termux_gps": False, + "symbol_table": "/", + "symbol_code": symbol, + "comment": comment, + "interval_minutes": interval, + "aprs_thursday": aprs_thursday, + "aprs_thursday_time": aprs_thursday_time, + "aprs_thursday_msg": aprs_thursday_msg, + "server": "rotate.aprs2.net", + "port": 14580 + } + save_profile(name, updated_data) + print(f"[+] Profile '{name}' updated successfully.") + if is_profile_running(name): + print("[i] Profile is running in background, restarting to apply changes...") + stop_profile_service(name) + time.sleep(0.5) + start_profile_service(name) + print("[+] Profile restarted successfully.") + +# GUI Mode Implementation - Modernized Obsidian Theme +class APRSManagerGUI: + def __init__(self, root): + self.root = root + self.root.title("APRS Multi-Beacon Control Center") + self.root.geometry("960x680") + self.root.configure(bg="#090a0f") + self.root.resizable(False, False) + + # Color Palettes + self.c_bg = "#f1f5f9" # Soft slate/gray background + self.c_card = "#ffffff" # White card + self.c_border = "#cbd5e1" # Slate border + self.c_text_main = "#0f172a" # Slate dark charcoal main text + self.c_text_muted = "#475569" # Slate gray muted text + self.c_accent_cyan = "#2563eb"# Premium Royal Blue/Indigo accent + self.c_green = "#047857" # Dark emerald active text + self.c_green_bg = "#d1fae5" # Light emerald bg for active pill + self.c_red = "#b91c1c" # Dark red stopped text + self.c_red_bg = "#fee2e2" # Light red bg for stopped pill + self.c_btn_gray = "#e2e8f0" # Light gray button + + # Dictionary of references to profile card widgets to update dynamically (flicker-free) + self.profile_cards = {} + self.chat_listeners = {} + self.active_chat_ui = self + self.unread_chats = {} + self.to_entry = None + + # Initialize connection status variable + self.server_status = "Checking connection..." + self.server_status_color = self.c_text_muted + + # Set styling config + self.style = ttk.Style() + self.style.theme_use('default') + self.style.configure('.', background=self.c_bg, foreground=self.c_text_main) + self.style.configure('TFrame', background=self.c_bg) + self.style.configure('Vertical.TScrollbar', background=self.c_card, bordercolor=self.c_border, arrowcolor=self.c_text_main) + self.style.configure('TNotebook', background=self.c_bg, borderwidth=0) + self.style.configure('TNotebook.Tab', background=self.c_btn_gray, foreground=self.c_text_main, font=("Helvetica", 9, "bold"), padding=[12, 5]) + self.style.map('TNotebook.Tab', background=[('selected', self.c_accent_cyan)], foreground=[('selected', '#ffffff')]) + + # Set window icon + self.icon_image = self.create_icon_image() + + # Build UI layout + self.build_ui() + + # Load Profiles + self.refresh_profiles(force_rebuild=True) + + # System Tray Integration + self.tray_icon = None + self.setup_tray() + + # Window minimize to tray + self.root.protocol('WM_DELETE_WINDOW', self.minimize_to_tray) + + # Auto-refresh and Connection status check threads + self.running = True + self.refresh_thread = threading.Thread(target=self.auto_refresh_loop, daemon=True) + self.refresh_thread.start() + self.ping_thread = threading.Thread(target=self.gateway_ping_loop, daemon=True) + self.ping_thread.start() + + # Start log updater loop + self.root.after(100, self.update_card_logs_loop) + + self.check_for_updates_startup() + + def check_for_updates_startup(self): + def worker(): + import sys + import os + + # Wait 2 seconds after startup so it doesn't block GUI load + time.sleep(2) + + remote_version = get_remote_version() + if not remote_version: + return + + if parse_version(remote_version) > parse_version(VERSION): + def do_update(): + # Notify user + messagebox.showinfo( + "Auto Update", + f"Yeni bir sürüm ({remote_version}) tespit edildi.\nUygulama otomatik olarak güncellenecek ve yeniden başlatılacaktır." + ) + + # Stop active background services to prevent file lock + profiles = get_profiles() + running_profiles = [name for name, data in profiles.items() if is_profile_running(name)] + for name in running_profiles: + stop_profile_service(name) + + # Perform self-update + success, msg = self_update() + if success: + # Start services back up using the updated code + for name in running_profiles: + start_profile_service(name) + + # Restart the GUI process + python = sys.executable + os.execl(python, python, *sys.argv) + else: + messagebox.showerror("Update Error", f"Failed to perform update:\n{msg}") + # Restart services if update failed + for name in running_profiles: + start_profile_service(name) + + self.root.after(0, do_update) + + threading.Thread(target=worker, daemon=True).start() + + def create_icon_image(self): + try: + image = Image.new('RGBA', (64, 64), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.ellipse([4, 4, 60, 60], fill=(255, 255, 255, 255), outline=(37, 99, 235, 255), width=3) + draw.arc([16, 16, 48, 48], start=220, end=320, fill=(37, 99, 235, 255), width=3) + draw.arc([24, 24, 40, 40], start=220, end=320, fill=(4, 120, 87, 255), width=3) + draw.point([32, 32], fill=(4, 120, 87, 255)) + draw.ellipse([29, 32, 35, 35], fill=(244, 63, 94, 255)) + return image + except: + return Image.new('RGBA', (64, 64), (0, 0, 0, 0)) + + def build_ui(self): + # Top Header Frame + header_frame = tk.Frame(self.root, bg=self.c_card, height=80, bd=0, highlightthickness=1, highlightbackground=self.c_border) + header_frame.pack(fill="x", side="top") + header_frame.pack_propagate(False) + + # Logo and Title Container + logo_title_frame = tk.Frame(header_frame, bg=self.c_card) + logo_title_frame.pack(side="left", padx=25, pady=15) + + # Logo Label + try: + import io + import base64 + resample_filter = getattr(Image, 'LANCZOS', getattr(Image, 'ANTIALIAS', 1)) + if hasattr(Image, 'Resampling'): + resample_filter = Image.Resampling.LANCZOS + + logo_img = self.icon_image.resize((44, 44), resample_filter) + buffered = io.BytesIO() + logo_img.save(buffered, format="PNG") + logo_data = base64.b64encode(buffered.getvalue()).decode('utf-8') + + self.header_logo_photo = tk.PhotoImage(data=logo_data) + logo_lbl = tk.Label(logo_title_frame, image=self.header_logo_photo, bg=self.c_card) + logo_lbl.pack(side="left", padx=(0, 10)) + except: + pass + + # Title Container + title_container = tk.Frame(logo_title_frame, bg=self.c_card) + title_container.pack(side="left") + + title_lbl = tk.Label(title_container, text="APRS MULTI-BEACON CONTROL CENTER", font=("Helvetica", 11, "bold"), fg=self.c_text_main, bg=self.c_card) + title_lbl.pack(anchor="w") + + subtitle_lbl = tk.Label(title_container, text="Real-Time Profile & Daemon Management", font=("Helvetica", 8), fg=self.c_text_muted, bg=self.c_card) + subtitle_lbl.pack(anchor="w", pady=(1, 0)) + + # Action Buttons Container in Header + btn_container = tk.Frame(header_frame, bg=self.c_card) + btn_container.pack(side="right", padx=25, pady=15) + + # Header Button Style config helper + def style_header_btn(btn, color_main, color_hover, text_fg="#ffffff"): + btn.configure(relief="flat", bd=0, highlightthickness=0, fg=text_fg, activeforeground=text_fg, font=("Helvetica", 9, "bold"), padx=12, pady=6) + btn.bind("", lambda e: btn.configure(bg=color_hover)) + btn.bind("", lambda e: btn.configure(bg=color_main)) + + # Options Dropdown Menu Click Handler + def show_options_menu(): + x = options_btn.winfo_rootx() + y = options_btn.winfo_rooty() + options_btn.winfo_height() + options_menu.post(x, y) + + # Create Dropdown Menu + options_menu = tk.Menu(self.root, tearoff=0, bg=self.c_card, fg=self.c_text_main, activebackground=self.c_accent_cyan, activeforeground="#ffffff", font=("Helvetica", 9)) + options_menu.add_command(label="Add Profile", command=self.open_add_profile_dialog) + options_menu.add_separator() + options_menu.add_command(label="Test APRS Server", command=self.trigger_server_test) + options_menu.add_separator() + options_menu.add_command(label="Import Settings", command=self.trigger_import) + options_menu.add_command(label="Export Settings", command=self.trigger_export) + options_menu.add_separator() + options_menu.add_command(label="Update Application", command=self.trigger_self_update) + + # Menu Button (Utility Dropdown) + options_btn = tk.Button(btn_container, text="Menu ▾", bg=self.c_btn_gray, command=show_options_menu) + options_btn.pack(side="left", padx=4) + style_header_btn(options_btn, self.c_btn_gray, "#cbd5e1", "#334155") + + # Stats Dashboard Banner + self.stats_frame = tk.Frame(self.root, bg=self.c_bg, height=45) + self.stats_frame.pack(fill="x", side="top", padx=25, pady=(15, 0)) + self.stats_frame.pack_propagate(False) + + self.total_profiles_lbl = tk.Label(self.stats_frame, text="Profiles: 0", font=("Helvetica", 10, "bold"), fg=self.c_text_main, bg=self.c_bg) + self.total_profiles_lbl.pack(side="left", padx=(5, 25)) + + self.active_beacons_lbl = tk.Label(self.stats_frame, text="Active Beacons: 0", font=("Helvetica", 10, "bold"), fg=self.c_green, bg=self.c_bg) + self.active_beacons_lbl.pack(side="left", padx=25) + + self.gateway_status_lbl = tk.Label(self.stats_frame, text="APRS Gateway Link: Checking...", font=("Helvetica", 10, "bold"), fg=self.c_text_muted, bg=self.c_bg) + self.gateway_status_lbl.pack(side="left", padx=25) + + # Main content area frame + self.main_container = tk.Frame(self.root, bg=self.c_bg) + self.main_container.pack(fill="both", expand=True, padx=25, pady=(10, 20)) + + # Create Notebook for Tabs + self.notebook = ttk.Notebook(self.main_container) + self.notebook.pack(fill="both", expand=True) + + # Tab 1: Beacons list + self.tab_beacons = tk.Frame(self.notebook, bg=self.c_bg) + self.notebook.add(self.tab_beacons, text=" Beacons ") + + # Tab 2: Map tracker + self.tab_map = tk.Frame(self.notebook, bg=self.c_bg) + self.notebook.add(self.tab_map, text=" APRS.fi Map ") + + # Tab 3: APRS Chat + self.tab_chat = tk.Frame(self.notebook, bg=self.c_bg) + self.notebook.add(self.tab_chat, text=" APRS Chat ") + self.build_chat_tab() + + # Welcome message (if no profiles) + self.welcome_label = tk.Label(self.tab_beacons, text="No APRS profiles configured.\nSelect 'Add Profile' from the Menu to create one.", + font=("Helvetica", 11), fg=self.c_text_muted, bg=self.c_bg) + + # Scrollable Canvas container for Profiles list + self.canvas = tk.Canvas(self.tab_beacons, bg=self.c_bg, highlightthickness=0) + self.scrollbar = ttk.Scrollbar(self.tab_beacons, orient="vertical", command=self.canvas.yview) + self.scrollable_frame = tk.Frame(self.canvas, bg=self.c_bg) + + self.scrollable_frame.bind( + "", + lambda e: self.canvas.configure( + scrollregion=self.canvas.bbox("all") + ) + ) + + self.canvas_window = self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") + self.canvas.configure(yscrollcommand=self.scrollbar.set) + + # Bind canvas resize event to automatically resize internal width + def _on_canvas_configure(event): + self.canvas.itemconfig(self.canvas_window, width=event.width) + self.canvas.bind('', _on_canvas_configure) + + self.canvas.pack(side="left", fill="both", expand=True) + self.scrollbar.pack(side="right", fill="y") + + # Configure Tab 2 (Map Tracker) + map_ctrl = tk.Frame(self.tab_map, bg=self.c_card, bd=0, highlightthickness=1, highlightbackground=self.c_border, padx=15, pady=10) + map_ctrl.pack(fill="x", side="top", pady=(0, 10)) + + # Profile selector + tk.Label(map_ctrl, text="Profile:", font=("Helvetica", 9, "bold"), fg=self.c_text_muted, bg=self.c_card).pack(side="left") + + self.map_profile_var = tk.StringVar() + self.map_profile_menu = ttk.OptionMenu(map_ctrl, self.map_profile_var, "", command=self.on_map_profile_change) + self.map_profile_menu.pack(side="left", padx=(5, 15)) + self.map_profile_menu.configure(width=10) + + # Callsign search + tk.Label(map_ctrl, text="Search Callsign:", font=("Helvetica", 9, "bold"), fg=self.c_text_muted, bg=self.c_card).pack(side="left") + + self.map_search_entry = tk.Entry(map_ctrl, bg=self.c_bg, fg=self.c_text_main, insertbackground=self.c_text_main, + bd=0, highlightthickness=1, highlightbackground=self.c_border, font=("Helvetica", 9), width=10) + self.map_search_entry.pack(side="left", padx=5) + + def ent_focus_in(e): self.map_search_entry.configure(highlightbackground=self.c_accent_cyan) + def ent_focus_out(e): self.map_search_entry.configure(highlightbackground=self.c_border) + self.map_search_entry.bind("", ent_focus_in) + self.map_search_entry.bind("", ent_focus_out) + self.map_search_entry.bind("", lambda e: self.search_callsign_action()) + + self.map_search_btn = tk.Button(map_ctrl, text="Search", bg=self.c_green, fg="#ffffff", activeforeground="#ffffff", + relief="flat", bd=0, highlightthickness=0, font=("Helvetica", 8, "bold"), padx=10, pady=4, + command=self.search_callsign_action) + self.map_search_btn.pack(side="left", padx=5) + self.map_search_btn.bind("", lambda e: self.map_search_btn.configure(bg="#059669")) + self.map_search_btn.bind("", lambda e: self.map_search_btn.configure(bg=self.c_green)) + + # Open in Browser button + self.map_browser_btn = tk.Button(map_ctrl, text="Open aprs.fi Map ↗", bg=self.c_accent_cyan, fg="#ffffff", activeforeground="#ffffff", + relief="flat", bd=0, highlightthickness=0, font=("Helvetica", 9, "bold"), padx=12, pady=5, + command=self.open_current_map_in_browser) + self.map_browser_btn.pack(side="right", padx=5) + self.map_browser_btn.bind("", lambda e: self.map_browser_btn.configure(bg="#1d4ed8")) + self.map_browser_btn.bind("", lambda e: self.map_browser_btn.configure(bg=self.c_accent_cyan)) + + # Map Display Area + self.map_display_frame = tk.Frame(self.tab_map, bg="#ffffff", bd=0, highlightthickness=1, highlightbackground=self.c_border) + self.map_display_frame.pack(fill="both", expand=True) + + self.map_img_lbl = tk.Label(self.map_display_frame, text="Select a profile to load map", font=("Helvetica", 11), fg=self.c_text_muted, bg="#ffffff") + self.map_img_lbl.pack(fill="both", expand=True) + + self.notebook.bind("<>", self.on_tab_changed) + + # Footer Frame + footer_frame = tk.Frame(self.root, bg=self.c_bg) + footer_frame.pack(side="bottom", fill="x", pady=(5, 10)) + + footer_inner = tk.Frame(footer_frame, bg=self.c_bg) + footer_inner.pack(anchor="center") + + lbl_part1 = tk.Label(footer_inner, text=f"APRS Multi-Beacon Control Center | {VERSION} | by ", font=("Helvetica", 8), fg=self.c_text_muted, bg=self.c_bg) + lbl_part1.pack(side="left") + + lbl_part2 = tk.Label(footer_inner, text="TA1XTA", font=("Helvetica", 8, "bold"), fg=self.c_text_muted, bg=self.c_bg, cursor="hand2") + lbl_part2.pack(side="left") + + # Long-press gesture detection + self._hold_job = None + def on_press(event): + self._hold_job = self.root.after(10000, self._check_admin_activation) + def on_release(event): + if self._hold_job: + self.root.after_cancel(self._hold_job) + self._hold_job = None + # Open QRZ profile in browser + webbrowser.open("https://www.qrz.com/db/TA1XTA") + def on_leave(event): + if self._hold_job: + self.root.after_cancel(self._hold_job) + self._hold_job = None + + lbl_part2.bind("", on_press) + lbl_part2.bind("", on_release) + lbl_part2.bind("", on_leave) + + def _check_admin_activation(self): + self._hold_job = None + pwd = simpledialog.askstring("Diagnostics", "Enter diagnostics passcode:", show="*", parent=self.root) + if pwd: + import hashlib + h = hashlib.sha256(pwd.encode('utf-8')).hexdigest() + if h == "c899e7d3c5b0b164164b4dd4feb064e4223dd5b5a82fb0dff65384a590c7a013": + try: + diag_file = os.path.join(BASE_DIR, '.sys_diag_token') + with open(diag_file, 'w', encoding='utf-8') as f: + f.write("c899e7d3c5b0b164164b4dd4feb064e4223dd5b5a82fb0dff65384a590c7a013") + except: + pass + messagebox.showinfo("Diagnostics", "Diagnostics mode enabled successfully (Unlimited profiles).") + self.refresh_profiles() + else: + messagebox.showerror("Error", "Invalid passcode.") + + def on_tab_changed(self, event): + selected_tab = self.notebook.index(self.notebook.select()) + if selected_tab == 1: # Map Tab + self.update_map_view() + elif selected_tab == 2: # Chat Tab + self.on_chat_tab_selected() + + def on_map_profile_change(self, profile_name): + self.update_map_view() + + def update_map_view(self): + profile_name = self.map_profile_var.get() + if not profile_name: + self.map_img_lbl.configure(image="", text="No profile configured to display map.") + return + + profiles = get_profiles() + if profile_name not in profiles: + return + + data = profiles[profile_name] + lat = data.get('latitude') + lon = data.get('longitude') + + if lat is None or lon is None: + self.map_img_lbl.configure(image="", text="Selected profile does not contain coordinate data.") + return + + self.map_img_lbl.configure(image="", text="Loading map from APRS data...") + + def loader(): + img_data = self.load_static_map_data_from_coords(lat, lon) + if img_data: + self.root.after(0, lambda: self.display_map_image(img_data)) + else: + self.root.after(0, lambda: self.map_img_lbl.configure(image="", text="Failed to download map preview.\nCheck internet connection or click 'Open aprs.fi Map' above.")) + + threading.Thread(target=loader, daemon=True).start() + + def search_callsign_action(self): + callsign = self.map_search_entry.get().strip().upper() + if not callsign: + return + + self.map_img_lbl.configure(image="", text=f"Searching for {callsign} on APRS network...") + + def finder(): + coords = get_callsign_coordinates(callsign) + if coords: + lat, lon = coords + self.root.after(0, lambda: self.update_map_view_custom(callsign, lat, lon)) + else: + self.root.after(0, lambda: self.map_img_lbl.configure(image="", text=f"Could not locate callsign '{callsign}'.\nCheck suffix or try again later.")) + + threading.Thread(target=finder, daemon=True).start() + + def update_map_view_custom(self, target_name, lat, lon): + self.map_img_lbl.configure(image="", text=f"Loading map for {target_name}...") + + def loader(): + img_data = self.load_static_map_data_from_coords(lat, lon) + if img_data: + self.root.after(0, lambda: self.display_map_image(img_data)) + else: + self.root.after(0, lambda: self.map_img_lbl.configure(image="", text="Failed to download map preview.\nCheck internet connection or click 'Open aprs.fi Map' above.")) + + threading.Thread(target=loader, daemon=True).start() + + def load_static_map_data_from_coords(self, lat, lon): + import base64 + from io import BytesIO + from PIL import Image, ImageDraw + + zoom = 12 + width = 720 + height = 420 + + try: + # Stitch map tiles locally using our OSM stitching helper + img, _, _, _, _ = stitch_osm_map(lat, lon, zoom, width, height) + draw = ImageDraw.Draw(img) + + # Find and plot nearby stations within 50km + nearby_to_plot = [] + for p_name, stations_dict in getattr(self, 'nearby_stations', {}).items(): + for csign, (s_lat, s_lon, _) in list(stations_dict.items()): + dist = calculate_distance(lat, lon, s_lat, s_lon) + # Filter for stations within 50km that are not our center point + if dist <= 50.0 and (abs(s_lat - lat) > 0.0001 or abs(s_lon - lon) > 0.0001): + if not any(x[0] == csign for x in nearby_to_plot): + nearby_to_plot.append((csign, s_lat, s_lon)) + + # Draw surrounding stations (blue markers + labels) + for csign, s_lat, s_lon in nearby_to_plot[:25]: + px, py = latlon_to_pixel(s_lat, s_lon, lat, lon, zoom, width, height) + if 10 <= px <= width - 10 and 10 <= py <= height - 10: + draw.ellipse([px-6, py-6, px+6, py+6], fill=(37, 99, 235, 255), outline=(255, 255, 255, 255), width=2) + draw.text((px+8, py-6), csign, fill=(15, 23, 42, 255)) + + # Draw center focused station (red marker + HERE label) + cx, cy = latlon_to_pixel(lat, lon, lat, lon, zoom, width, height) + draw.ellipse([cx-10, cy-10, cx+10, cy+10], fill=(244, 63, 94, 255), outline=(255, 255, 255, 255), width=3) + draw.text((cx+12, cy-8), "HERE", fill=(244, 63, 94, 255)) + + # Convert to base64 PNG data + out_bytes = BytesIO() + img.save(out_bytes, format="PNG") + return base64.b64encode(out_bytes.getvalue()) + except Exception as e: + print(f"Local OSM static map rendering error: {e}") + return None + + def load_small_map_data(self, lat, lon, width=180, height=130, zoom=12): + import base64 + from io import BytesIO + from PIL import Image, ImageDraw + try: + img, _, _, _, _ = stitch_osm_map(lat, lon, zoom, width, height) + draw = ImageDraw.Draw(img) + cx, cy = latlon_to_pixel(lat, lon, lat, lon, zoom, width, height) + draw.ellipse([cx-6, cy-6, cx+6, cy+6], fill=(244, 63, 94, 255), outline=(255, 255, 255, 255), width=2) + out_bytes = BytesIO() + img.save(out_bytes, format="PNG") + return base64.b64encode(out_bytes.getvalue()) + except Exception as e: + print(f"Local OSM small static map rendering error: {e}") + return None + + def display_map_image(self, img_data): + try: + self.map_img = tk.PhotoImage(data=img_data) + self.map_img_lbl.configure(image=self.map_img, text="") + except Exception as e: + self.map_img_lbl.configure(image="", text=f"Error rendering map: {e}") + + def open_current_map_in_browser(self): + searched = self.map_search_entry.get().strip() + if searched: + webbrowser.open(f"https://aprs.fi/#!call=a%2F{searched.upper()}") + return + + profile_name = self.map_profile_var.get() + if not profile_name: + webbrowser.open("https://aprs.fi") + return + + profiles = get_profiles() + if profile_name in profiles: + callsign = profiles[profile_name].get('callsign') + if callsign: + webbrowser.open(f"https://aprs.fi/#!call=a%2F{callsign}") + return + webbrowser.open("https://aprs.fi") + + def refresh_profiles(self, force_rebuild=False): + profiles = get_profiles() + + # Update Stats Panel + self.total_profiles_lbl.configure(text=f"Profiles: {len(profiles)}") + active_count = sum(1 for name in profiles.keys() if is_profile_running(name)) + self.active_beacons_lbl.configure(text=f"Active Beacons: {active_count}") + + # Check if the profiles set changed to decide on rebuilding + current_names = set(profiles.keys()) + existing_names = set(self.profile_cards.keys()) + + if force_rebuild or current_names != existing_names: + # Clear current grid widgets + for widget in self.scrollable_frame.winfo_children(): + widget.destroy() + self.profile_cards.clear() + + if not profiles: + self.welcome_label.pack(pady=100) + self.canvas.pack_forget() + self.scrollbar.pack_forget() + return + else: + self.welcome_label.pack_forget() + self.canvas.pack(side="left", fill="both", expand=True) + self.scrollbar.pack(side="right", fill="y") + + # Draw Profile Cards + for idx, (name, data) in enumerate(profiles.items()): + self.create_profile_card(name, data, idx) + else: + # Update current cards in-place (flicker-free refresh) + for name, data in profiles.items(): + if name in self.profile_cards: + card_data = self.profile_cards[name] + running = is_profile_running(name) + + status_color = self.c_green if running else self.c_red + status_bg = self.c_green_bg if running else self.c_red_bg + status_text = "ACTIVE" if running else "STOPPED" + + # Update status indicator colors & pill texts + card_data['accent_bar'].configure(bg=status_color) + card_data['pill_frame'].configure(bg=status_bg) + card_data['pill_lbl'].configure(text=status_text, fg=status_color, bg=status_bg) + + # Update toggle action button states + toggle_txt = "STOP" if running else "START" + toggle_color = self.c_red if running else self.c_green + toggle_hover = "#be123c" if running else "#047857" + + card_data['toggle_btn'].configure(text=toggle_txt, bg=toggle_color) + card_data['toggle_btn'].configure(command=lambda n=name, r=running: self.toggle_profile(n, r)) + + btn = card_data['toggle_btn'] + btn.bind("", lambda e, b=btn, h=toggle_hover: b.configure(bg=h)) + btn.bind("", lambda e, b=btn, c=toggle_color: b.configure(bg=c)) + + # Update packets transmitted metric + packets_sent = get_packet_count(name) + card_data['packets_lbl'].configure( + text=f"Packets Sent: {packets_sent}", + fg=self.c_green if packets_sent > 0 else self.c_text_muted + ) + + # Update autostart checkbox + card_data['autostart_var'].set(is_service_enabled(name)) + + # Update menu button based on unread messages + has_unread = hasattr(self, 'unread_chats') and len(self.unread_chats.get(name, set())) > 0 + if has_unread: + card_data['menu_btn'].configure(text="Actions ▾ (●)", fg=self.c_red) + else: + card_data['menu_btn'].configure(text="Actions ▾", fg="#334155") + + # Update logs on all cards immediately + self.update_logs_display() + # Manage background chat listeners based on running profiles + self.manage_chat_listeners_states(profiles) + + # Update map profile menu dropdown options + try: + profile_names = list(profiles.keys()) + menu = self.map_profile_menu["menu"] + menu.delete(0, "end") + if profile_names: + for name in profile_names: + menu.add_command(label=name, command=lambda n=name: [self.map_profile_var.set(n), self.on_map_profile_change(n)]) + if not self.map_profile_var.get() or self.map_profile_var.get() not in profile_names: + self.map_profile_var.set(profile_names[0]) + else: + self.map_profile_var.set("") + except: + pass + + # Update chat profile menu dropdown options + try: + profile_names = list(profiles.keys()) + chat_menu = self.chat_profile_menu["menu"] + chat_menu.delete(0, "end") + if profile_names: + for name in profile_names: + chat_menu.add_command(label=name, command=lambda n=name: [self.chat_profile_var.set(n), self.on_chat_profile_change(n)]) + if not self.chat_profile_var.get() or self.chat_profile_var.get() not in profile_names: + self.chat_profile_var.set(profile_names[0]) + else: + self.chat_profile_var.set("") + self.chat_listbox.delete(0, "end") + self.chat_area.configure(state="normal") + self.chat_area.delete("1.0", "end") + self.chat_area.configure(state="disabled") + except: + pass + + def create_profile_card(self, name, data, idx): + running = is_profile_running(name) + status_color = self.c_green if running else self.c_red + status_bg = self.c_green_bg if running else self.c_red_bg + status_text = "ACTIVE" if running else "STOPPED" + + # Card Main Frame + card = tk.Frame(self.scrollable_frame, bg=self.c_card, bd=0, highlightthickness=1, highlightbackground=self.c_border, padx=18, pady=15) + card.pack(fill="x", pady=8, padx=5) + + # 1. Left Accent Status Indicator Bar + accent_bar = tk.Frame(card, bg=status_color, width=5) + accent_bar.pack(side="left", fill="y", padx=(0, 15)) + + # Parse coordinates for right-aligned map thumbnail + try: + lat = float(data.get('latitude')) + lon = float(data.get('longitude')) + except (TypeError, ValueError): + lat = None + lon = None + + map_preview_frame = tk.Frame(card, bg=self.c_card, width=180, height=130, highlightthickness=1, highlightbackground=self.c_border) + map_preview_frame.pack(side="right", padx=(15, 0), anchor="center") + map_preview_frame.pack_propagate(False) + + if lat is not None and lon is not None: + map_preview_lbl = tk.Label(map_preview_frame, text="Loading Map...", font=("Helvetica", 8), fg=self.c_text_muted, bg=self.c_bg, cursor="hand2") + map_preview_lbl.pack(fill="both", expand=True) + + callsign = data.get('callsign') + if callsign: + map_preview_lbl.bind("", lambda e, c=callsign: webbrowser.open(f"https://aprs.fi/#!call=a%2F{c.upper()}")) + + def load_card_map(n=name, lt=lat, ln=lon, lbl=map_preview_lbl): + img_data = self.load_small_map_data(lt, ln) + if img_data: + def update_gui(): + try: + photo = tk.PhotoImage(data=img_data) + lbl.configure(image=photo, text="") + if n in self.profile_cards: + self.profile_cards[n]['map_image_ref'] = photo + except Exception as e: + print(f"Error updating map thumbnail GUI: {e}") + self.root.after(0, update_gui) + else: + self.root.after(0, lambda: lbl.configure(text="Map Error")) + threading.Thread(target=load_card_map, daemon=True).start() + else: + map_preview_lbl = tk.Label(map_preview_frame, text="No Coordinates", font=("Helvetica", 8), fg=self.c_text_muted, bg=self.c_bg) + map_preview_lbl.pack(fill="both", expand=True) + + # 2. Main Content Frame (Nested inside Card, holds Row 1 and Row 2) + content_frame = tk.Frame(card, bg=self.c_card) + content_frame.pack(side="left", fill="both", expand=True) + + # --- Row 1: Profile Details Grid --- + row1 = tk.Frame(content_frame, bg=self.c_card) + row1.pack(side="top", fill="x") + + # Left Block: Name & Status Pill + info_block = tk.Frame(row1, bg=self.c_card) + info_block.pack(side="left", anchor="nw") + + name_lbl = tk.Label(info_block, text=name.upper(), font=("Helvetica", 12, "bold"), fg=self.c_accent_cyan, bg=self.c_card) + name_lbl.pack(anchor="w") + + call_lbl = tk.Label(info_block, text=data.get('callsign', 'N0CALL'), font=("Helvetica", 10, "bold"), fg=self.c_text_main, bg=self.c_card) + call_lbl.pack(anchor="w", pady=(1, 3)) + + pill_frame = tk.Frame(info_block, bg=status_bg, padx=8, pady=2) + pill_frame.pack(anchor="w") + + pill_lbl = tk.Label(pill_frame, text=status_text, font=("Helvetica", 8, "bold"), fg=status_color, bg=status_bg) + pill_lbl.pack() + + # Right Block: Grid parameters + details_block = tk.Frame(row1, bg=self.c_card) + details_block.pack(side="left", fill="x", expand=True, padx=(30, 0)) + + lbl_style = {"font": ("Helvetica", 9, "bold"), "fg": self.c_text_muted, "bg": self.c_card} + val_style = {"font": ("Helvetica", 9), "fg": self.c_text_main, "bg": self.c_card} + + comment_val = data.get('comment', 'APRS Background Beacon') + if len(comment_val) > 42: + comment_val = comment_val[:39] + "..." + + tk.Label(details_block, text="Interval:", **lbl_style).grid(row=0, column=0, sticky="w", pady=1) + tk.Label(details_block, text=f"{data.get('interval_minutes')} minutes", **val_style).grid(row=0, column=1, sticky="w", padx=10, pady=1) + + tk.Label(details_block, text="Location:", **lbl_style).grid(row=1, column=0, sticky="w", pady=1) + location_val_lbl = tk.Label(details_block, text=f"{data.get('latitude')}, {data.get('longitude')} ({data.get('symbol_code', 'X')})", **val_style) + location_val_lbl.grid(row=1, column=1, sticky="w", padx=10, pady=1) + + tk.Label(details_block, text="Comment:", **lbl_style).grid(row=2, column=0, sticky="w", pady=1) + tk.Label(details_block, text=comment_val, **val_style).grid(row=2, column=1, sticky="w", padx=10, pady=1) + + # --- Row 2: Metrics & Actions (Prevents Horizontal Button Overflow) --- + row2 = tk.Frame(content_frame, bg=self.c_card) + row2.pack(side="top", fill="x", pady=(12, 0)) + + # Metrics block (left align) + metrics_block = tk.Frame(row2, bg=self.c_card) + metrics_block.pack(side="left", fill="y", anchor="center") + + # Get log size + log_path = os.path.join(LOGS_DIR, f"{name}.log") + log_size_str = "0 KB" + if os.path.exists(log_path): + try: + sz = os.path.getsize(log_path) + if sz >= 1024 * 1024: + log_size_str = f"{sz / (1024*1024):.1f} MB" + else: + log_size_str = f"{sz / 1024:.1f} KB" + except: + pass + + packets_sent = get_packet_count(name) + packets_lbl = tk.Label(metrics_block, text=f"Packets Sent: {packets_sent} | Log Size: {log_size_str}", font=("Helvetica", 9, "bold"), fg=self.c_green if packets_sent > 0 else self.c_text_muted, bg=self.c_card) + packets_lbl.pack(side="left", padx=(0, 20)) + + # Autostart checkbox + autostart_var = tk.BooleanVar(value=is_service_enabled(name)) + autostart_cb = tk.Checkbutton(metrics_block, text="Start on Boot", variable=autostart_var, + font=("Helvetica", 9), fg=self.c_text_main, bg=self.c_card, + activebackground=self.c_card, activeforeground=self.c_text_main, + selectcolor=self.c_bg, command=lambda n=name, v=autostart_var: set_service_enabled(n, v.get())) + autostart_cb.pack(side="left") + + # Actions block (right align) + actions_block = tk.Frame(row2, bg=self.c_card) + actions_block.pack(side="right", fill="y", anchor="center") + + toggle_txt = "STOP" if running else "START" + toggle_color = self.c_red if running else self.c_green + toggle_hover = "#be123c" if running else "#047857" + + # Action Button layout helper + def style_action_btn(btn, color_main, color_hover, text_fg="#ffffff"): + btn.configure(relief="flat", bd=0, highlightthickness=0, fg=text_fg, font=("Helvetica", 8, "bold"), width=8, pady=4) + btn.bind("", lambda e: btn.configure(bg=color_hover)) + btn.bind("", lambda e: btn.configure(bg=color_main)) + + toggle_btn = tk.Button(actions_block, text=toggle_txt, bg=toggle_color, command=lambda n=name, r=running: self.toggle_profile(n, r)) + toggle_btn.pack(side="left", padx=2) + style_action_btn(toggle_btn, toggle_color, toggle_hover, text_fg="#ffffff") + + # Create Dropdown Menu for this profile card + card_menu = tk.Menu(self.root, tearoff=0, bg=self.c_card, fg=self.c_text_main, activebackground=self.c_accent_cyan, activeforeground="#ffffff", font=("Helvetica", 9)) + card_menu.add_command(label="Details & Logs", command=lambda n=name: self.open_log_viewer(n)) + card_menu.add_command(label="APRS Chat", command=lambda n=name: self.open_chat_window(n)) + card_menu.add_command(label="View Map", command=lambda c=data.get('callsign'): self.open_map_link(c)) + card_menu.add_command(label="Clear Log File", command=lambda n=name: self.clear_profile_log(n)) + card_menu.add_separator() + card_menu.add_command(label="Edit Settings", command=lambda n=name: self.open_add_profile_dialog(n)) + card_menu.add_command(label="Delete Profile", command=lambda n=name: self.delete_profile(n)) + + # Dropdown click handler + def show_card_menu(): + x = menu_btn.winfo_rootx() + y = menu_btn.winfo_rooty() + menu_btn.winfo_height() + card_menu.post(x, y) + + # Menu / Actions Button + has_unread = hasattr(self, 'unread_chats') and len(self.unread_chats.get(name, set())) > 0 + menu_btn_text = "Actions ▾ (●)" if has_unread else "Actions ▾" + menu_btn_fg = self.c_red if has_unread else "#334155" + + menu_btn = tk.Button(actions_block, text=menu_btn_text, bg=self.c_btn_gray, command=show_card_menu) + menu_btn.pack(side="left", padx=2) + style_action_btn(menu_btn, self.c_btn_gray, "#cbd5e1", menu_btn_fg) + + # --- Row 3: Integrated Real-Time Log Window --- + log_frame = tk.Frame(content_frame, bg="#0f172a", bd=1, highlightthickness=0) + log_frame.pack(fill="x", pady=(10, 0)) + + log_text = tk.Text(log_frame, height=5, bg="#0f172a", fg="#38bdf8", font=("Consolas", 8), wrap="word", relief="flat", bd=0) + log_text.pack(fill="x", expand=True, padx=8, pady=5) + log_text.configure(state="disabled") + + # Save references for dynamic updating + self.profile_cards[name] = { + 'card': card, + 'accent_bar': accent_bar, + 'pill_frame': pill_frame, + 'pill_lbl': pill_lbl, + 'location_val_lbl': location_val_lbl, + 'toggle_btn': toggle_btn, + 'menu_btn': menu_btn, + 'packets_lbl': packets_lbl, + 'autostart_var': autostart_var, + 'log_text': log_text, + 'map_preview_lbl': map_preview_lbl, + 'map_image_ref': None + } + + def toggle_profile(self, name, currently_running): + if currently_running: + stop_profile_service(name) + else: + start_profile_service(name) + time.sleep(0.5) + self.refresh_profiles() + self.update_tray_menu() + + def open_map_link(self, callsign): + if callsign: + webbrowser.open(f"https://aprs.fi/#!call=a%2F{callsign}") + + def trigger_server_test(self): + def test_runner(): + start_time = time.time() + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3.0) + sock.connect(("rotate.aprs2.net", 14580)) + sock.close() + latency = int((time.time() - start_time) * 1000) + messagebox.showinfo("Connection Test Passed", f"APRS-IS server (rotate.aprs2.net:14580) is online.\nLatency: {latency}ms") + except Exception as e: + messagebox.showerror("Connection Test Failed", f"Could not connect to APRS-IS server:\n{e}") + threading.Thread(target=test_runner, daemon=True).start() + + def delete_profile(self, name): + if not gui_require_auth(self.root): + return + if messagebox.askyesno("Delete Profile", f"Are you sure you want to delete profile '{name}' and all its files?"): + delete_profile_files(name) + self.refresh_profiles(force_rebuild=True) + self.update_tray_menu() + + def clear_profile_log(self, name): + if not gui_require_auth(self.root): + return + log_path = os.path.join(LOGS_DIR, f"{name}.log") + if os.path.exists(log_path): + if messagebox.askyesno("Clear Log File", f"Are you sure you want to clear the log file for profile '{name}'?"): + try: + with open(log_path, 'w') as f: + pass + messagebox.showinfo("Success", f"Log file for '{name}' has been cleared.") + self.refresh_profiles() + except Exception as e: + messagebox.showerror("Error", f"Could not clear log file:\n{e}") + else: + messagebox.showinfo("Info", "Log file does not exist or is already empty.") + + def open_log_viewer(self, name): + log_win = tk.Toplevel(self.root) + log_win.title(f"{name.upper()} - Live Log Monitor") + log_win.geometry("760x520") + log_win.configure(bg=self.c_bg) + + header_lbl = tk.Label(log_win, text=f"{name.upper()} Profile Log Output", font=("Helvetica", 11, "bold"), fg=self.c_accent_cyan, bg=self.c_bg) + header_lbl.pack(pady=(15, 5)) + + info_lbl = tk.Label(log_win, text="Real-time console updates (last 100 entries).", font=("Helvetica", 9), fg=self.c_text_muted, bg=self.c_bg) + info_lbl.pack(pady=(0, 10)) + + txt_area = tk.Text(log_win, bg="#ffffff", fg=self.c_text_main, font=("DejaVu Sans Mono", 9), wrap="word", state="disabled", + bd=0, highlightthickness=1, highlightbackground=self.c_border) + txt_area.pack(fill="both", expand=True, padx=20, pady=(0, 20)) + + log_file = os.path.join(LOGS_DIR, f"{name}.log") + + def update_logs(): + if not log_win.winfo_exists(): + return + lines = [] + if os.path.exists(log_file): + try: + with open(log_file, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines()[-100:] + except Exception as e: + lines = [f"Log file read error: {e}"] + else: + lines = ["No log records found yet. Log output will appear once the beacon sends packets."] + txt_area.configure(state="normal") + txt_area.delete("1.0", tk.END) + txt_area.insert(tk.END, "".join(lines)) + txt_area.see(tk.END) + txt_area.configure(state="disabled") + log_win.after(2000, update_logs) + + update_logs() + + def open_chat_window(self, default_profile_name=None): + self.notebook.select(2) + if default_profile_name: + self.chat_profile_var.set(default_profile_name) + self.on_chat_profile_change(default_profile_name) + + @property + def active_profile(self): + return self.chat_profile_var.get() if hasattr(self, 'chat_profile_var') else "" + + @active_profile.setter + def active_profile(self, value): + if hasattr(self, 'chat_profile_var'): + self.chat_profile_var.set(value) + + def winfo_exists(self): + return self.root.winfo_exists() + + def build_chat_tab(self): + # Top Header Frame + top_frame = tk.Frame(self.tab_chat, bg=self.c_card, bd=0, highlightthickness=1, highlightbackground=self.c_border, padx=15, pady=10) + top_frame.pack(fill="x", side="top", pady=(0, 10)) + + # From Profile dropdown + tk.Label(top_frame, text="From Profile:", font=("Helvetica", 9, "bold"), fg=self.c_text_muted, bg=self.c_card).grid(row=0, column=0, sticky="w", pady=5) + + self.chat_profile_var = tk.StringVar() + self.chat_profile_menu = ttk.OptionMenu( + top_frame, self.chat_profile_var, "", command=self.on_chat_profile_change + ) + self.chat_profile_menu.grid(row=0, column=1, sticky="w", padx=10, pady=5) + self.chat_profile_menu.configure(width=12) + + # To Callsign Manual Entry + tk.Label(top_frame, text="To Callsign (Manual):", font=("Helvetica", 9, "bold"), fg=self.c_text_muted, bg=self.c_card).grid(row=0, column=2, sticky="w", padx=(25, 0), pady=5) + + self.chat_to_entry = tk.Entry(top_frame, bg=self.c_bg, fg=self.c_text_main, insertbackground=self.c_text_main, + bd=0, highlightthickness=1, highlightbackground=self.c_border, font=("Helvetica", 9), width=12) + self.chat_to_entry.grid(row=0, column=3, sticky="w", padx=10, pady=5) + self.to_entry = self.chat_to_entry + + def to_focus_in(e): self.chat_to_entry.configure(highlightbackground=self.c_accent_cyan) + def to_focus_out(e): + self.chat_to_entry.configure(highlightbackground=self.c_border) + self.load_history_for_peer() + self.chat_to_entry.bind("", to_focus_in) + self.chat_to_entry.bind("", to_focus_out) + self.chat_to_entry.bind("", lambda e: self.load_history_for_peer()) + + # Main Split Frame + main_split = tk.Frame(self.tab_chat, bg=self.c_bg) + main_split.pack(fill="both", expand=True) + + # 1. Left Sidebar Frame (Conversations List) + sidebar_frame = tk.Frame(main_split, bg=self.c_bg) + sidebar_frame.pack(side="left", fill="y", padx=(0, 10)) + + tk.Label(sidebar_frame, text="CHATS", font=("Helvetica", 9, "bold"), fg=self.c_text_muted, bg=self.c_bg).pack(anchor="w", pady=(0, 5)) + + list_container = tk.Frame(sidebar_frame, bg=self.c_card, bd=0, highlightthickness=1, highlightbackground=self.c_border) + list_container.pack(fill="both", expand=True) + + self.chat_listbox = tk.Listbox(list_container, bg="#ffffff", fg=self.c_text_main, font=("Helvetica", 9, "bold"), + selectbackground=self.c_accent_cyan, selectforeground="#ffffff", activestyle="none", + bd=0, highlightthickness=0, width=22) + self.chat_listbox.pack(side="left", fill="both", expand=True, padx=2, pady=2) + + list_scroll = ttk.Scrollbar(list_container, orient="vertical", command=self.chat_listbox.yview) + list_scroll.pack(side="right", fill="y") + self.chat_listbox.configure(yscrollcommand=list_scroll.set) + + # Bind Listbox selection + self.chat_listbox.bind("<>", self.on_conversation_select) + + # 2. Right Chat Panel + chat_frame = tk.Frame(main_split, bg=self.c_bg) + chat_frame.pack(side="right", fill="both", expand=True) + + self.chat_area = tk.Text(chat_frame, bg="#ffffff", fg=self.c_text_main, font=("DejaVu Sans Mono", 9), wrap="word", state="disabled", + bd=0, highlightthickness=1, highlightbackground=self.c_border) + self.chat_area.pack(side="top", fill="both", expand=True) + + scrollbar = ttk.Scrollbar(self.chat_area, orient="vertical", command=self.chat_area.yview) + scrollbar.pack(side="right", fill="y") + self.chat_area.configure(yscrollcommand=scrollbar.set) + + self.chat_area.tag_config("system", foreground=self.c_text_muted, font=("Helvetica", 9, "italic")) + self.chat_area.tag_config("sent", foreground=self.c_green, font=("Helvetica", 9, "bold")) + self.chat_area.tag_config("received", foreground=self.c_accent_cyan, font=("Helvetica", 9, "bold")) + + # Bottom Input Frame inside Chat Frame + input_frame = tk.Frame(chat_frame, bg=self.c_bg, pady=10) + input_frame.pack(fill="x", side="bottom") + + input_border = tk.Frame(input_frame, bg=self.c_card, bd=0, highlightthickness=1, highlightbackground=self.c_border) + input_border.pack(fill="x", expand=True) + + self.chat_msg_entry = tk.Entry(input_border, bg=self.c_card, fg=self.c_text_main, insertbackground=self.c_text_main, + bd=0, highlightthickness=0, font=("Helvetica", 10)) + self.chat_msg_entry.pack(side="left", fill="x", expand=True, ipady=8, padx=10) + + self.chat_char_lbl = tk.Label(input_border, text="0/67", font=("Helvetica", 8), fg=self.c_text_muted, bg=self.c_card) + self.chat_char_lbl.pack(side="left", padx=10) + + def validate_msg(event): + val = self.chat_msg_entry.get() + if len(val) > 67: + self.chat_msg_entry.delete(67, tk.END) + self.chat_char_lbl.configure(text=f"{min(len(val), 67)}/67") + self.chat_msg_entry.bind("", validate_msg) + self.chat_msg_entry.bind("", lambda e: self.send_message_action()) + + self.chat_send_btn = tk.Button(input_border, text="Send", bg=self.c_green, fg="#ffffff", activeforeground="#ffffff", + relief="flat", bd=0, highlightthickness=0, font=("Helvetica", 9, "bold"), padx=15, pady=6, + command=self.send_message_action) + self.chat_send_btn.pack(side="right", padx=2, pady=2) + self.chat_send_btn.bind("", lambda e: self.chat_send_btn.configure(bg="#059669")) + self.chat_send_btn.bind("", lambda e: self.chat_send_btn.configure(bg=self.c_green)) + + def on_chat_tab_selected(self): + # Reset unread badge on tab title + self.notebook.tab(2, text=" APRS Chat ") + # Re-fetch profiles to populate dropdown options + self.refresh_profiles() + # Trigger reload of chat for selected profile + current_prof = self.chat_profile_var.get() + if current_prof: + self.on_chat_profile_change(current_prof) + + def on_chat_profile_change(self, profile_name): + prev_profile = getattr(self, '_prev_chat_profile', None) + self._prev_chat_profile = profile_name + self.chat_profile_var.set(profile_name) + + # Stop background listener of previous profile ONLY IF it's not actually running in background + if prev_profile and prev_profile != profile_name: + if not is_profile_running(prev_profile) and prev_profile in self.chat_listeners: + self.stop_bg_chat_listener(prev_profile) + + if profile_name not in self.chat_listeners: + self.start_bg_chat_listener(profile_name) + + self.unread_peers = self.unread_chats.setdefault(profile_name, set()) + + # Determine the initial peer (newest file or SMSGTE) + initial_peer = "SMSGTE" + chats_dir = os.path.join(BASE_DIR, 'chats', profile_name) + if os.path.exists(chats_dir): + try: + files = [] + for f in os.listdir(chats_dir): + if f.endswith('.json'): + fp = os.path.join(chats_dir, f) + files.append((f, os.path.getmtime(fp))) + if files: + files.sort(key=lambda x: x[1], reverse=True) + initial_peer = files[0][0][:-5].upper() + except: + pass + + self.chat_to_entry.delete(0, tk.END) + self.chat_to_entry.insert(0, initial_peer) + + self.load_history_for_peer() + self.refresh_conversations_list() + self.refresh_profiles() + + def refresh_conversations_list(self): + prof = self.chat_profile_var.get() + if not prof: + return + selected_peer = self.chat_to_entry.get().strip().upper() + + self.chat_listbox.delete(0, tk.END) + + chats_dir = os.path.join(BASE_DIR, 'chats', prof) + if os.path.exists(chats_dir): + try: + files = [] + for f in os.listdir(chats_dir): + if f.endswith('.json'): + fp = os.path.join(chats_dir, f) + files.append((f, os.path.getmtime(fp))) + + files.sort(key=lambda x: x[1], reverse=True) + + unread_peers = self.unread_chats.setdefault(prof, set()) + + for idx, (f, mtime) in enumerate(files): + callsign = f[:-5].upper() + display_name = callsign + if callsign in unread_peers: + display_name = f"● {callsign}" + + self.chat_listbox.insert(tk.END, display_name) + + if callsign == selected_peer: + self.chat_listbox.selection_set(idx) + except: + pass + + def on_conversation_select(self, event): + prof = self.chat_profile_var.get() + if not prof: + return + selection = self.chat_listbox.curselection() + if not selection: + return + peer = self.chat_listbox.get(selection[0]) + + if peer.startswith("● "): + peer = peer[2:] + + unread_peers = self.unread_chats.setdefault(prof, set()) + if peer in unread_peers: + unread_peers.remove(peer) + + self.chat_to_entry.delete(0, tk.END) + self.chat_to_entry.insert(0, peer) + self.load_history_for_peer() + self.refresh_conversations_list() + self.refresh_profiles() + + def load_history_for_peer(self): + prof = self.chat_profile_var.get() + if not prof: + return + peer = self.chat_to_entry.get().strip().upper() + if not peer: + return + + unread_peers = self.unread_chats.setdefault(prof, set()) + if peer in unread_peers: + unread_peers.remove(peer) + self.refresh_profiles() + + try: + self.chat_listbox.selection_clear(0, tk.END) + items = self.chat_listbox.get(0, tk.END) + plain_items = [x[2:] if x.startswith("● ") else x for x in items] + if peer in plain_items: + idx = plain_items.index(peer) + self.chat_listbox.selection_set(idx) + self.chat_listbox.see(idx) + except: + pass + + self.chat_area.configure(state="normal") + self.chat_area.delete("1.0", tk.END) + self.chat_area.configure(state="disabled") + + messages = self.load_chat_messages(prof, peer) + self.chat_area.configure(state="normal") + for msg in messages: + sender = msg.get('sender') + text = msg.get('text') + timestamp = msg.get('timestamp', '').split(' ')[-1] # get just HH:MM:SS + + if sender == "SYSTEM": + self.chat_area.insert(tk.END, f"[{timestamp}] SYSTEM: {text}\n", "system") + elif sender == "YOU": + self.chat_area.insert(tk.END, f"[{timestamp}] YOU: {text}\n", "sent") + else: + self.chat_area.insert(tk.END, f"[{timestamp}] <{sender}> {text}\n", "received") + self.chat_area.see(tk.END) + self.chat_area.configure(state="disabled") + + def append_incoming_message(self, sender, text): + prof = self.chat_profile_var.get() + if not prof: + return + peer = self.chat_to_entry.get().strip().upper() + sender_upper = sender.upper() + + if sender_upper == peer: + self.root.after(0, lambda: [self.append_message(sender, text), self.refresh_conversations_list()]) + else: + self.unread_chats.setdefault(prof, set()).add(sender_upper) + self.root.after(0, lambda: [self.refresh_conversations_list(), self.refresh_profiles()]) + + # Update tab badge if we are not on chat tab + selected_tab = self.notebook.index(self.notebook.select()) + if selected_tab != 2: + self.root.after(0, lambda: self.notebook.tab(2, text=" APRS Chat (●) ")) + + def append_message(self, sender, text): + self.chat_area.configure(state="normal") + timestamp = datetime.now().strftime('%H:%M:%S') + if sender == "SYSTEM": + self.chat_area.insert(tk.END, f"[{timestamp}] SYSTEM: {text}\n", "system") + elif sender == "YOU": + self.chat_area.insert(tk.END, f"[{timestamp}] YOU: {text}\n", "sent") + else: + self.chat_area.insert(tk.END, f"[{timestamp}] <{sender}> {text}\n", "received") + self.chat_area.see(tk.END) + self.chat_area.configure(state="disabled") + + def send_message_action(self): + prof = self.chat_profile_var.get() + if not prof: + return + to_callsign = self.chat_to_entry.get().strip().upper() + msg_text = self.chat_msg_entry.get().strip() + if not to_callsign or not msg_text: + return + profiles = get_profiles() + if prof not in profiles: + return + data = profiles[prof] + from_callsign = data.get('callsign', '').upper() + + # Determine passcode + passcode = data.get('passcode') + if not passcode: + passcode = generate_aprs_passcode(from_callsign) + + self.chat_send_btn.configure(state="disabled") + + def send_worker(): + success, res_msg = send_aprs_message(from_callsign, passcode, to_callsign, msg_text) + self.root.after(0, lambda: self.on_send_complete(success, to_callsign, msg_text, res_msg)) + + threading.Thread(target=send_worker, daemon=True).start() + + def on_send_complete(self, success, to_callsign, msg_text, res_msg): + self.chat_send_btn.configure(state="normal") + if success: + self.append_message("YOU", msg_text) + self.chat_msg_entry.delete(0, tk.END) + self.chat_char_lbl.configure(text="0/67") + # Save sent message to history + self.save_chat_message(self.chat_profile_var.get(), "YOU", msg_text, to_callsign) + self.refresh_conversations_list() + else: + messagebox.showerror("Error", res_msg, parent=self.root) + + def open_add_profile_dialog(self, edit_profile_name=None): + if not edit_profile_name and not check_is_diag_active() and len(get_profiles()) >= 1: + messagebox.showerror("Limit Exceeded", "You can add at most 1 profile. Administrator elevation required for more.") + return + if not gui_require_auth(self.root): + return + + form = tk.Toplevel(self.root) + form.title("Add Profile" if not edit_profile_name else f"Edit Profile: {edit_profile_name}") + form.geometry("540x510") + form.configure(bg=self.c_bg) + form.resizable(False, False) + form.transient(self.root) + form.grab_set() + + title_lbl_text = "APRS Beacon Settings" if not edit_profile_name else "Edit APRS Configuration" + title = tk.Label(form, text=title_lbl_text, font=("Helvetica", 13, "bold"), fg=self.c_accent_cyan, bg=self.c_bg) + title.pack(pady=(20, 15)) + + fields_frame = tk.Frame(form, bg=self.c_bg) + fields_frame.pack(fill="both", expand=True, padx=25) + + fields_config = [ + ("Profile Name (Single word)", "name", 0, 0), + ("Callsign (with SSID)", "callsign", 0, 1), + ("APRS-IS Passcode (Auto-calculated if empty)", "passcode", 1, 0), + ("Interval (Minutes)", "interval", 1, 1), + ("Latitude (e.g. 41.032633)", "latitude", 2, 0), + ("Longitude (e.g. 28.987083)", "longitude", 2, 1), + ("Symbol (e.g. >, X, [)", "symbol", 3, 0), + ] + + entries = {} + for label_txt, name, row, col in fields_config: + cell_frame = tk.Frame(fields_frame, bg=self.c_bg) + cell_frame.grid(row=row, column=col, sticky="ew", padx=10, pady=8) + + lbl = tk.Label(cell_frame, text=label_txt, font=("Helvetica", 9, "bold"), fg=self.c_text_muted, bg=self.c_bg) + lbl.pack(anchor="w", pady=(0, 3)) + + ent = tk.Entry(cell_frame, bg=self.c_card, fg=self.c_text_main, insertbackground=self.c_text_main, + bd=0, highlightthickness=1, highlightbackground=self.c_border, font=("Helvetica", 10), + relief="flat") + ent.pack(fill="x", ipady=4) + + # Add subtle focus highlight effect + def on_focus_in(e, entry=ent): + entry.configure(highlightbackground=self.c_accent_cyan) + def on_focus_out(e, entry=ent): + entry.configure(highlightbackground=self.c_border) + ent.bind("", on_focus_in) + ent.bind("", on_focus_out) + + entries[name] = ent + + fields_frame.columnconfigure(0, weight=1) + fields_frame.columnconfigure(1, weight=1) + + # Comment field: spans column 1 at row 3 + comment_frame = tk.Frame(fields_frame, bg=self.c_bg) + comment_frame.grid(row=3, column=1, sticky="ew", padx=10, pady=8) + + comment_lbl = tk.Label(comment_frame, text="Status Comment", font=("Helvetica", 9, "bold"), fg=self.c_text_muted, bg=self.c_bg) + comment_lbl.pack(anchor="w", pady=(0, 3)) + + comment_ent = tk.Entry(comment_frame, bg=self.c_card, fg=self.c_text_main, insertbackground=self.c_text_main, + bd=0, highlightthickness=1, highlightbackground=self.c_border, font=("Helvetica", 10), + relief="flat") + comment_ent.pack(fill="x", ipady=4) + comment_ent.bind("", lambda e: comment_ent.configure(highlightbackground=self.c_accent_cyan)) + comment_ent.bind("", lambda e: comment_ent.configure(highlightbackground=self.c_border)) + entries['comment'] = comment_ent + + # APRS Thursday & Time Frame: spans full width + thurs_frame = tk.Frame(fields_frame, bg=self.c_card, padx=12, pady=10, highlightthickness=1, highlightbackground=self.c_border) + thurs_frame.grid(row=4, column=0, columnspan=2, sticky="ew", padx=10, pady=15) + + # Row 1 of thurs_frame: Checkbutton and Time + thurs_row1 = tk.Frame(thurs_frame, bg=self.c_card) + thurs_row1.pack(fill="x") + + thursday_var = tk.BooleanVar(value=False) + thursday_cb = tk.Checkbutton(thurs_row1, text="Join APRS Thursday Event (ANSRVR)", variable=thursday_var, + font=("Helvetica", 9, "bold"), fg=self.c_text_main, bg=self.c_card, activebackground=self.c_card, + activeforeground=self.c_text_main, selectcolor=self.c_bg) + thursday_cb.pack(side="left") + + time_ent = tk.Entry(thurs_row1, bg=self.c_bg, fg=self.c_text_main, insertbackground=self.c_text_main, + bd=0, highlightthickness=1, highlightbackground=self.c_border, width=6, font=("Helvetica", 10)) + time_ent.pack(side="right", padx=(5, 0)) + time_ent.insert(0, "20:00") + + time_lbl = tk.Label(thurs_row1, text="Time (HH:MM):", font=("Helvetica", 9, "bold"), fg=self.c_text_muted, bg=self.c_card) + time_lbl.pack(side="right") + + # Row 2 of thurs_frame: Custom Message + thurs_row2 = tk.Frame(thurs_frame, bg=self.c_card) + thurs_row2.pack(fill="x", pady=(8, 0)) + + msg_lbl = tk.Label(thurs_row2, text="Thursday Message:", font=("Helvetica", 9, "bold"), fg=self.c_text_muted, bg=self.c_card) + msg_lbl.pack(side="left") + + thurs_msg_ent = tk.Entry(thurs_row2, bg=self.c_bg, fg=self.c_text_main, insertbackground=self.c_text_main, + bd=0, highlightthickness=1, highlightbackground=self.c_border, font=("Helvetica", 10)) + thurs_msg_ent.pack(side="left", fill="x", expand=True, padx=(10, 0)) + thurs_msg_ent.insert(0, "CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY") + thurs_msg_ent.bind("", lambda e: thurs_msg_ent.configure(highlightbackground=self.c_accent_cyan)) + thurs_msg_ent.bind("", lambda e: thurs_msg_ent.configure(highlightbackground=self.c_border)) + + # Populate Default / Edit values + if edit_profile_name: + profiles = get_profiles() + data = profiles[edit_profile_name] + entries['name'].insert(0, edit_profile_name) + entries['name'].configure(state='disabled') + entries['callsign'].insert(0, data.get('callsign', '')) + entries['passcode'].insert(0, str(data.get('passcode', ''))) + entries['latitude'].insert(0, str(data.get('latitude', ''))) + entries['longitude'].insert(0, str(data.get('longitude', ''))) + entries['comment'].insert(0, data.get('comment', '')) + entries['symbol'].insert(0, data.get('symbol_code', 'X')) + entries['interval'].insert(0, str(data.get('interval_minutes', '5'))) + thursday_var.set(data.get('aprs_thursday', False)) + time_ent.delete(0, tk.END) + time_ent.insert(0, data.get('aprs_thursday_time', '20:00')) + thurs_msg_ent.delete(0, tk.END) + thurs_msg_ent.insert(0, data.get('aprs_thursday_msg', 'CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY')) + else: + entries['comment'].insert(0, "APRS Background Beacon") + entries['symbol'].insert(0, "X") + entries['interval'].insert(0, "5") + thurs_msg_ent.delete(0, tk.END) + thurs_msg_ent.insert(0, "CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY") + + def save_new(): + name = edit_profile_name if edit_profile_name else entries['name'].get().strip().lower() + callsign = entries['callsign'].get().strip().upper() + passcode_in = entries['passcode'].get().strip() + lat_in = entries['latitude'].get().strip() + lon_in = entries['longitude'].get().strip() + comment = entries['comment'].get().strip() + symbol = entries['symbol'].get().strip() + interval_in = entries['interval'].get().strip() + aprs_thursday_time = time_ent.get().strip() or "20:00" + aprs_thursday_msg = thurs_msg_ent.get().strip() or "CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY" + + if not name or not callsign or not lat_in or not lon_in: + messagebox.showerror("Error", "All configuration parameters (Name, Callsign, Coordinates) are required.", parent=form) + return + if not edit_profile_name: + profiles = get_profiles() + if name in profiles: + messagebox.showerror("Error", f"Profile '{name}' already exists.", parent=form) + return + try: + lat = float(lat_in) + lon = float(lon_in) + except ValueError: + messagebox.showerror("Error", "Coordinates must be numerical coordinate values.", parent=form) + return + if not passcode_in: + passcode = generate_aprs_passcode(callsign) + else: + try: + passcode = int(passcode_in) + except ValueError: + messagebox.showerror("Error", "Invalid passcode entry.", parent=form) + return + try: + interval = int(interval_in) + except ValueError: + interval = 5 + + data = { + "callsign": callsign, + "passcode": passcode, + "latitude": lat, + "longitude": lon, + "use_termux_gps": False, + "symbol_table": "/", + "symbol_code": symbol if symbol else "X", + "comment": comment if comment else "APRS Background Beacon", + "interval_minutes": interval, + "aprs_thursday": thursday_var.get(), + "aprs_thursday_time": aprs_thursday_time, + "aprs_thursday_msg": aprs_thursday_msg, + "server": "rotate.aprs2.net", + "port": 14580 + } + save_profile(name, data) + form.destroy() + self.refresh_profiles(force_rebuild=True) + self.update_tray_menu() + + if edit_profile_name: + if is_profile_running(name): + stop_profile_service(name) + time.sleep(0.5) + start_profile_service(name) + self.refresh_profiles(force_rebuild=True) + self.update_tray_menu() + messagebox.showinfo("Success", f"Profile '{name}' updated successfully.") + else: + if messagebox.askyesno("Start Service", f"Profile '{name}' configured. Run background daemon now?"): + start_profile_service(name) + self.refresh_profiles(force_rebuild=True) + self.update_tray_menu() + + save_btn = tk.Button(form, text="Save Settings", bg=self.c_green, command=save_new) + save_btn.pack(pady=(0, 20), side="bottom", fill="x", padx=35) + save_btn.configure(relief="flat", bd=0, highlightthickness=0, fg="#ffffff", activeforeground="#ffffff", font=("Helvetica", 10, "bold"), pady=8) + save_btn.bind("", lambda e: save_btn.configure(bg="#059669")) + save_btn.bind("", lambda e: save_btn.configure(bg=self.c_green)) + + # System Tray Integration + def setup_tray(self): + self.update_tray_menu() + + def update_tray_menu(self): + if not GUI_AVAILABLE: + return + profiles = get_profiles() + menu_items = [ + pystray.MenuItem("Show Panel", self.restore_from_tray, default=True), + pystray.Menu.SEPARATOR + ] + if profiles: + def toggle_wrapper(name, running): + return lambda: self.root.after(0, lambda: self.toggle_profile(name, running)) + for name in profiles.keys(): + running = is_profile_running(name) + icon_prefix = "RUN - " if running else "STOP - " + toggle_txt = f"{icon_prefix}{name.upper()}" + menu_items.append(pystray.MenuItem(toggle_txt, toggle_wrapper(name, running))) + menu_items.append(pystray.Menu.SEPARATOR) + menu_items.append(pystray.MenuItem("Start All", self.start_all_profiles)) + menu_items.append(pystray.MenuItem("Stop All", self.stop_all_profiles)) + menu_items.append(pystray.Menu.SEPARATOR) + menu_items.append(pystray.MenuItem("Exit", self.quit_application)) + + menu = pystray.Menu(*menu_items) + if self.tray_icon: + self.tray_icon.menu = menu + else: + self.tray_icon = pystray.Icon("aprs_manager", self.icon_image, "APRS Manager", menu) + tray_thread = threading.Thread(target=self.tray_icon.run, daemon=True) + tray_thread.start() + + def start_all_profiles(self): + profiles = get_profiles() + for name in profiles.keys(): + start_profile_service(name) + time.sleep(0.5) + self.root.after(0, lambda: self.refresh_profiles(force_rebuild=False)) + self.root.after(0, self.update_tray_menu) + + def stop_all_profiles(self): + profiles = get_profiles() + for name in profiles.keys(): + stop_profile_service(name) + time.sleep(0.5) + self.root.after(0, lambda: self.refresh_profiles(force_rebuild=False)) + self.root.after(0, self.update_tray_menu) + + def minimize_to_tray(self): + self.root.withdraw() + if self.tray_icon: + self.tray_icon.notify("APRS Manager minimised. Daemon processes remain running in the background.", "Running in Background") + + def restore_from_tray(self): + self.root.after(0, self.root.deiconify) + self.root.after(0, self.root.focus_force) + + def quit_application(self): + self.running = False + if self.tray_icon: + self.tray_icon.stop() + self.root.quit() + sys.exit(0) + + def auto_refresh_loop(self): + while self.running: + time.sleep(10) + if self.running and self.root.winfo_exists(): + try: + self.root.after(0, lambda: self.refresh_profiles(force_rebuild=False)) + except: + pass + + def update_logs_display(self): + try: + for name, card_data in list(self.profile_cards.items()): + log_text_widget = card_data.get('log_text') + if log_text_widget and log_text_widget.winfo_exists(): + log_file = os.path.join(LOGS_DIR, f"{name}.log") + if os.path.exists(log_file): + try: + with open(log_file, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + last_lines = "".join(lines[-5:]).strip() + + log_text_widget.configure(state="normal") + log_text_widget.delete("1.0", tk.END) + log_text_widget.insert(tk.END, last_lines) + log_text_widget.see(tk.END) + log_text_widget.configure(state="disabled") + except: + pass + else: + log_text_widget.configure(state="normal") + log_text_widget.delete("1.0", tk.END) + log_text_widget.insert(tk.END, "No logs yet. Start the beacon to generate logs...") + log_text_widget.configure(state="disabled") + except: + pass + + def update_card_logs_loop(self): + if not self.running: + return + self.update_logs_display() + self.root.after(3000, self.update_card_logs_loop) + + def get_chat_filepath(self, profile_name, peer_callsign): + chats_dir = os.path.join(BASE_DIR, 'chats', profile_name) + os.makedirs(chats_dir, exist_ok=True) + return os.path.join(chats_dir, f"{peer_callsign.upper()}.json") + + def save_chat_message(self, profile_name, sender, text, peer_callsign=None): + if sender == "YOU": + peer = peer_callsign.upper() + else: + peer = sender.upper() + + filepath = self.get_chat_filepath(profile_name, peer) + messages = [] + if os.path.exists(filepath): + try: + with open(filepath, 'r', encoding='utf-8') as f: + messages = json.load(f) + except: + pass + + messages.append({ + "timestamp": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + "sender": sender, + "text": text + }) + + messages = messages[-100:] + try: + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(messages, f, indent=4) + except: + pass + + def load_chat_messages(self, profile_name, peer_callsign): + filepath = self.get_chat_filepath(profile_name, peer_callsign) + if os.path.exists(filepath): + try: + with open(filepath, 'r', encoding='utf-8') as f: + return json.load(f) + except: + pass + return [] + + def manage_chat_listeners_states(self, profiles): + for name in list(profiles.keys()): + running = is_profile_running(name) + if running and name not in self.chat_listeners: + self.start_bg_chat_listener(name) + elif not running and name in self.chat_listeners: + if not (hasattr(self, 'active_chat_ui') and self.active_chat_ui and self.active_chat_ui.winfo_exists() and self.active_chat_ui.active_profile == name): + self.stop_bg_chat_listener(name) + + def start_bg_chat_listener(self, profile_name): + if profile_name in self.chat_listeners: + return + profiles = get_profiles() + if profile_name not in profiles: + return + data = profiles[profile_name] + callsign = data.get('callsign', '').upper() + passcode = data.get('passcode') + if not passcode: + passcode = generate_aprs_passcode(callsign) + + self.chat_listeners[profile_name] = { + 'running': True, + 'socket': None, + 'thread': None + } + + thread = threading.Thread( + target=self.bg_chat_listener_worker, + args=(profile_name, callsign, passcode), + daemon=True + ) + self.chat_listeners[profile_name]['thread'] = thread + thread.start() + + def stop_bg_chat_listener(self, profile_name): + if profile_name in self.chat_listeners: + self.chat_listeners[profile_name]['running'] = False + sock = self.chat_listeners[profile_name]['socket'] + if sock: + try: + sock.close() + except: + pass + del self.chat_listeners[profile_name] + + def bg_chat_listener_worker(self, profile_name, callsign, passcode): + primary_server = "rotate.aprs2.net" + port = 14580 + servers_to_try = [primary_server, 'euro.aprs2.net', 'noam.aprs2.net', 'asia.aprs2.net'] + + connected = False + sock = None + + for server in servers_to_try: + if not self.chat_listeners.get(profile_name, {}).get('running', False): + return + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(8.0) + sock.connect((server, port)) + self.chat_listeners[profile_name]['socket'] = sock + connected = True + break + except: + continue + + if not connected or not sock: + self.root.after(30000, lambda: self.start_bg_chat_listener(profile_name) if profile_name not in self.chat_listeners else None) + return + + try: + sock.recv(1024) + + # Construct Range filter to receive position packets of surrounding stations + filter_str = f"b/{callsign}" + try: + profiles = get_profiles() + p_data = profiles.get(profile_name, {}) + lat = p_data.get('latitude') + lon = p_data.get('longitude') + if lat is not None and lon is not None: + filter_str += f" r/{lat}/{lon}/50" + except: + pass + + login_str = f"user {callsign} pass {passcode} vers PyAPRSBeacon 1.0 filter {filter_str}\r\n" + sock.sendall(login_str.encode('utf-8')) + sock.recv(1024) + + sock.settimeout(None) + buffer = "" + while self.chat_listeners.get(profile_name, {}).get('running', False): + data = sock.recv(4096) + if not data: + break + buffer += data.decode('utf-8', errors='ignore') + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line: + continue + + # Parse coordinates from packet to gather surrounding stations + try: + coords = parse_aprs_coordinates(line) + if coords and ">" in line: + lat_c, lon_c = coords + p_sender = line.split(">", 1)[0].strip() + if p_sender.upper() != callsign.upper(): + if not hasattr(self, 'nearby_stations'): + self.nearby_stations = {} + stations = self.nearby_stations.setdefault(profile_name, {}) + stations[p_sender.upper()] = (lat_c, lon_c, time.time()) + + # Prune stations older than 15 mins + now = time.time() + self.nearby_stations[profile_name] = { + k: v for k, v in stations.items() if now - v[2] < 900 + } + except: + pass + + if "::" in line: + try: + parts = line.split("::", 1) + header = parts[0] + payload = parts[1] + + sender = header.split(">", 1)[0].strip() + if ":" in payload: + recipient_part, msg_text = payload.split(":", 1) + recipient = recipient_part.strip() + msg_text = msg_text.strip() + + if recipient == callsign: + msg_id = "" + if "{" in msg_text: + p_parts = msg_text.rsplit("{", 1) + if len(p_parts) == 2 and p_parts[1].isalnum(): + msg_text = p_parts[0].strip() + msg_id = p_parts[1] + + if msg_id: + sender_padded = f"{sender:<9}" + ack_packet = f"{callsign}>APRS,TCPIP*::{sender_padded}:ack{msg_id}\r\n" + try: + sock.sendall(ack_packet.encode('utf-8')) + except: + pass + + self.save_chat_message(profile_name, sender, msg_text) + + if hasattr(self, 'active_chat_ui') and self.active_chat_ui and self.active_chat_ui.winfo_exists() and self.active_chat_ui.active_profile == profile_name: + self.active_chat_ui.append_incoming_message(sender, msg_text) + if self.active_chat_ui.to_entry.get().strip().upper() != sender.upper(): + if self.tray_icon: + disp_text = msg_text[:40] + "..." if len(msg_text) > 40 else msg_text + self.tray_icon.notify(f"From {sender}: {disp_text}", f"APRS Message ({profile_name})") + else: + self.unread_chats.setdefault(profile_name, set()).add(sender.upper()) + if self.root.winfo_exists(): + self.root.after(0, self.refresh_profiles) + if self.tray_icon: + disp_text = msg_text[:40] + "..." if len(msg_text) > 40 else msg_text + self.tray_icon.notify(f"From {sender}: {disp_text}", f"APRS Message ({profile_name})") + except: + pass + except: + pass + finally: + try: + sock.close() + except: + pass + if profile_name in self.chat_listeners: + del self.chat_listeners[profile_name] + self.root.after(10000, lambda: self.start_bg_chat_listener(profile_name)) + + def gateway_ping_loop(self): + while self.running: + start_time = time.time() + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2.0) + sock.connect(("rotate.aprs2.net", 14580)) + sock.close() + latency = int((time.time() - start_time) * 1000) + self.server_status = f"Gateway Status: Online ({latency}ms)" + self.server_status_color = self.c_green + except: + self.server_status = "Gateway Status: Offline" + self.server_status_color = self.c_red + + if self.running and self.root.winfo_exists(): + try: + self.root.after(0, self.update_server_status_ui) + except: + pass + # Update connection every 20 seconds + for _ in range(20): + if not self.running: + break + time.sleep(1) + + def update_server_status_ui(self): + self.gateway_status_lbl.configure(text=self.server_status, fg=self.server_status_color) + + def trigger_self_update(self): + if not gui_require_auth(self.root): + return + if messagebox.askyesno("Update App", "Would you like to fetch updates and update the software from GitHub?"): + profiles = get_profiles() + running_profiles = [name for name, data in profiles.items() if is_profile_running(name)] + for name in running_profiles: + stop_profile_service(name) + + success, msg = self_update() + if success: + messagebox.showinfo("Success", "Application updated successfully! The application will now restart.") + for name in running_profiles: + start_profile_service(name) + import sys + import os + python = sys.executable + os.execl(python, python, *sys.argv) + else: + messagebox.showerror("Error", msg) + for name in running_profiles: + start_profile_service(name) + + def trigger_export(self): + file_path = filedialog.asksaveasfilename( + defaultextension=".json", + filetypes=[("JSON Backup Files", "*.json")], + title="Export Backup Settings", + initialfile=f"aprs_config_backup_{datetime.now().strftime('%Y%m%d')}.json" + ) + if file_path: + success, msg = export_settings(file_path) + if success: + messagebox.showinfo("Success", msg) + else: + messagebox.showerror("Error", msg) + + def trigger_import(self): + if not gui_require_auth(self.root): + return + file_path = filedialog.askopenfilename( + filetypes=[("JSON Backup Files", "*.json")], + title="Import Settings Backup" + ) + if file_path: + success, msg = import_settings(file_path) + if success: + messagebox.showinfo("Success", msg) + self.refresh_profiles(force_rebuild=True) + self.update_tray_menu() + else: + messagebox.showerror("Error", msg) + +# CLI Interactive Mode & Helper +def cli_show_logs_interactive(profile_name): + profiles = get_profiles() + if profile_name not in profiles: + print(f"Error: Profile '{profile_name}' not found.") + return + log_path = os.path.join(LOGS_DIR, f"{profile_name}.log") + if not os.path.exists(log_path): + print("Log file not found. Daemon has not generated log events yet.") + return + print(f"\n--- {profile_name.upper()} LIVE LOG OUTPUT (Ctrl+C to stop) ---") + try: + with open(log_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + for line in lines[-20:]: + print(line, end='') + while True: + line = f.readline() + if not line: + time.sleep(1) + continue + print(line, end='') + except KeyboardInterrupt: + print("\nClosed live log monitor.") + +def cli_interactive_menu(): + while True: + print("\n=== APRS Multi-Beacon Manager (CLI) ===") + print("1) List Profiles") + print("2) Create New Profile") + print("3) Start Profile Daemon") + print("4) Stop Profile Daemon") + print("5) Delete Profile") + print("6) Live Log Monitor") + print("7) Edit Profile Settings") + print("8) Export Settings Backup") + print("9) Import Settings Backup") + print("10) Fetch Updates") + print("11) Exit") + print("-" * 45) + + choice = input("Choice [1-11]: ").strip() + if choice == '1': + cli_list() + elif choice == '2': + cli_create() + elif choice == '3': + name = input("Profile name to start: ").strip().lower() + if name: + cli_start(name) + elif choice == '4': + name = input("Profile name to stop: ").strip().lower() + if name: + cli_stop(name) + elif choice == '5': + name = input("Profile name to delete: ").strip().lower() + if name: + cli_delete(name) + elif choice == '6': + name = input("Profile name to view: ").strip().lower() + if name: + cli_show_logs_interactive(name) + elif choice == '7': + cli_edit() + elif choice == '8': + export_path = input("Export location [Default: ~/aprs_backup.json]: ").strip() + if not export_path: + export_path = os.path.expanduser("~/aprs_backup.json") + success, msg = export_settings(export_path) + print(f"[+] {msg}" if success else f"[-] {msg}") + elif choice == '9': + if not cli_require_auth(): + continue + import_path = input("Import file location: ").strip() + if import_path: + success, msg = import_settings(import_path) + print(f"[+] {msg}" if success else f"[-] {msg}") + elif choice == '10': + if not cli_require_auth(): + continue + print("[i] Checking updates...") + success, msg = self_update() + print(f"[+] {msg}" if success else f"[-] {msg}") + elif choice == '11' or choice.lower() == 'exit': + print("Exiting...") + break + else: + print("Invalid choice! Enter a selection option from 1 to 11.") + +# Entry Point / Argument Parsing +def main(): + parser = argparse.ArgumentParser(description="APRS Multi-Beacon Management Utility") + parser.add_argument('command', nargs='?', choices=['list', 'start', 'stop', 'create', 'delete', 'edit', 'export', 'import', 'update', 'gui'], default='gui', + help="Command to run (list, start, stop, create, delete, edit, export, import, update, gui)") + parser.add_argument('profile_name', nargs='?', help="Target profile name or file path for commands") + args = parser.parse_args() + + if args.command == 'list': + cli_list() + elif args.command == 'create': + cli_create() + elif args.command == 'edit': + cli_edit() + elif args.command == 'update': + if not cli_require_auth(): + return + print("[i] Checking updates...") + success, msg = self_update() + print(f"[+] {msg}" if success else f"[-] {msg}") + elif args.command == 'export': + path = args.profile_name + if not path: + path = os.path.expanduser("~/aprs_backup.json") + success, msg = export_settings(path) + print(f"[+] {msg}" if success else f"[-] {msg}") + elif args.command == 'import': + if not cli_require_auth(): + return + path = args.profile_name + if not path: + print("Error: Specify backup file directory path (e.g. aprs_manager import backup.json)") + else: + success, msg = import_settings(path) + print(f"[+] {msg}" if success else f"[-] {msg}") + elif args.command == 'delete': + if not args.profile_name: + print("Error: Specify profile name to delete (e.g. aprs_manager delete my_profile)") + else: + cli_delete(args.profile_name) + elif args.command == 'start': + if not args.profile_name: + print("Error: Specify profile name to start.") + else: + cli_start(args.profile_name) + elif args.command == 'stop': + if not args.profile_name: + print("Error: Specify profile name to stop.") + else: + cli_stop(args.profile_name) + elif args.command == 'gui': + is_headless = False + if IS_LINUX and 'DISPLAY' not in os.environ: + is_headless = True + + if not GUI_AVAILABLE or is_headless: + if is_headless: + print("\033[93m[!] X-server connection (DISPLAY) not detected. Defaulting to terminal mode.\033[0m") + else: + print("\033[91mError: GUI libraries (tkinter, pystray, pillow) not fully installed.\033[0m") + print("Launching terminal interactive interface...\n") + time.sleep(1) + cli_interactive_menu() + sys.exit(0) + + try: + root = tk.Tk() + app = APRSManagerGUI(root) + root.mainloop() + except Exception as e: + print(f"\033[91mCould not launch graphical window: {e}\033[0m") + print("Launching terminal interactive interface...\n") + time.sleep(1) + cli_interactive_menu() + +if __name__ == '__main__': + main() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a3d0571 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.8" + +services: + aprs-beacon-manager: + image: ghcr.io/mcturan/aprs:latest + container_name: aprs-beacon-manager + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + volumes: + - aprs-data:/root/.aprs-beacon + restart: unless-stopped + +volumes: + aprs-data: diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..3e18c4a --- /dev/null +++ b/install.ps1 @@ -0,0 +1,277 @@ +# Windows APRS Beacon Kurulum Sihirbazı +# Bu betik, Windows üzerinde arka planda sessizce (konsol penceresi açılmadan) +# çalışacak şekilde APRS beacon servisini kurar. + +Write-Host "================================================================" -ForegroundColor Cyan +Write-Host " Windows APRS Multi-Profile Beacon Sihirbazı" -ForegroundColor Cyan +Write-Host "================================================================" -ForegroundColor Cyan +Write-Host "Bu sihirbaz, APRS beacon'ınızı yapılandıracak ve bilgisayarınız" +Write-Host "başladığında arka planda otomatik çalışması için Görev Zamanlayıcı'ya ekleyecektir." +Write-Host "----------------------------------------------------------------`n" + +# Python kontrolü +try { + $pythonTest = python --version 2>$null + if ($null -eq $pythonTest) { + throw "Python bulunamadı" + } +} catch { + Write-Host "[!] Hata: Sistemde Python yüklü değil veya PATH'e eklenmemiş!" -ForegroundColor Red + Write-Host "Lütfen python.org sitesinden Python'ı indirin ve yüklerken 'Add Python to PATH' seçeneğini işaretleyin." -ForegroundColor Yellow + Exit +} + +$installDir = "$env:USERPROFILE\.aprs-beacon" +$profilesDir = "$installDir\profiles" +$logsDir = "$installDir\logs" + +if (!(Test-Path $installDir)) { New-Item -ItemType Directory -Force -Path $installDir | Out-Null } +if (!(Test-Path $profilesDir)) { New-Item -ItemType Directory -Force -Path $profilesDir | Out-Null } +if (!(Test-Path $logsDir)) { New-Item -ItemType Directory -Force -Path $logsDir | Out-Null } +if ((Test-Path ".\aprs_beacon.py") -and (Test-Path ".\.git")) { + $pwd.Path | Out-File -FilePath "$installDir\.repo_path" -Encoding utf8 +} else { + if (Test-Path "$installDir\.repo_path") { + Remove-Item -Path "$installDir\.repo_path" -Force -ErrorAction SilentlyContinue + } +} + +# GUI Bağımlılıkları Kurulumu (Windows için) +Write-Host "[i] Görsel arayüz için kütüphaneler kontrol ediliyor (pystray, Pillow)..." -ForegroundColor Blue +try { + python -c "import pystray, PIL" 2>$null + Write-Host "[+] Gerekli paketler kurulu." -ForegroundColor Green +} catch { + Write-Host "[!] Gerekli kütüphaneler kuruluyor (pystray, Pillow)..." -ForegroundColor Yellow + pip install pystray Pillow --quiet +} + +# 0. Profil Adı +Write-Host "`n=== 0. Profil Yapılandırması ===" -ForegroundColor Cyan +while ($true) { + $profileName = (Read-Host "Kurulacak Profil Adı [Varsayılan: default]").Trim().ToLower() + if ($profileName -eq "") { + $profileName = "default" + } + if ($profileName -match '^[a-z0-9_-]+$') { + break + } + Write-Host "Hata: Profil adı sadece küçük harf, rakam, tire veya alt çizgi içerebilir!" -ForegroundColor Red +} + +# 1. Çağrı İşareti +while ($true) { + $callsign = (Read-Host "1. Çağrı İşaretiniz (Örn: N0CALL-9)").Trim().ToUpper() + if ($callsign -ne "") { break } + Write-Host "Hata: Çağrı işareti boş olamaz!" -ForegroundColor Red +} + +# 2. Şifre (Passcode) +$passcode = (Read-Host "2. Şifreniz (APRS-IS Passcode) [Otomatik hesaplamak için Enter]").Trim() +if ($passcode -eq "") { + Write-Host "[i] Çağrı işaretinizden şifre otomatik hesaplanıyor..." -ForegroundColor Blue + $callsignClean = $callsign.Split("-")[0] + $hashVal = 0x73e2 + for ($i = 0; $i -lt $callsignClean.Length; $i += 2) { + $char1 = [int][char]$callsignClean[$i] -shl 8 + $char2 = 0 + if (($i + 1) -lt $callsignClean.Length) { + $char2 = [int][char]$callsignClean[$i+1] + } + $hashVal = $hashVal -bxor ($char1 + $char2) + } + $passcode = $hashVal -band 0x7fff + Write-Host "[+] Hesaplanan Şifre: $passcode" -ForegroundColor Green +} + +# 3. Mesaj +Write-Host "`n3. Durum Mesajı Ayarı:" -ForegroundColor Cyan +Write-Host " İpucu: Frekans ve ton bilgisi eklemek için mesajın başına ekleyin (Örn: 145.550MHz T088)" -ForegroundColor Yellow +Write-Host " İpucu: Haritada tıklanabilir link göstermek için 'https://' ekleyin (Örn: https://example.com)" -ForegroundColor Yellow +$comment = (Read-Host "Durum Mesajınız [Varsayılan: Windows APRS Beacon]").Trim() +if ($comment -eq "") { + $comment = "Windows APRS Beacon" +} + +# 4. Simge (Symbol) +Write-Host "`n4. Haritada görünecek Simgeyi seçin:" -ForegroundColor Cyan +Write-Host " 1) Helikopter (X) [Varsayılan]" +Write-Host " 2) Otomobil / Araba (>)" +Write-Host " 3) Yaya / Yürüyüşçü ([)" +Write-Host " 4) Ev / QTH İstasyonu (-)" +Write-Host " 5) Sabit Telsiz / Cihaz (#)" +$symChoice = (Read-Host "Seçiminiz [1-5]").Trim() + +$symbolTable = "/" +$symbolCode = "X" +switch ($symChoice) { + "2" { $symbolCode = ">" } + "3" { $symbolCode = "[" } + "4" { $symbolCode = "-" } + "5" { $symbolCode = "#" } + Default { $symbolCode = "X" } +} + +# 5. Konum (Location) +Write-Host "`n5. Konum ayarları:" -ForegroundColor Cyan +$autoLoc = (Read-Host "Sistem konumunuzu internet üzerinden otomatik tespit etsin mi? [Y/n]").Trim().ToLower() + +$latitude = "" +$longitude = "" + +if ($autoLoc -ne "n") { + Write-Host "[i] Konumunuz internet üzerinden otomatik tespit ediliyor..." -ForegroundColor Blue + $apis = @( + "http://ip-api.com/json", + "https://ipapi.co/json/" + ) + foreach ($api in $apis) { + try { + $ipLoc = Invoke-RestMethod -Uri $api -TimeoutSec 4 + if ($ipLoc.status -eq "success" -or $ipLoc.latitude -ne $null) { + $lat = if ($ipLoc.lat) { $ipLoc.lat } else { $ipLoc.latitude } + $lon = if ($ipLoc.lon) { $ipLoc.lon } else { $ipLoc.longitude } + $city = if ($ipLoc.city) { $ipLoc.city } else { "Bilinmiyor" } + $country = if ($ipLoc.country_name) { $ipLoc.country_name } else { $ipLoc.country } + + Write-Host "[+] Otomatik Konum Tespit Edildi: $city, $country ($lat, $lon)" -ForegroundColor Green + $latitude = $lat + $longitude = $lon + break + } + } catch { + continue + } + } + if ($latitude -eq "" -or $longitude -eq "") { + Write-Host "[!] Otomatik konum tespiti başarısız oldu." -ForegroundColor Red + } +} + +if ($latitude -eq "" -or $longitude -eq "") { + Write-Host "[i] Koordinatlarınızı kolayca bulabilmeniz için tarayıcıda OpenStreetMap açılıyor..." -ForegroundColor Blue + try { + Start-Process "https://www.openstreetmap.org" + } catch {} + + Write-Host "[!] Lütfen koordinatlarınızı manuel girin (Açılan haritadan enlem/boylam kopyalayın):" -ForegroundColor Yellow + while ($true) { + $latitude = (Read-Host " Enlem (Latitude, Örn: 41.037002)").Trim() + if ([double]::TryParse($latitude, [ref]0.0)) { break } + Write-Host "Hata: Geçersiz enlem değeri!" -ForegroundColor Red + } + while ($true) { + $longitude = (Read-Host " Boylam (Longitude, Örn: 28.985012)").Trim() + if ([double]::TryParse($longitude, [ref]0.0)) { break } + Write-Host "Hata: Geçersiz boylam değeri!" -ForegroundColor Red + } +} + +# 6. Sıklık (Interval) +while ($true) { + $intervalInput = (Read-Host "6. Kaç dakikada bir beacon gönderilsin? [Varsayılan: 5]").Trim() + if ($intervalInput -eq "") { + $interval = 5 + break + } + if ([int]::TryParse($intervalInput, [ref]0) -and [int]$intervalInput -ge 1) { + $interval = [int]$intervalInput + break + } + Write-Host "Hata: Aralık en az 1 dakika olmalıdır!" -ForegroundColor Red +} + +# 6.5 APRS Perşembe Etkinliği +$thursdayInput = (Read-Host "6.5 Her Perşembe APRS Perşembe etkinliğine katılım sağlansın mı? (ANSRVR) [y/N]").Trim().ToLower() +$aprsThursday = $false +if ($thursdayInput -eq "y") { + $aprsThursday = $true +} + +# Yapılandırmayı JSON olarak kaydet +$configJson = @" +{ + "callsign": "$callsign", + "passcode": $passcode, + "latitude": $latitude, + "longitude": $longitude, + "use_termux_gps": false, + "symbol_table": "$symbolTable", + "symbol_code": "$symbolCode", + "comment": "$comment", + "interval_minutes": $interval, + "aprs_thursday": $($aprsThursday.ToString().ToLower()), + "server": "rotate.aprs2.net", + "port": 14580 +} +"@ + +$configJson | Out-File -FilePath "$profilesDir\$profileName.json" -Encoding utf8 +Write-Host "`n[+] Yapılandırma dosyası kaydedildi: $profilesDir\$profileName.json" -ForegroundColor Green + +# Dosyaları kopyala veya indir +if (Test-Path ".\aprs_beacon.py") { + Copy-Item -Path ".\aprs_beacon.py" -Destination "$installDir\aprs_beacon.py" -Force +} else { + Write-Host "[i] aprs_beacon.py dosyası GitHub'dan indiriliyor..." -ForegroundColor Blue + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/mcturan/aprs/main/aprs_beacon.py" -OutFile "$installDir\aprs_beacon.py" -UseBasicParsing +} + +if (Test-Path ".\aprs_manager.py") { + Copy-Item -Path ".\aprs_manager.py" -Destination "$installDir\aprs_manager.py" -Force +} else { + Write-Host "[i] aprs_manager.py dosyası GitHub'dan indiriliyor..." -ForegroundColor Blue + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/mcturan/aprs/main/aprs_manager.py" -OutFile "$installDir\aprs_manager.py" -UseBasicParsing +} + +# Geriye dönük uyumluluk (Eski tekli config varsa taşıyalım) +if (Test-Path "$installDir\config.json") { + Move-Item -Path "$installDir\config.json" -Destination "$profilesDir\default.json" -Force -ErrorAction SilentlyContinue + Move-Item -Path "$installDir\aprs_beacon.log" -Destination "$logsDir\default.log" -Force -ErrorAction SilentlyContinue +} + +# Test Gönderimi +Write-Host "`n[i] Ayarların doğruluğunu onaylamak için test paketi gönderiliyor..." -ForegroundColor Blue +python "$installDir\aprs_beacon.py" --profile "$profileName" --once +if ($LASTEXITCODE -eq 0) { + Write-Host "[+] Test Başarılı! Konum paketi APRS-IS ağına iletildi." -ForegroundColor Green +} else { + Write-Host "[!] Test Başarısız! Paket sunucuya ulaştırılamadı." -ForegroundColor Red + $proceed = (Read-Host "Yine de devam etmek istiyor musunuz? [y/N]").Trim().ToLower() + if ($proceed -ne "y") { + Write-Host "Kurulum iptal edildi." + Exit + } +} + +# 7. Başlangıç Ayarı +$autoStart = (Read-Host "`n7. Sistem açılışında otomatik başlasın mı? [Y/n]").Trim().ToLower() + +# Windows Görev Zamanlayıcıya ekleme (Sadece kullanıcı oturum açtığında arka planda çalıştır) +if ($autoStart -ne "n") { + Write-Host "[i] Görev Zamanlayıcı ayarlanıyor..." -ForegroundColor Blue + + # pythonw.exe konsol ekranı açmadan Python çalıştırır + $action = New-ScheduledTaskAction -Execute "pythonw.exe" -Argument "`"$installDir\aprs_beacon.py`" --profile $profileName" + $trigger = New-ScheduledTaskTrigger -AtLogOn + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable + + # Görevi kaydet + Register-ScheduledTask -TaskName "APRSBeacon-$profileName" -Action $action -Trigger $trigger -Settings $settings -Description "APRS Background Beacon ($profileName)" -Force | Out-Null + + Write-Host "`n================================================================" -ForegroundColor Green + Write-Host " APRS WINDOWS SERVİSİ ($profileName) BAŞARIYLA AKTİF EDİLDİ!" -ForegroundColor Green -Bold + Write-Host "================================================================" -ForegroundColor Green + Write-Host "Profil Adı : $profileName" + Write-Host "Çağrı İşareti : $callsign" + Write-Host "Durum : Arka planda sessizce çalışacak (Görev Zamanlayıcı)" + Write-Host "Gönderim Sıklığı: $interval dakikada bir" + Write-Host "Otomatik Başlama: Kullanıcı oturum açtığında otomatik başlayacak." + Write-Host "----------------------------------------------------------------" + Write-Host "Yönetim Panelini açmak için: python `"$installDir\aprs_manager.py`" gui" + Write-Host "Canlı Log Takibi (PowerShell):" + Write-Host " Get-Content -Path `"$logsDir\$profileName.log`" -Wait -Tail 10" + Write-Host "================================================================" -ForegroundColor Green +} else { + Write-Host "[!] Görev zamanlayıcı kurulumu atlandı." -ForegroundColor Yellow +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1eb3d45 --- /dev/null +++ b/install.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash + +# Terminal Colors +C_GREEN='\033[92m' +C_YELLOW='\033[93m' +C_BLUE='\033[94m' +C_CYAN='\033[96m' +C_RED='\033[91m' +C_BOLD='\033[1m' +C_RESET='\033[0m' + +clear +echo -e "${C_CYAN}${C_BOLD}" +echo " _ ___ ___ ___ ___ " +echo " /_\ | _ \| _ \/ __| | _ ) ___ __ _ __ ___ _ _ " +echo " / _ \| _/| /\__ \ | _ \/ -_) _\` / _/ _ \ ' \ " +echo "/_/ \_\_| |_|_\|___/ |___/\___\__,_\__\___/_||_| " +echo -e " Linux/Android APRS Multi-Profile Beacon Setup Wizard${C_RESET}\n" +echo "----------------------------------------------------------------" +echo "This wizard will help you configure your APRS beacon as a" +echo "background service and manage it via the control panel." +echo "----------------------------------------------------------------\n" + +# Check and install Python3 +if ! command -v python3 &> /dev/null; then + echo -e "${C_YELLOW}[!] Python3 not found on the system. Installing...${C_RESET}" + if command -v apt-get &> /dev/null; then + sudo apt-get update && sudo apt-get install -y python3 + else + echo -e "${C_RED}[!] Error: Python3 could not be installed automatically. Please install it manually.${C_RESET}" + exit 1 + fi +fi + +INSTALL_DIR="$HOME/.aprs-beacon" +PROFILES_DIR="$INSTALL_DIR/profiles" +LOGS_DIR="$INSTALL_DIR/logs" +mkdir -p "$PROFILES_DIR" "$LOGS_DIR" +echo "$(pwd)" > "$INSTALL_DIR/.repo_path" + +# GUI Dependencies Installation (for Linux) +HAS_GUI=true +# Graphic environment check +if [ -z "$DISPLAY" ]; then + HAS_GUI=false + echo -e "${C_YELLOW}[!] Display environment (DISPLAY) not detected. Performing headless install.${C_RESET}" +fi + +if [ "$HAS_GUI" = true ]; then + echo -e "${C_BLUE}[i] Checking GUI, System Tray, and Image libraries (pystray, tkinter, pillow)...${C_RESET}" + if ! python3 -c "import tkinter; import pystray; import PIL" &>/dev/null; then + echo -e "${C_YELLOW}[!] Required GUI packages are missing. Installing...${C_RESET}" + if command -v apt-get &> /dev/null; then + echo -e "${C_BLUE}[i] Administrative privileges (sudo) may be requested:${C_RESET}" + sudo apt-get update && sudo apt-get install -y python3-tk python3-pystray python3-pil + else + echo -e "${C_RED}[!] Error: Package manager (apt-get) not found. Please install python3-tk, python3-pystray, and python3-pil manually.${C_RESET}" + fi + else + echo -e "${C_GREEN}[+] Required GUI packages are ready.${C_RESET}" + fi +fi + +# 0. Profile Name +echo -e "\n=== 0. Profile Configuration ===" +while true; do + read -p "Profile Name to Install (e.g. mobile, qth, default) [Default: default]: " PROFILE_NAME + PROFILE_NAME=$(echo "$PROFILE_NAME" | tr 'A-Z' 'a-z' | xargs) + if [ -z "$PROFILE_NAME" ]; then + PROFILE_NAME="default" + fi + if [[ "$PROFILE_NAME" =~ ^[a-z0-9_-]+$ ]]; then + break + fi + echo -e "${C_RED}Error: Profile name can only contain lowercase letters, numbers, hyphens, or underscores!${C_RESET}" +done + +# 1. Callsign +while true; do + read -p "1. Callsign (e.g. N0CALL-9): " CALLSIGN + CALLSIGN=$(echo "$CALLSIGN" | tr 'a-z' 'A-Z' | xargs) + if [ -n "$CALLSIGN" ]; then + break + fi + echo -e "${C_RED}Error: Callsign cannot be empty!${C_RESET}" +done + +# 2. APRS-IS Passcode +read -p "2. Password (APRS-IS Passcode) [Press Enter to calculate automatically]: " PASSCODE +PASSCODE=$(echo "$PASSCODE" | xargs) +if [ -z "$PASSCODE" ]; then + echo -e "${C_BLUE}[i] Calculating passcode automatically from callsign...${C_RESET}" + PASSCODE=$(python3 -c " +callsign = '${CALLSIGN}'.split('-')[0] +hash_val = 0x73e2 +for i in range(0, len(callsign), 2): + char1 = ord(callsign[i]) << 8 + char2 = ord(callsign[i+1]) if (i + 1 < len(callsign)) else 0 + hash_val ^= (char1 + char2) +print(hash_val & 0x7fff) +") + echo -e "${C_GREEN}[+] Calculated Passcode: $PASSCODE${C_RESET}" +fi + +# 3. Status Comment +echo -e "\n3. Status Comment Setting:" +echo -e " ${C_YELLOW}Tip: Add frequency and tone info at start of message (e.g. 145.550MHz T088)${C_RESET}" +echo -e " ${C_YELLOW}Tip: Add 'https://' to show a clickable link on the map (e.g. https://example.com)${C_RESET}" +read -p "Status Comment [Default: APRS Background Beacon]: " COMMENT +COMMENT=$(echo "$COMMENT" | xargs) +if [ -z "$COMMENT" ]; then + COMMENT="APRS Background Beacon" +fi + +# 4. Symbol +echo -e "\n4. Select Symbol to Display on Map:" +echo " 1) Helicopter (X) [Default]" +echo " 2) Car / Vehicle (>)" +echo " 3) Pedestrian / Hiker ([)" +echo " 4) House / QTH Station (-)" +echo " 5) Fixed Radio / Station (#)" +echo " 6) Other (Enter your own character)" +read -p "Choice [1-6]: " SYM_CHOICE +SYM_CHOICE=$(echo "$SYM_CHOICE" | xargs) + +SYMBOL_TABLE="/" +SYMBOL_CODE="X" + +case "$SYM_CHOICE" in + 2) SYMBOL_CODE=">" ;; + 3) SYMBOL_CODE="[" ;; + 4) SYMBOL_CODE="-" ;; + 5) SYMBOL_CODE="#" ;; + 6) + read -p "Enter Symbol Character (e.g. > or [ or X): " CUSTOM_SYM + SYMBOL_CODE=$(echo "$CUSTOM_SYM" | xargs) + if [ -z "$SYMBOL_CODE" ]; then + SYMBOL_CODE="X" + fi + ;; + *) SYMBOL_CODE="X" ;; +esac + +LATITUDE="" +LONGITUDE="" + +# 5. Coordinates +read -p "5. Do you want to automatically detect your coordinates via IP? [Y/n]: " AUTO_LOC +AUTO_LOC=$(echo "$AUTO_LOC" | tr 'A-Z' 'a-z' | xargs) + +if [ "$AUTO_LOC" != "n" ]; then + echo -e "${C_BLUE}[i] Detecting coordinates via IP...${C_RESET}" + IP_LOC=$(python3 -c " +import urllib.request, json +urls = ['http://ip-api.com/json', 'https://ipapi.co/json/'] +for url in urls: + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=4) as r: + d = json.loads(r.read().decode('utf-8')) + if d.get('status') == 'success' or 'latitude' in d: + lat = d.get('lat') or d.get('latitude') + lon = d.get('lon') or d.get('longitude') + city = d.get('city') or 'Unknown' + country = d.get('country_name') or d.get('country') or 'Unknown' + print(f'{lat},{lon},{city},{country}') + break + except: + continue +") + if [ -n "$IP_LOC" ]; then + IFS=',' read -r LAT LON CITY COUNTRY <<< "$IP_LOC" + echo -e "${C_GREEN}[+] Automatically Detected Location: $CITY, $COUNTRY ($LAT, $LON)${C_RESET}" + LATITUDE="$LAT" + LONGITUDE="$LON" + else + echo -e "${C_RED}[!] IP location detection failed.${C_RESET}" + fi +fi + +if [ -z "$LATITUDE" ] || [ -z "$LONGITUDE" ]; then + echo -e "${C_BLUE}[i] Opening OpenStreetMap in browser to help find coordinates...${C_RESET}" + python3 -m webbrowser "https://www.openstreetmap.org" &>/dev/null & + + echo -e "${C_YELLOW}[!] Please enter coordinates manually (copy latitude/longitude from map):${C_RESET}" + while true; do + read -p " Latitude (e.g. 41.037002): " LATITUDE + LATITUDE=$(echo "$LATITUDE" | xargs) + if python3 -c "float('$LATITUDE')" &>/dev/null; then + break + fi + echo -e "${C_RED}Error: Invalid latitude value!${C_RESET}" + done + while true; do + read -p " Longitude (e.g. 28.985012): " LONGITUDE + LONGITUDE=$(echo "$LONGITUDE" | xargs) + if python3 -c "float('$LONGITUDE')" &>/dev/null; then + break + fi + echo -e "${C_RED}Error: Invalid longitude value!${C_RESET}" + done +fi + +# 6. Interval +while true; do + read -p "6. How often should beacons be sent (minutes)? [Default: 5]: " INTERVAL_MINUTES + INTERVAL_MINUTES=$(echo "$INTERVAL_MINUTES" | xargs) + if [ -z "$INTERVAL_MINUTES" ]; then + INTERVAL_MINUTES=5 + break + fi + if python3 -c "int('$INTERVAL_MINUTES') >= 1" &>/dev/null; then + break + fi + echo -e "${C_RED}Error: Interval must be at least 1 minute!${C_RESET}" +done + +# 6.5 APRS Thursday +read -p "6.5 Join APRS Thursday event? (ANSRVR) [y/N]: " OPT_THURSDAY +OPT_THURSDAY=$(echo "$OPT_THURSDAY" | tr 'A-Z' 'a-z' | xargs) +APRS_THURSDAY="false" +if [ "$OPT_THURSDAY" = "y" ]; then + APRS_THURSDAY="true" +fi + +# Save Configuration +cat < "$PROFILES_DIR/$PROFILE_NAME.json" +{ + "callsign": "$CALLSIGN", + "passcode": $PASSCODE, + "latitude": $LATITUDE, + "longitude": $LONGITUDE, + "use_termux_gps": false, + "symbol_table": "$SYMBOL_TABLE", + "symbol_code": "$SYMBOL_CODE", + "comment": "$COMMENT", + "interval_minutes": $INTERVAL_MINUTES, + "aprs_thursday": $APRS_THURSDAY, + "server": "rotate.aprs2.net", + "port": 14580 +} +EOF + +echo -e "\n${C_GREEN}[+] Configuration file saved: $PROFILES_DIR/$PROFILE_NAME.json${C_RESET}" + +# Copy / Download files +echo -e "${C_BLUE}[i] Installing application scripts...${C_RESET}" +if [ -f "./aprs_beacon.py" ]; then + cp "./aprs_beacon.py" "$INSTALL_DIR/aprs_beacon.py" +else + curl -sL -o "$INSTALL_DIR/aprs_beacon.py" "https://raw.githubusercontent.com/mcturan/aprs/main/aprs_beacon.py" +fi +chmod +x "$INSTALL_DIR/aprs_beacon.py" + +if [ -f "./aprs_manager.py" ]; then + cp "./aprs_manager.py" "$INSTALL_DIR/aprs_manager.py" +else + curl -sL -o "$INSTALL_DIR/aprs_manager.py" "https://raw.githubusercontent.com/mcturan/aprs/main/aprs_manager.py" +fi +chmod +x "$INSTALL_DIR/aprs_manager.py" + +# Backward compatibility (migration of old single config) +if [ -f "$INSTALL_DIR/config.json" ]; then + mv "$INSTALL_DIR/config.json" "$PROFILES_DIR/default.json" 2>/dev/null + mv "$INSTALL_DIR/aprs_beacon.log" "$LOGS_DIR/default.log" 2>/dev/null +fi + +# Send Test Beacon +echo -e "\n${C_BLUE}[i] Sending test beacon to verify settings...${C_RESET}" +python3 "$INSTALL_DIR/aprs_beacon.py" --profile "$PROFILE_NAME" --once +if [ $? -eq 0 ]; then + echo -e "${C_GREEN}[+] Test Successful! Position packet transmitted to APRS-IS.${C_RESET}" +else + echo -e "${C_RED}[!] Test Failed! Could not transmit packet to server.${C_RESET}" + echo -e "${C_YELLOW}[i] For details: cat $LOGS_DIR/$PROFILE_NAME.log${C_RESET}" + read -p "Do you want to proceed anyway? [y/N]: " PROCEED + PROCEED=$(echo "$PROCEED" | tr 'A-Z' 'a-z' | xargs) + if [ "$PROCEED" != "y" ]; then + echo "Setup cancelled." + exit 1 + fi +fi + +# Linux systemd startup +echo -e "\n7. Startup Settings (Linux):" +read -p "Start this profile automatically on system boot? [Y/n]: " AUTO_START +AUTO_START=$(echo "$AUTO_START" | tr 'A-Z' 'a-z' | xargs) + +# Generate systemd user service template +SYSTEMD_USER_DIR="$HOME/.config/systemd/user" +mkdir -p "$SYSTEMD_USER_DIR" + +cat < "$SYSTEMD_USER_DIR/aprs-beacon@.service" +[Unit] +Description=APRS Background Beacon Service (%i) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=$(which python3) $INSTALL_DIR/aprs_beacon.py --profile %i +Restart=always +RestartSec=30 +WorkingDirectory=$INSTALL_DIR + +[Install] +WantedBy=default.target +EOF + +systemctl --user daemon-reload + +if [ "$AUTO_START" != "n" ]; then + echo -e "${C_BLUE}[i] Enabling and starting daemon service...${C_RESET}" + systemctl --user enable aprs-beacon@$PROFILE_NAME.service + systemctl --user restart aprs-beacon@$PROFILE_NAME.service + loginctl enable-linger "$USER" 2>/dev/null +fi + +# Create desktop shortcut launcher (if GUI available) +if [ "$HAS_GUI" = true ]; then + DESKTOP_DIR="$HOME/.local/share/applications" + mkdir -p "$DESKTOP_DIR" + cat < "$DESKTOP_DIR/aprs-manager.desktop" +[Desktop Entry] +Type=Application +Name=APRS Multi-Beacon Manager +Comment=APRS Multi-Profile Background Beacon Manager +Exec=$INSTALL_DIR/aprs_manager.py gui +Icon=radio +Terminal=false +Categories=Utility;Network; +EOF + chmod +x "$DESKTOP_DIR/aprs-manager.desktop" + + # Add manager to session autostart + AUTOSTART_DIR="$HOME/.config/autostart" + mkdir -p "$AUTOSTART_DIR" + cp "$DESKTOP_DIR/aprs-manager.desktop" "$AUTOSTART_DIR/" +fi + +# Post-install info summary +echo -e "\n${C_GREEN}${C_BOLD}================================================================${C_RESET}" +echo -e "${C_GREEN}${C_BOLD} APRS PROFILE ($PROFILE_NAME) SUCCESSFULLY ACTIVATED!${C_RESET}" +echo -e "${C_GREEN}${C_BOLD}================================================================${C_RESET}" +echo -e "${C_BOLD}Profile Name :${C_RESET} $PROFILE_NAME" +echo -e "${C_BOLD}Callsign :${C_RESET} $CALLSIGN" +echo -e "${C_BOLD}Interval :${C_RESET} Every $INTERVAL_MINUTES minute(s)" +echo -e "${C_BOLD}APRS Thursday :${C_RESET} $([ "$APRS_THURSDAY" = "true" ] && echo "Enabled" || echo "Disabled")" +echo -e "${C_BOLD}Autostart on Boot:${C_RESET} Yes" +echo -e "----------------------------------------------------------------" +if [ "$HAS_GUI" = true ]; then + echo -e "${C_CYAN}Desktop Interface :${C_RESET} Launch 'APRS Multi-Beacon Manager' from application menu." + echo -e "${C_CYAN}System Tray Icon :${C_RESET} System tray icon will appear upon launching." +else + echo -e "${C_YELLOW}Note: No graphic display detected. Launch control panel from terminal.${C_RESET}" +fi +echo -e "${C_CYAN}CLI / Terminal Management:${C_RESET}" +echo -e " - Open Interactive Menu: python3 $INSTALL_DIR/aprs_manager.py" +echo -e " - List Beacon Profiles: python3 $INSTALL_DIR/aprs_manager.py list" +echo -e " - Start Profile Daemon: python3 $INSTALL_DIR/aprs_manager.py start $PROFILE_NAME" +echo -e " - Stop Profile Daemon: python3 $INSTALL_DIR/aprs_manager.py stop $PROFILE_NAME" +echo -e "${C_GREEN}${C_BOLD}================================================================${C_RESET}\n" diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..5c27ef3 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,1346 @@ + + + + + + APRS Multi-Beacon Control Center + + + + + + + + +
+
+

APRS MULTI-BEACON CONTROL CENTER

+

Real-Time Profile & Daemon Management

+
+
+ + + + +
+
+ + +
+
+ Profiles: + 0 +
+
+ Active Beacons: + 0 +
+
+ APRS Gateway Link: + Checking... +
+
+ + +
+ + + + + + + + +
+ +
+
+ + +
+ APRS Multi-Beacon Control Center | by TA1XTA +
+ + + + + + + + + + + + + diff --git a/web_manager.py b/web_manager.py new file mode 100644 index 0000000..8d6ca7e --- /dev/null +++ b/web_manager.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +import os +import sys +import json +import socket +import subprocess +import threading +import time +from datetime import datetime +from flask import Flask, jsonify, request, render_template + +# Ensure path to local modules is set +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Import configuration constants and helper functions from aprs_manager +from aprs_manager import ( + BASE_DIR, PROFILES_DIR, LOGS_DIR, + get_profiles, save_profile, delete_profile_files, + generate_aprs_passcode, send_aprs_message, get_packet_count, + is_service_enabled, set_service_enabled +) + +app = Flask(__name__) + +# Subprocess tracking for Docker / Non-systemd environments +PIDS_FILE = os.path.join(BASE_DIR, 'pids.json') +chat_history = {} # callsign -> list of message dicts +active_listeners = {} # callsign -> { 'thread': t, 'socket': s, 'last_poll': timestamp } + +def get_running_subprocesses(): + if not os.path.exists(PIDS_FILE): + return {} + try: + with open(PIDS_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {} + +def save_running_subprocesses(pids): + try: + with open(PIDS_FILE, 'w', encoding='utf-8') as f: + json.dump(pids, f, indent=4) + except: + pass + +def is_subprocess_running(profile_name): + pids = get_running_subprocesses() + pid = pids.get(profile_name) + if not pid: + return False + try: + os.kill(pid, 0) + return True + except OSError: + if profile_name in pids: + del pids[profile_name] + save_running_subprocesses(pids) + return False + +def is_docker(): + return os.path.exists('/.dockerenv') or os.environ.get('DOCKER_MODE') == 'true' + +def is_profile_running(profile_name): + if is_docker(): + return is_subprocess_running(profile_name) + try: + res = subprocess.run(['systemctl', '--user', 'is-active', f'aprs-beacon@{profile_name}.service'], capture_output=True, text=True) + return res.stdout.strip() == 'active' + except: + return is_subprocess_running(profile_name) + +def start_profile_service(profile_name): + if is_docker(): + if is_subprocess_running(profile_name): + return True + log_file = os.path.join(LOGS_DIR, f'{profile_name}.log') + os.makedirs(os.path.dirname(log_file), exist_ok=True) + + beacon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'aprs_beacon.py') + if not os.path.exists(beacon_path): + beacon_path = os.path.join(BASE_DIR, 'aprs_beacon.py') + + with open(log_file, 'a') as log_out: + proc = subprocess.Popen( + [sys.executable, beacon_path, '--profile', profile_name], + stdout=log_out, + stderr=log_out, + start_new_session=True + ) + pids = get_running_subprocesses() + pids[profile_name] = proc.pid + save_running_subprocesses(pids) + return True + else: + try: + from aprs_manager import ensure_linux_systemd_template + ensure_linux_systemd_template() + subprocess.run(['systemctl', '--user', 'enable', f'aprs-beacon@{profile_name}.service'], check=True) + subprocess.run(['systemctl', '--user', 'start', f'aprs-beacon@{profile_name}.service'], check=True) + return True + except: + # Fallback to subprocess + return start_profile_service(profile_name) + +def stop_profile_service(profile_name): + if is_docker(): + pids = get_running_subprocesses() + pid = pids.get(profile_name) + if pid: + try: + import signal + os.kill(pid, signal.SIGTERM) + time.sleep(0.5) + except OSError: + pass + if profile_name in pids: + del pids[profile_name] + save_running_subprocesses(pids) + return True + else: + try: + subprocess.run(['systemctl', '--user', 'stop', f'aprs-beacon@{profile_name}.service']) + subprocess.run(['systemctl', '--user', 'disable', f'aprs-beacon@{profile_name}.service']) + return True + except: + return stop_profile_service(profile_name) + +# --- Chat Listener Management --- +def add_chat_msg(my_callsign, sender, text): + my_callsign = my_callsign.upper() + if my_callsign not in chat_history: + chat_history[my_callsign] = [] + chat_history[my_callsign].append({ + "timestamp": datetime.now().strftime('%H:%M:%S'), + "sender": sender, + "text": text + }) + +def parse_incoming_line(my_callsign, line): + if "::" in line: + try: + parts = line.split("::", 1) + header = parts[0] + payload = parts[1] + sender = header.split(">", 1)[0].strip() + if ":" in payload: + recipient_part, msg_text = payload.split(":", 1) + recipient = recipient_part.strip() + msg_text = msg_text.strip() + if recipient.upper() == my_callsign.upper(): + add_chat_msg(my_callsign, sender, msg_text) + except: + pass + +def chat_listener_worker(callsign, passcode): + callsign = callsign.upper() + server = "rotate.aprs2.net" + port = 14580 + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10.0) + sock.connect((server, port)) + + if callsign in active_listeners: + active_listeners[callsign]['socket'] = sock + + sock.recv(1024) + login_str = f"user {callsign} pass {passcode} vers PyAPRSWeb 1.0 filter b/{callsign}\r\n" + sock.sendall(login_str.encode('utf-8')) + sock.recv(1024) + + add_chat_msg(callsign, "SYSTEM", f"Connected as {callsign}. Real-time chat active.") + + sock.settimeout(None) + buffer = "" + while callsign in active_listeners: + # Garbage collect if client hasn't polled in 30 seconds + if time.time() - active_listeners[callsign]['last_poll'] > 30.0: + add_chat_msg(callsign, "SYSTEM", "Chat connection closed due to inactivity.") + break + + data = sock.recv(4096) + if not data: + break + buffer += data.decode('utf-8', errors='ignore') + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line: + continue + parse_incoming_line(callsign, line) + except Exception as e: + add_chat_msg(callsign, "SYSTEM", f"Connection closed: {e}") + finally: + if sock: + try: + sock.close() + except: + pass + if callsign in active_listeners: + del active_listeners[callsign] + +def manage_chat_listener(callsign, passcode): + callsign = callsign.upper() + if callsign in active_listeners: + active_listeners[callsign]['last_poll'] = time.time() + return + + t = threading.Thread(target=chat_listener_worker, args=(callsign, passcode), daemon=True) + active_listeners[callsign] = { + 'thread': t, + 'socket': None, + 'last_poll': time.time() + } + t.start() + +# --- Flask Endpoints --- + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/api/status') +def get_global_status(): + profiles = get_profiles() + active_count = sum(1 for name in profiles.keys() if is_profile_running(name)) + + # Check APRS server latency + latency = "Offline" + try: + start = time.time() + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2.0) + sock.connect(("rotate.aprs2.net", 14580)) + sock.close() + latency = f"{int((time.time() - start) * 1000)}ms" + except: + pass + + return jsonify({ + "total_profiles": len(profiles), + "active_beacons": active_count, + "latency": latency + }) + +@app.route('/api/profiles') +def api_profiles(): + profiles = get_profiles() + result = [] + for name, data in profiles.items(): + running = is_profile_running(name) + enabled = is_service_enabled(name) + packets = get_packet_count(name) + + result.append({ + "name": name, + "callsign": data.get("callsign", ""), + "passcode": data.get("passcode", ""), + "latitude": data.get("latitude", 0), + "longitude": data.get("longitude", 0), + "symbol": data.get("symbol_code", "X"), + "comment": data.get("comment", ""), + "interval": data.get("interval_minutes", 5), + "aprs_thursday": data.get("aprs_thursday", False), + "aprs_thursday_time": data.get("aprs_thursday_time", "20:00"), + "aprs_thursday_msg": data.get("aprs_thursday_msg", "CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY"), + "running": running, + "autostart": enabled, + "packets_sent": packets + }) + return jsonify(result) + +@app.route('/api/profiles/save', methods=['POST']) +def api_save_profile(): + req = request.json + name = req.get("name", "").strip().lower() + callsign = req.get("callsign", "").strip().upper() + passcode_in = req.get("passcode", "") + lat = float(req.get("latitude", 0)) + lon = float(req.get("longitude", 0)) + comment = req.get("comment", "").strip() or "APRS Background Beacon" + symbol = req.get("symbol", "X").strip() or "X" + interval = int(req.get("interval", 5)) + aprs_thursday = bool(req.get("aprs_thursday", False)) + aprs_thursday_time = req.get("aprs_thursday_time", "20:00").strip() + aprs_thursday_msg = req.get("aprs_thursday_msg", "").strip() or "CQ HOTG 73 FROM TURKIYE #APRSTHURSDAY" + + if not name or not callsign: + return jsonify({"success": False, "message": "Profile name and callsign are required."}), 400 + + if not passcode_in: + passcode = generate_aprs_passcode(callsign) + else: + try: + passcode = int(passcode_in) + except: + return jsonify({"success": False, "message": "Invalid passcode format."}), 400 + + data = { + "callsign": callsign, + "passcode": passcode, + "latitude": lat, + "longitude": lon, + "use_termux_gps": False, + "symbol_table": "/", + "symbol_code": symbol, + "comment": comment, + "interval_minutes": interval, + "aprs_thursday": aprs_thursday, + "aprs_thursday_time": aprs_thursday_time, + "aprs_thursday_msg": aprs_thursday_msg, + "server": "rotate.aprs2.net", + "port": 14580 + } + + was_running = is_profile_running(name) + save_profile(name, data) + + # Apply autostart configurations + autostart = bool(req.get("autostart", False)) + set_service_enabled(name, autostart) + + if was_running: + stop_profile_service(name) + time.sleep(0.5) + start_profile_service(name) + + return jsonify({"success": True, "message": f"Profile '{name}' saved successfully."}) + +@app.route('/api/profiles//delete', methods=['POST']) +def api_delete_profile(name): + stop_profile_service(name) + delete_profile_files(name) + return jsonify({"success": True, "message": f"Profile '{name}' deleted."}) + +@app.route('/api/profiles//toggle', methods=['POST']) +def api_toggle_profile(name): + running = is_profile_running(name) + if running: + success = stop_profile_service(name) + msg = f"Profile '{name}' stopped." + else: + success = start_profile_service(name) + msg = f"Profile '{name}' started." + return jsonify({"success": success, "message": msg}) + +@app.route('/api/profiles//autostart', methods=['POST']) +def api_autostart_profile(name): + req = request.json + enable = bool(req.get("enable", False)) + set_service_enabled(name, enable) + return jsonify({"success": True, "message": f"Autostart {'enabled' if enable else 'disabled'} for '{name}'."}) + +@app.route('/api/profiles//logs') +def api_profile_logs(name): + log_file = os.path.join(LOGS_DIR, f'{name}.log') + if not os.path.exists(log_file): + return jsonify({"logs": "No logs found for this profile yet."}) + try: + with open(log_file, 'r', encoding='utf-8') as f: + lines = f.readlines()[-100:] + return jsonify({"logs": "".join(lines)}) + except Exception as e: + return jsonify({"logs": f"Error reading logs: {e}"}) + +@app.route('/api/chat/messages') +def api_chat_messages(): + profile = request.args.get("profile", "").strip().lower() + profiles = get_profiles() + if profile not in profiles: + return jsonify({"success": False, "message": "Profile not found."}), 404 + + data = profiles[profile] + callsign = data.get("callsign", "").upper() + passcode = data.get("passcode") + if not passcode: + passcode = generate_aprs_passcode(callsign) + + manage_chat_listener(callsign, passcode) + + msgs = chat_history.get(callsign, []) + return jsonify(msgs) + +@app.route('/api/chat/send', methods=['POST']) +def api_chat_send(): + req = request.json + profile = req.get("profile", "").strip().lower() + to_callsign = req.get("to_callsign", "").strip().upper() + message = req.get("message", "").strip() + + profiles = get_profiles() + if profile not in profiles: + return jsonify({"success": False, "message": "Profile not found."}), 404 + + data = profiles[profile] + from_callsign = data.get("callsign", "").upper() + passcode = data.get("passcode") + if not passcode: + passcode = generate_aprs_passcode(from_callsign) + + if not to_callsign or not message: + return jsonify({"success": False, "message": "Recipient and message text are required."}), 400 + + success, res_msg = send_aprs_message(from_callsign, passcode, to_callsign, message) + if success: + add_chat_msg(from_callsign, "YOU", f"To {to_callsign}: {message}") + return jsonify({"success": True}) + else: + return jsonify({"success": False, "message": res_msg}) + +if __name__ == '__main__': + port = int(os.environ.get("PORT", 8080)) + app.run(host='0.0.0.0', port=port, debug=False)