mirror of
https://github.com/TheCommsChannel/TC2-APRS-BBS.git
synced 2025-02-05 09:55:27 -05:00
![TC²](/assets/img/avatar_default.png)
Added slight delay to ack now that acks are being requested due to the Message ID addition towork around apps and duplicate messages
279 lines
11 KiB
Python
279 lines
11 KiB
Python
import os
|
|
import json
|
|
import random
|
|
import string
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from threading import Lock
|
|
|
|
import aprs
|
|
import requests
|
|
|
|
import commands
|
|
import config
|
|
|
|
|
|
# Global dictionary to track unacknowledged messages
|
|
unacknowledged_messages = {}
|
|
unack_lock = Lock()
|
|
|
|
# Message numbering for ACKS
|
|
message_counter = 1
|
|
message_lock = threading.Lock()
|
|
|
|
|
|
JSON_URL = "https://aprs-deviceid.aprsfoundation.org/tocalls.pretty.json"
|
|
|
|
def fetch_device_data():
|
|
local_file = "tocalls_cache.json"
|
|
|
|
if os.path.exists(local_file):
|
|
try:
|
|
if time.time() - os.path.getmtime(local_file) > 7 * 24 * 60 * 60: # Check if the JSON is older than 7 days
|
|
print("Cache is outdated. Refreshing...")
|
|
raise Exception("Cache expired")
|
|
|
|
with open(local_file, "r") as file:
|
|
print("Loading device data from cache...")
|
|
return json.load(file)
|
|
except Exception as e:
|
|
print(f"Error with cache: {e}")
|
|
|
|
print("Fetching device data online...")
|
|
try:
|
|
response = requests.get(JSON_URL)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
with open(local_file, "w") as file:
|
|
json.dump(data, file, indent=4)
|
|
return data
|
|
except Exception as e:
|
|
print(f"Error fetching device data: {e}")
|
|
return {}
|
|
|
|
|
|
def get_device_info(destination, device_data):
|
|
tocalls = device_data.get("tocalls", {})
|
|
for pattern, info in tocalls.items():
|
|
if pattern.replace("?", "") in destination:
|
|
model = info.get("model", "Unknown Model")
|
|
vendor = info.get("vendor", "Unknown Vendor")
|
|
device_class = info.get("class", "Unknown Class")
|
|
return f"{vendor} {model}, {device_class.capitalize()}"
|
|
return "Unknown Device Type"
|
|
|
|
|
|
def send_ack(ki, aprs_frame):
|
|
try:
|
|
source_callsign = aprs_frame.source.callsign.decode('utf-8') if isinstance(aprs_frame.source.callsign,
|
|
bytes) else aprs_frame.source.callsign
|
|
source_ssid = aprs_frame.source.ssid
|
|
source = f"{source_callsign}-{source_ssid}" if source_ssid else source_callsign
|
|
|
|
message_number = aprs_frame.info.number.decode('utf-8') if isinstance(aprs_frame.info.number, bytes) else str(
|
|
aprs_frame.info.number)
|
|
|
|
ack_info = f":{source:<9}:ack{message_number}".encode('utf-8')
|
|
|
|
frame = aprs.APRSFrame.ui(
|
|
destination=config.STANDARD_CALL,
|
|
source=config.TACTICAL_CALL,
|
|
path=config.APRS_PATH,
|
|
info=ack_info,
|
|
)
|
|
ki.write(frame)
|
|
time.sleep(0.3) # delay to ensure ack is sent
|
|
except Exception as e:
|
|
print(f"Failed to send ACK: {e}")
|
|
|
|
|
|
def send_bulletin(bulletin_id, bulletin_text):
|
|
"""Send an APRS bulletin in BLN format."""
|
|
try:
|
|
message_number = get_next_message_number()
|
|
formatted_bulletin = f"{bulletin_text}{{{message_number}"
|
|
|
|
frame_info = f":{bulletin_id:<9}:{formatted_bulletin}".encode('utf-8')
|
|
frame = aprs.APRSFrame.ui(
|
|
destination=config.STANDARD_CALL,
|
|
source=config.TACTICAL_CALL,
|
|
path=config.APRS_PATH,
|
|
info=frame_info
|
|
)
|
|
|
|
ki = aprs.TCPKISS(host=config.KISS_HOST, port=config.KISS_PORT)
|
|
ki.start()
|
|
ki.write(frame)
|
|
print(f"Urgent bulletin transmitted: {bulletin_text}")
|
|
ki.stop()
|
|
|
|
except Exception as e:
|
|
print(f"Failed to send urgent bulletin: {e}")
|
|
|
|
|
|
def start():
|
|
ki = aprs.TCPKISS(host=config.KISS_HOST, port=config.KISS_PORT)
|
|
ki.start()
|
|
|
|
device_data = fetch_device_data()
|
|
|
|
print("Listening for APRS frames...\n")
|
|
|
|
COLOR_SEPARATOR = "\033[96m" # Cyan
|
|
COLOR_TIMESTAMP = "\033[92m" # Green
|
|
COLOR_MESSAGE = "\033[94m" # Blue
|
|
COLOR_DEVICE = "\033[93m" # Yellow
|
|
COLOR_RAW = "\033[91m" # Bright Red
|
|
COLOR_RESET = "\033[0m" # Reset to Default
|
|
|
|
separator_line = f"{COLOR_SEPARATOR}{'=' * 110}{COLOR_RESET}"
|
|
sub_separator_line = f"{COLOR_SEPARATOR}{'-' * 110}{COLOR_RESET}"
|
|
|
|
def normalize_callsign(callsign):
|
|
return callsign.split('-')[0].upper() if callsign else ""
|
|
|
|
my_callsign = normalize_callsign(config.TACTICAL_CALL)
|
|
print(f"BBS Callsign: {my_callsign}")
|
|
|
|
while True:
|
|
for frame in ki.read(min_frames=1):
|
|
try:
|
|
if config.RAW_PACKET_DISPLAY:
|
|
print(f"{COLOR_RAW}RAW PACKET:{COLOR_RESET} {frame}")
|
|
|
|
aprs_frame = aprs.APRSFrame.from_bytes(bytes(frame))
|
|
|
|
if isinstance(aprs_frame.info, aprs.Message):
|
|
message_text = aprs_frame.info.text.decode('utf-8') if isinstance(aprs_frame.info.text,
|
|
bytes) else aprs_frame.info.text
|
|
|
|
if message_text.startswith("ack"):
|
|
ack_number = message_text[3:].strip() # Extract the message number after "ack"
|
|
|
|
# Ensure ACK is intended for this BBS
|
|
recipient = aprs_frame.info.addressee.decode('utf-8').strip() if isinstance(
|
|
aprs_frame.info.addressee, bytes) else aprs_frame.info.addressee.strip()
|
|
if normalize_callsign(recipient) != my_callsign:
|
|
continue
|
|
|
|
with unack_lock:
|
|
for tracked_recipient, (msg, msg_num) in unacknowledged_messages.items():
|
|
if str(msg_num) == ack_number:
|
|
print(
|
|
f"ACK received for {tracked_recipient}'s message #{msg_num}. Removing from tracking.")
|
|
del unacknowledged_messages[tracked_recipient]
|
|
continue
|
|
|
|
if isinstance(aprs_frame.info, aprs.Message):
|
|
recipient = aprs_frame.info.addressee.decode('utf-8') if isinstance(aprs_frame.info.addressee, bytes) else aprs_frame.info.addressee
|
|
recipient = recipient.strip()
|
|
# print(f"DEBUG: Extracted recipient from addressee: {recipient}")
|
|
|
|
normalized_recipient = normalize_callsign(recipient)
|
|
# print(f"DEBUG: Normalized recipient: {normalized_recipient}, My callsign: {my_callsign}")
|
|
|
|
if normalized_recipient != my_callsign:
|
|
continue
|
|
|
|
source_callsign = aprs_frame.source.callsign.decode('utf-8') if isinstance(aprs_frame.source.callsign, bytes) else aprs_frame.source.callsign
|
|
source_ssid = aprs_frame.source.ssid
|
|
source = f"{source_callsign}-{source_ssid}" if source_ssid else source_callsign
|
|
|
|
message = aprs_frame.info.text.decode('utf-8') if isinstance(aprs_frame.info.text, bytes) else aprs_frame.info.text
|
|
|
|
send_ack(ki, aprs_frame)
|
|
|
|
destination = aprs_frame.destination.callsign.decode('utf-8') if isinstance(aprs_frame.destination.callsign, bytes) else str(aprs_frame.destination.callsign)
|
|
device_info = get_device_info(destination, device_data)
|
|
|
|
iso_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
print(separator_line)
|
|
print(f"{COLOR_TIMESTAMP}{iso_timestamp}{COLOR_RESET} {COLOR_MESSAGE}Message from {source}: {message}{COLOR_RESET}")
|
|
print(f"{COLOR_DEVICE}({device_info}){COLOR_RESET}")
|
|
print(sub_separator_line)
|
|
|
|
responses = commands.handle_command(source, message)
|
|
|
|
if isinstance(responses, str):
|
|
responses = [responses]
|
|
|
|
for response in responses:
|
|
dec_timestamp = datetime.now().strftime("%b%d %H:%M")
|
|
message_number = get_next_message_number()
|
|
formatted_response = f"{response}{{{message_number}"
|
|
|
|
response_info = f":{source:<9}:{formatted_response}".encode('utf-8')
|
|
response_frame = aprs.APRSFrame.ui(
|
|
destination=config.STANDARD_CALL,
|
|
source=config.TACTICAL_CALL,
|
|
path=config.APRS_PATH,
|
|
info=response_info,
|
|
)
|
|
ki.write(response_frame)
|
|
|
|
print(f"{COLOR_TIMESTAMP}{iso_timestamp}{COLOR_RESET} Response to {source}: {response}")
|
|
|
|
print(separator_line, "\n")
|
|
|
|
except Exception as e:
|
|
print(f"Error processing frame: {e}")
|
|
|
|
def send_direct_message(recipient, message):
|
|
"""Send a direct APRS message to a recipient without ACK request."""
|
|
try:
|
|
message_number = get_next_message_number()
|
|
formatted_message = f"{message}{{{message_number}"
|
|
|
|
frame_info = f":{recipient:<9}:{formatted_message}".encode('utf-8')
|
|
|
|
frame = aprs.APRSFrame.ui(
|
|
destination=config.STANDARD_CALL,
|
|
source=config.TACTICAL_CALL,
|
|
path=config.APRS_PATH,
|
|
info=frame_info
|
|
)
|
|
ki = aprs.TCPKISS(host=config.KISS_HOST, port=config.KISS_PORT)
|
|
ki.start()
|
|
ki.write(frame)
|
|
print(f"Direct message sent to {recipient}: {formatted_message}")
|
|
ki.stop()
|
|
|
|
except Exception as e:
|
|
print(f"Failed to send direct message to {recipient}: {e}")
|
|
|
|
|
|
def wait_for_ack(ki, recipient, message_number, timeout=5):
|
|
"""Wait for an acknowledgment from the recipient."""
|
|
try:
|
|
start_time = time.time()
|
|
|
|
while time.time() - start_time < timeout:
|
|
for frame in ki.read(min_frames=1):
|
|
aprs_frame = aprs.APRSFrame.from_bytes(bytes(frame))
|
|
|
|
if isinstance(aprs_frame.info, aprs.Message):
|
|
message_text = aprs_frame.info.text.decode('utf-8') if isinstance(aprs_frame.info.text, bytes) else aprs_frame.info.text
|
|
|
|
if message_text.startswith("ack"):
|
|
ack_number = message_text[3:].strip()
|
|
if ack_number == str(message_number):
|
|
print(f"ACK received from {recipient} for message #{message_number}.")
|
|
return True
|
|
print(f"Timeout reached: No ACK received from {recipient} for message #{message_number}.")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"Error while waiting for ACK: {e}")
|
|
return False
|
|
|
|
|
|
def get_next_message_number():
|
|
"""Generates a unique 5-character alphanumeric message number."""
|
|
with message_lock:
|
|
# Characters: A-Z and 0-9
|
|
characters = string.ascii_uppercase + string.digits
|
|
# Random 5-character combination
|
|
return ''.join(random.choices(characters, k=5)) |