Files
APRS_BEACON/aprs_manager.py
T
suphicakir 96b396149e initial commit
initial commit
2026-07-13 22:20:40 +03:00

2829 lines
126 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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("<Enter>", lambda e: btn.configure(bg=color_hover))
btn.bind("<Leave>", 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(
"<Configure>",
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('<Configure>', _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("<FocusIn>", ent_focus_in)
self.map_search_entry.bind("<FocusOut>", ent_focus_out)
self.map_search_entry.bind("<Return>", 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("<Enter>", lambda e: self.map_search_btn.configure(bg="#059669"))
self.map_search_btn.bind("<Leave>", 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("<Enter>", lambda e: self.map_browser_btn.configure(bg="#1d4ed8"))
self.map_browser_btn.bind("<Leave>", 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("<<NotebookTabChanged>>", 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("<ButtonPress-1>", on_press)
lbl_part2.bind("<ButtonRelease-1>", on_release)
lbl_part2.bind("<Leave>", 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("<Enter>", lambda e, b=btn, h=toggle_hover: b.configure(bg=h))
btn.bind("<Leave>", 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("<Button-1>", 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("<Enter>", lambda e: btn.configure(bg=color_hover))
btn.bind("<Leave>", 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("<FocusIn>", to_focus_in)
self.chat_to_entry.bind("<FocusOut>", to_focus_out)
self.chat_to_entry.bind("<Return>", 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("<<ListboxSelect>>", 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("<KeyRelease>", validate_msg)
self.chat_msg_entry.bind("<Return>", 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("<Enter>", lambda e: self.chat_send_btn.configure(bg="#059669"))
self.chat_send_btn.bind("<Leave>", 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("<FocusIn>", on_focus_in)
ent.bind("<FocusOut>", 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("<FocusIn>", lambda e: comment_ent.configure(highlightbackground=self.c_accent_cyan))
comment_ent.bind("<FocusOut>", 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("<FocusIn>", lambda e: thurs_msg_ent.configure(highlightbackground=self.c_accent_cyan))
thurs_msg_ent.bind("<FocusOut>", 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("<Enter>", lambda e: save_btn.configure(bg="#059669"))
save_btn.bind("<Leave>", 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()