Add files via upload

This commit is contained in:
TC² 2025-01-07 14:53:16 -05:00 committed by GitHub
commit 635e7f42d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 2399 additions and 0 deletions

99
README.md Normal file
View File

@ -0,0 +1,99 @@
# TC²-BBS Meshtastic Version
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/B0B1OZ22Z)
This is the TC²-BBS for APRS. The system allows for basic mail message for specific users and bulletin boards.
This is an APRS BBS only because it uses the APRS protocol and it's not meant to be used on the nationwide APRS freq 144.390MHz.
Ideally, this is meant to be used within the following frequency ranges (US users):
2M - 144.90-145.10 & 145.50-145.80
## Setup
### Requirements
- Python 3.x
- `requests`
- `aprs3`
### Update and Install Git
```sh
sudo apt update
sudo apt upgrade
sudo apt install git
```
### Installation (Linux)
1. Clone the repository:
```sh
cd ~
git clone https://github.com/TheCommsChannel/TC2-APRS-BBS.git
cd TC2-APRS-BBS
```
2. Set up a Python virtual environment:
```sh
python -m venv venv
```
3. Activate the virtual environment:
```sh
source venv/bin/activate
```
4. Install the required packages:
```sh
pip install -r requirements.txt
```
5. Rename `example_config.ini`:
```sh
mv example_config.ini config.ini
```
6. Set up the configuration in `config.ini`:
You'll need to open up the config.ini file in a text editor and make your changes following the instructions below
**MYCALL**
This is where you enter the callsign of your BBS. This can be your FCC callsign or eventually a tactical callsign (like BBS). If using a tactical call the BBS will need to transmit your FCC call every 10 minutes while in operation. This hasn't been implemented yet however, so it's best to use your FCC call until then.
**KISS_HOST & KISS PORT**
IP Address and Port of the host running direwolf (127.0.0.1 if the BBS is running on the same system)
**BULLETIN_EXPIRATION_DAYS**
Number of days to have bulletins expire
**APRS_PATH**
The WIDEN-n path for digipeater hops
**RAW_PACKET_DISPLAY**
IP Address of the host running direwolf (127.0.0.1 if the BBS is running on the same system)
### Running the Server
Run the server with:
```sh
python main.py
```
Be sure you've followed the Python virtual environment steps above and activated it before running.
You should see (venv) at the beginning of the command prompt
## Automatically run at boot
Instructions coming soon....
## License
GNU General Public License v3.0

245
aprs_comm.py Normal file
View File

@ -0,0 +1,245 @@
import time
from threading import Lock
import aprs
import os
import json
import requests
import commands
import config
# Global dictionary to track unacknowledged messages
unacknowledged_messages = {}
unack_lock = Lock()
# Message numbering for ACKS
message_counter = 1
from datetime import datetime
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=source,
source=config.MYCALL,
path=config.APRS_PATH,
info=ack_info,
)
ki.write(frame)
except Exception as e:
print(f"Failed to send ACK: {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.MYCALL)
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 = normalize_callsign(f"{source_callsign}-{source_ssid}")
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")
response_info = f":{source:<9}:{response}".encode('utf-8')
response_frame = aprs.APRSFrame.ui(
destination=source,
source=config.MYCALL,
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 with ACK request and monitor for retries."""
global message_counter
try:
message_number = message_counter
message_counter += 1
ack_info = f":{recipient:<9}:{message}{{{message_number}".encode('utf-8')
frame = aprs.APRSFrame.ui(
destination=recipient.upper(),
source=config.MYCALL,
path=config.APRS_PATH,
info=ack_info
)
ki = aprs.TCPKISS(host=config.KISS_HOST, port=config.KISS_PORT)
ki.start()
ki.write(frame)
print(f"Direct message sent to {recipient} with ACK request (msg #{message_number}): {message}")
if not wait_for_ack(ki, recipient, message_number, timeout=5):
print(f"No ACK received from {recipient} for message #{message_number}. Monitoring for activity...")
with unack_lock:
unacknowledged_messages[recipient.upper()] = (message, message_number)
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

45
commands.py Normal file
View File

@ -0,0 +1,45 @@
import database
import aprs_comm
def handle_command(callsign, command):
normalized_callsign = callsign.split('-')[0].upper()
parts = command.split(' ', 1)
cmd = parts[0].upper()
arg = parts[1] if len(parts) > 1 else ''
if cmd in ['LIST', 'L']:
bulletins = database.get_bulletins()
return [
f"{b[2]} {b[1]}: {b[0]}" for b in bulletins
] or ["No bulletins available."]
elif cmd in ['MSG', 'M']:
messages = database.get_messages_for_user(normalized_callsign)
return [
f"From {m[0]} ({m[2]}): {m[1]}" for m in messages
] or ["No messages for you."]
elif cmd in ['POST', 'P']:
if arg:
database.add_bulletin(normalized_callsign, arg)
return ["Bulletin posted."]
return ["Usage: POST <text>"]
elif cmd in ['SEND', 'S']:
args = arg.split(' ', 1)
if len(args) == 2:
recipient, message = args
database.add_message(normalized_callsign, recipient, message)
notification = f"You have a new message from {normalized_callsign}."
aprs_comm.send_direct_message(recipient, notification)
return [f"Message sent to {recipient}."]
return ["Usage: SEND <callsign> <text>"]
else:
return [
"Hello and Welcome to the TC2-BBS!",
"Please send a message with one of the commands below.",
"Commands: (L)IST, (M)SG, (P)OST <text>, (S)SEND <callsign> <text>"
]

26
config.py Normal file
View File

@ -0,0 +1,26 @@
import os
from configparser import ConfigParser
config_file = "config.ini"
config = ConfigParser()
# Check if config.ini exists and create one if it doesnt.
if not os.path.exists(config_file):
with open(config_file, "w") as file:
file.write("""
[DEFAULT]
MYCALL = BBS
KISS_HOST = 192.168.1.94
KISS_PORT = 8001
BULLETIN_EXPIRATION_DAYS = 7
APRS_PATH = WIDE1-1
""")
config.read(config_file)
MYCALL = config.get("DEFAULT", "MYCALL", fallback="BBS")
KISS_HOST = config.get("DEFAULT", "KISS_HOST", fallback="127.0.0.1")
KISS_PORT = config.getint("DEFAULT", "KISS_PORT", fallback=8001)
BULLETIN_EXPIRATION_DAYS = config.getint("DEFAULT", "BULLETIN_EXPIRATION_DAYS", fallback=7)
APRS_PATH = config.get("DEFAULT", "APRS_PATH", fallback="WIDE1-1").split(",")
RAW_PACKET_DISPLAY = config.getboolean("DEFAULT", "RAW_PACKET_DISPLAY", fallback=False)

81
database.py Normal file
View File

@ -0,0 +1,81 @@
import sqlite3
from datetime import datetime, timedelta
import config
def init_db():
conn = sqlite3.connect('aprs.db')
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS bulletins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
poster TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
recipient TEXT NOT NULL,
text TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
def format_timestamp(timestamp):
dt = datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S")
return dt.strftime("%b%d %H:%M").upper()
def add_bulletin(callsign, text):
conn = sqlite3.connect('aprs.db')
cursor = conn.cursor()
cursor.execute("INSERT INTO bulletins (text, poster) VALUES (?, ?)", (text, callsign))
conn.commit()
conn.close()
def get_bulletins():
conn = sqlite3.connect('aprs.db')
cursor = conn.cursor()
cursor.execute("SELECT text, poster, timestamp FROM bulletins ORDER BY timestamp DESC")
results = cursor.fetchall()
conn.close()
return [(text, poster, format_timestamp(ts)) for text, poster, ts in results]
def add_message(sender, recipient, text):
conn = sqlite3.connect('aprs.db')
cursor = conn.cursor()
normalized_recipient = normalize_callsign(recipient)
cursor.execute("INSERT INTO messages (sender, recipient, text) VALUES (?, ?, ?)", (sender, normalized_recipient, text))
conn.commit()
conn.close()
def get_messages_for_user(callsign):
conn = sqlite3.connect('aprs.db')
cursor = conn.cursor()
normalized_callsign = normalize_callsign(callsign)
cursor.execute("""
SELECT sender, text, timestamp
FROM messages
WHERE UPPER(recipient) = ?
ORDER BY timestamp DESC
""", (normalized_callsign,))
results = cursor.fetchall()
conn.close()
return [(normalize_callsign(sender), text, format_timestamp(ts)) for sender, text, ts in results]
def normalize_callsign(callsign):
return callsign.split('-')[0].upper()
def delete_expired_bulletins():
"""Delete bulletins older than the configured expiration time."""
expiration_threshold = datetime.now() - timedelta(days=config.BULLETIN_EXPIRATION_DAYS)
conn = sqlite3.connect('aprs.db')
cursor = conn.cursor()
cursor.execute("DELETE FROM bulletins WHERE timestamp < ?", (expiration_threshold,))
conn.commit()
conn.close()

28
example_config.ini Normal file
View File

@ -0,0 +1,28 @@
# This is the config file for the TC² APRS-BBS.
# Edit the values below to customize the behavior of the system.
[DEFAULT]
# MYCALL: The callsign of the BBS. This is the identifier used to communicate
# with the APRS network. Change it to your assigned ham radio callsign or a tactical callsign
# Note on tactical callsigns:
# Your FCC callsign will need to be transmitted every 10 minutes during operation if using a tactical call.
# This feature hasn't been implemented yet, so it is best to enter in your FCC call here until then.
MYCALL = BBS
# KISS_HOST: The hostname or IP address of the KISS TNC (Terminal Node Controller)
# used for APRS communications. Typically, this is a local device or a remote server.
KISS_HOST = 192.168.1.94
# KISS_PORT: The port number used to connect to the KISS TNC.
# Ensure the port matches the configuration of your TNC.
KISS_PORT = 8001
# BULLETIN_EXPIRATION_DAYS: The number of days after which bulletins will
# automatically expire and be deleted from the database. Set to 0 to disable expiration.
BULLETIN_EXPIRATION_DAYS = 7
# APRS Path
APRS_PATH = WIDE1-1,WIDE2-1
# Enable or disable raw packet display (True or False)
RAW_PACKET_DISPLAY = True

41
main.py Normal file
View File

@ -0,0 +1,41 @@
import database
import aprs_comm
import threading
import time
def scheduled_cleanup():
"""Periodically run cleanup of expired bulletins."""
while True:
try:
print("Running periodic cleanup of expired bulletins...")
database.delete_expired_bulletins()
except Exception as e:
print(f"Error during cleanup: {e}")
time.sleep(24 * 60 * 60) # Run cleanup every 24 hours
def main():
banner = """
\033[96m
\033[93mAPRS Version\033[0m
"""
print(banner)
print("Initializing database...")
database.init_db()
# Start periodic bulletin cleanup in a separate thread
cleanup_thread = threading.Thread(target=scheduled_cleanup, daemon=True)
cleanup_thread.start()
print("Starting APRS communications...")
aprs_comm.start()
if __name__ == "__main__":
main()

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
requests
aprs3

1832
tocalls_cache.json Normal file

File diff suppressed because it is too large Load Diff