Merge pull request #278 from peanutyost/main

Added Features to Location module
This commit is contained in:
Kelly
2026-02-27 14:31:02 -08:00
committed by GitHub
5 changed files with 887 additions and 40 deletions

2
.gitignore vendored
View File

@@ -28,4 +28,4 @@ modules/custom_scheduler.py
venv/ venv/
# Python cache # Python cache
__pycache__/ __pycache__/

View File

@@ -200,6 +200,12 @@ lat = 48.50
lon = -123.0 lon = -123.0
fuzzConfigLocation = True fuzzConfigLocation = True
fuzzItAll = False fuzzItAll = False
# database file for saved locations
locations_db = data/locations.db
# if True, only administrators can save public locations
public_location_admin_manage = False
# if True, only administrators can delete locations
delete_public_locations_admins_only = False
# Default to metric units rather than imperial # Default to metric units rather than imperial
useMetric = False useMetric = False

View File

@@ -353,16 +353,15 @@ The system uses SQLite with four tables:
| `howfar` | Distance traveled since last check | | `howfar` | Distance traveled since last check |
| `howtall` | Calculate height using sun angle | | `howtall` | Calculate height using sun angle |
| `whereami` | Show current location/address | | `whereami` | Show current location/address |
| `map` | Log/view location data to map.csv | | `map` | Save/retrieve locations, get headings, manage location database |
Configure in `[location]` section of `config.ini`. Configure in `[location]` section of `config.ini`.
Certainly! Heres a README help section for your `mapHandler` command, suitable for users of your meshbot:
--- ---
## 📍 Map Command ## 📍 Map Command
The `map` command allows you to log your current GPS location with a custom description. This is useful for mapping mesh nodes, events, or points of interest. The `map` command provides a comprehensive location management system that allows you to save, retrieve, and manage locations in a SQLite database. You can save private locations (visible only to you) or public locations (visible to all nodes), get headings and distances to saved locations, and manage your location data.
### Usage ### Usage
@@ -370,23 +369,117 @@ The `map` command allows you to log your current GPS location with a custom desc
``` ```
map help map help
``` ```
Displays usage instructions for the map command. Displays usage instructions for all map commands.
- **Log a Location** - **Save a Private Location**
``` ```
map <description> map save <name> [description]
``` ```
Saves your current location as a private location (only visible to your node).
Examples:
```
map save BaseCamp
map save BaseCamp Main base camp location
```
- **Save a Public Location**
```
map save public <name> [description]
```
Saves your current location as a public location (visible to all nodes).
Examples:
```
map save public TrailHead
map save public TrailHead Starting point for hiking trail
```
**Note:** If `public_location_admin_manage = True` in config, only administrators can save public locations.
- **Get Heading to a Location**
```
map <name>
```
Retrieves a saved location and provides heading (bearing) and distance from your current position.
The system prioritizes your private location if both private and public locations exist with the same name.
Example: Example:
``` ```
map Found a new mesh node near the park map BaseCamp
```
Response includes:
- Location coordinates
- Compass heading (bearing)
- Distance
- Description (if provided)
- **Get Heading to a Public Location**
```
map public <name>
```
Specifically retrieves a public location, even if you have a private location with the same name.
Example:
```
map public BaseCamp
```
- **List All Saved Locations**
```
map list
```
Lists all locations you can access:
- Your private locations (🔒Private)
- All public locations (🌐Public)
Locations are sorted with private locations first, then public locations, both alphabetically by name.
- **Delete a Location**
```
map delete <name>
```
Deletes a location from the database.
**Permission Rules:**
- If `delete_public_locations_admins_only = False` (default):
- Users can delete their own private locations
- Users can delete public locations they created
- Anyone can delete any public location
- If `delete_public_locations_admins_only = True`:
- Only administrators can delete public locations
The system prioritizes deleting your private location if both private and public locations exist with the same name.
- **Legacy CSV Logging**
```
map log <description>
```
Logs your current location to the legacy CSV file (`data/map_data.csv`) with a description. This is the original map functionality preserved for backward compatibility.
Example:
```
map log Found a new mesh node near the park
``` ```
This will log your current location with the description "Found a new mesh node near the park".
### How It Works ### How It Works
- The bot records your user ID, latitude, longitude, and your description in a CSV file (`data/map_data.csv`). - **Database Storage:** All locations are stored in a SQLite database (`data/locations.db` by default, configurable via `locations_db` in config.ini).
- If your location data is missing or invalid, youll receive an error message. - **Location Types:**
- You can view or process the CSV file later for mapping or analysis. - **Private Locations:** Only visible to the node that created them
- **Public Locations:** Visible to all nodes
- **Conflict Resolution:** If you try to save a private location with the same name as an existing public location, you'll be prompted that there is a the public record with that name.
- **Distance Calculation:** Uses the Haversine formula for accurate distance calculations. Distances less than 0.25 miles are displayed in feet; otherwise in miles (or kilometers if metric is enabled).
- **Heading Calculation:** Provides compass bearing (0-360 degrees) from your current location to the target location.
### Configuration
Configure in `[location]` section of `config.ini`:
- `locations_db` - Path to the SQLite database file (default: `data/locations.db`)
- `public_location_admin_manage` - If `True`, only administrators can save public locations (default: `False`)
- `delete_public_locations_admins_only` - If `True`, only administrators can delete locations (default: `False`)
**Tip:** Use `map help` at any time to see these instructions in the bot. **Tip:** Use `map help` at any time to see these instructions in the bot.

View File

@@ -14,6 +14,7 @@ import modules.settings as my_settings
import math import math
import csv import csv
import os import os
import sqlite3
trap_list_location = ("whereami", "wx", "wxa", "wxalert", "rlist", "ea", "ealert", "riverflow", "valert", "earthquake", "howfar", "map",) trap_list_location = ("whereami", "wx", "wxa", "wxalert", "rlist", "ea", "ealert", "riverflow", "valert", "earthquake", "howfar", "map",)
@@ -1171,6 +1172,507 @@ def get_openskynetwork(lat=0, lon=0, altitude=0, node_altitude=0, altitude_windo
logger.debug(f"SYSTEM: Location HighFly: Error processing OpenSky Network data: {e}") logger.debug(f"SYSTEM: Location HighFly: Error processing OpenSky Network data: {e}")
return False return False
def get_public_location_admin_manage():
"""Get the public_location_admin_manage setting directly from config file
This ensures the setting is reloaded fresh on first load of the program
"""
import configparser
config = configparser.ConfigParser()
try:
config.read("config.ini", encoding='utf-8')
return config['location'].getboolean('public_location_admin_manage', False)
except Exception:
return False
def get_delete_public_locations_admins_only():
"""Get the delete_public_locations_admins_only setting directly from config file
This ensures the setting is reloaded fresh on first load of the program
"""
import configparser
config = configparser.ConfigParser()
try:
config.read("config.ini", encoding='utf-8')
return config['location'].getboolean('delete_public_locations_admins_only', False)
except Exception:
return False
def get_node_altitude(nodeID, deviceID=1):
"""Get altitude for a node from position data or positionMetadata
Returns altitude in meters, or None if not available
"""
try:
import modules.system as system_module
# Try to get altitude from node position dict first
# Access interface dynamically from system module
interface = getattr(system_module, f'interface{deviceID}', None)
if interface and hasattr(interface, 'nodes') and interface.nodes:
for node in interface.nodes.values():
if nodeID == node['num']:
pos = node.get('position')
if pos and isinstance(pos, dict) and pos.get('altitude') is not None:
try:
altitude = float(pos['altitude'])
if altitude > 0: # Valid altitude
return altitude
except (ValueError, TypeError):
pass
# Fall back to positionMetadata (from POSITION_APP packets)
positionMetadata = getattr(system_module, 'positionMetadata', None)
if positionMetadata and nodeID in positionMetadata:
metadata = positionMetadata[nodeID]
if 'altitude' in metadata:
altitude = metadata.get('altitude', 0)
if altitude and altitude > 0:
return float(altitude)
return None
except Exception as e:
logger.debug(f"Location: Error getting altitude for node {nodeID}: {e}")
return None
def initialize_locations_database():
"""Initialize the SQLite database for storing saved locations"""
try:
# Ensure data directory exists
db_dir = os.path.dirname(my_settings.locations_db)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
conn = sqlite3.connect(my_settings.locations_db)
c = conn.cursor()
logger.debug("Location: Initializing locations database...")
# Check if table exists and get its structure
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='locations'")
table_exists = c.fetchone() is not None
if table_exists:
# Check if is_public column exists
c.execute("PRAGMA table_info(locations)")
columns_info = c.fetchall()
column_names = [col[1] for col in columns_info]
# Check for UNIQUE constraint on location_name by examining the table schema
c.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='locations'")
table_sql = c.fetchone()
has_unique_constraint = False
if table_sql and table_sql[0]:
# Check if UNIQUE constraint exists in the table definition
if 'UNIQUE' in table_sql[0].upper() and 'location_name' in table_sql[0]:
has_unique_constraint = True
# If UNIQUE constraint exists, we need to recreate the table
if has_unique_constraint:
logger.debug("Location: Removing UNIQUE constraint from locations table")
# Create temporary table without UNIQUE constraint
c.execute('''CREATE TABLE locations_new
(location_id INTEGER PRIMARY KEY AUTOINCREMENT,
location_name TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
description TEXT,
userID TEXT,
is_public INTEGER DEFAULT 0,
created_date TEXT,
created_time TEXT)''')
# Copy data from old table to new table
c.execute('''INSERT INTO locations_new
(location_id, location_name, latitude, longitude, description, userID,
is_public, created_date, created_time)
SELECT location_id, location_name, latitude, longitude, description, userID,
COALESCE(is_public, 0), created_date, created_time
FROM locations''')
# Drop old table
c.execute("DROP TABLE locations")
# Rename new table
c.execute("ALTER TABLE locations_new RENAME TO locations")
logger.debug("Location: Successfully removed UNIQUE constraint")
# Refresh column list after table recreation
c.execute("PRAGMA table_info(locations)")
columns_info = c.fetchall()
column_names = [col[1] for col in columns_info]
# Add is_public column if it doesn't exist (migration)
if 'is_public' not in column_names:
try:
c.execute('''ALTER TABLE locations ADD COLUMN is_public INTEGER DEFAULT 0''')
logger.debug("Location: Added is_public column to locations table")
except sqlite3.OperationalError:
# Column might already exist, ignore
pass
# Add altitude column if it doesn't exist (migration)
if 'altitude' not in column_names:
try:
c.execute('''ALTER TABLE locations ADD COLUMN altitude REAL''')
logger.debug("Location: Added altitude column to locations table")
except sqlite3.OperationalError:
# Column might already exist, ignore
pass
else:
# Table doesn't exist, create it without UNIQUE constraint
c.execute('''CREATE TABLE locations
(location_id INTEGER PRIMARY KEY AUTOINCREMENT,
location_name TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
description TEXT,
userID TEXT,
is_public INTEGER DEFAULT 0,
created_date TEXT,
created_time TEXT)''')
# Create index for faster lookups (non-unique)
c.execute('''CREATE INDEX IF NOT EXISTS idx_location_name_user
ON locations(location_name, userID, is_public)''')
conn.commit()
conn.close()
return True
except Exception as e:
logger.error(f"Location: Failed to initialize locations database: {e}")
return False
def save_location_to_db(location_name, lat, lon, description="", userID="", is_public=False, altitude=None):
"""Save a location to the SQLite database
Returns:
(success, message, conflict_info)
conflict_info is None if no conflict, or dict with conflict details if conflict exists
"""
try:
if not location_name or not location_name.strip():
return False, "Location name cannot be empty", None
# Check if public locations are admin-only and user is not admin
if is_public and get_public_location_admin_manage():
from modules.system import isNodeAdmin
if not isNodeAdmin(userID):
return False, "Only admins can save public locations.", None
conn = sqlite3.connect(my_settings.locations_db)
c = conn.cursor()
location_name_clean = location_name.strip()
# Check for conflicts
# 1. Check if user already has a location with this name (private or public)
c.execute('''SELECT location_id, is_public FROM locations
WHERE location_name = ? AND userID = ?''',
(location_name_clean, userID))
user_existing = c.fetchone()
if user_existing:
conn.close()
return False, f"Location '{location_name}' already exists for your node", None
# 2. Check if there's a public location with this name
# Note: We allow public locations to overlap with other users' private locations
# Only check for existing public locations to prevent duplicate public locations
c.execute('''SELECT location_id, userID, description FROM locations
WHERE location_name = ? AND is_public = 1''',
(location_name_clean,))
public_existing = c.fetchone()
if public_existing:
if not is_public:
# User is trying to create private location but public one exists
# They can use "map public <name>" to access the public location
conn.close()
return False, f"Public location '{location_name}' already exists. Use 'map public {location_name}' to access it.", None
else:
# User is trying to create public location but one already exists
# Only one public location per name globally
conn.close()
return False, f"Public location '{location_name}' already exists", None
# 3. If saving as public, we don't check for other users' private locations
# This allows public locations to overlap with private location names
# Insert new location
now = datetime.now()
c.execute('''INSERT INTO locations
(location_name, latitude, longitude, altitude, description, userID, is_public, created_date, created_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(location_name_clean, lat, lon, altitude, description, userID, 1 if is_public else 0,
now.strftime("%Y-%m-%d"), now.strftime("%H:%M:%S")))
conn.commit()
conn.close()
visibility = "public" if is_public else "private"
logger.debug(f"Location: Saved {visibility} location '{location_name}' to database")
return True, f"Location '{location_name}' saved as {visibility}", None
except Exception as e:
logger.error(f"Location: Failed to save location: {e}")
return False, f"Error saving location: {e}", None
def get_location_from_db(location_name, userID=None):
"""Retrieve a location from the database by name
Returns:
- User's private location if exists
- Public location if exists
- None if not found
"""
try:
conn = sqlite3.connect(my_settings.locations_db)
c = conn.cursor()
location_name_clean = location_name.strip()
# First, try to get user's private location
if userID:
c.execute('''SELECT location_name, latitude, longitude, altitude, description, userID, is_public, created_date, created_time
FROM locations
WHERE location_name = ? AND userID = ? AND is_public = 0''',
(location_name_clean, userID))
result = c.fetchone()
if result:
conn.close()
return {
'name': result[0],
'lat': result[1],
'lon': result[2],
'altitude': result[3],
'description': result[4],
'userID': result[5],
'is_public': bool(result[6]),
'created_date': result[7],
'created_time': result[8]
}
# Then try public location
c.execute('''SELECT location_name, latitude, longitude, altitude, description, userID, is_public, created_date, created_time
FROM locations
WHERE location_name = ? AND is_public = 1''',
(location_name_clean,))
result = c.fetchone()
conn.close()
if result:
return {
'name': result[0],
'lat': result[1],
'lon': result[2],
'altitude': result[3],
'description': result[4],
'userID': result[5],
'is_public': bool(result[6]),
'created_date': result[7],
'created_time': result[8]
}
return None
except Exception as e:
logger.error(f"Location: Failed to retrieve location: {e}")
return None
def get_public_location_from_db(location_name):
"""Retrieve only a public location from the database by name (ignores private locations)
Returns:
- Public location if exists
- None if not found
"""
try:
conn = sqlite3.connect(my_settings.locations_db)
c = conn.cursor()
location_name_clean = location_name.strip()
# Get only public location
c.execute('''SELECT location_name, latitude, longitude, altitude, description, userID, is_public, created_date, created_time
FROM locations
WHERE location_name = ? AND is_public = 1''',
(location_name_clean,))
result = c.fetchone()
conn.close()
if result:
return {
'name': result[0],
'lat': result[1],
'lon': result[2],
'altitude': result[3],
'description': result[4],
'userID': result[5],
'is_public': bool(result[6]),
'created_date': result[7],
'created_time': result[8]
}
return None
except Exception as e:
logger.error(f"Location: Failed to retrieve public location: {e}")
return None
def list_locations_from_db(userID=None):
"""List saved locations
Shows:
- User's private locations
- All public locations
"""
try:
conn = sqlite3.connect(my_settings.locations_db)
c = conn.cursor()
if userID:
# Get user's private locations and all public locations
c.execute('''SELECT location_name, latitude, longitude, altitude, description, is_public, created_date
FROM locations
WHERE (userID = ? AND is_public = 0) OR is_public = 1
ORDER BY is_public ASC, location_name''', (userID,))
else:
# Get all public locations only
c.execute('''SELECT location_name, latitude, longitude, altitude, description, is_public, created_date
FROM locations
WHERE is_public = 1
ORDER BY location_name''')
results = c.fetchall() # Get ALL results, no limit
conn.close()
if not results:
return "No saved locations found"
locations_list = f"Saved Locations ({len(results)} total):\n"
# Return ALL results, not limited
for result in results:
is_public = bool(result[5])
visibility = "🌐Public" if is_public else "🔒Private"
locations_list += f"{result[0]} ({result[1]:.5f}, {result[2]:.5f})"
if result[3] is not None: # altitude
locations_list += f" @ {result[3]:.1f}m"
locations_list += f" [{visibility}]"
if result[4]: # description
locations_list += f" - {result[4]}"
locations_list += "\n"
return locations_list.strip()
except Exception as e:
logger.error(f"Location: Failed to list locations: {e}")
return f"Error listing locations: {e}"
def delete_location_from_db(location_name, userID=""):
"""Delete a location from the database
Returns:
(success, message)
"""
try:
if not location_name or not location_name.strip():
return False, "Location name cannot be empty"
conn = sqlite3.connect(my_settings.locations_db)
c = conn.cursor()
location_name_clean = location_name.strip()
# Check if location exists - prioritize user's private location, then public
# First try to get user's private location
c.execute('''SELECT location_id, userID, is_public FROM locations
WHERE location_name = ? AND userID = ? AND is_public = 0''',
(location_name_clean, userID))
location = c.fetchone()
# If not found, try public location
if not location:
c.execute('''SELECT location_id, userID, is_public FROM locations
WHERE location_name = ? AND is_public = 1''',
(location_name_clean,))
location = c.fetchone()
# If still not found, try any location (for admin delete)
if not location:
c.execute('''SELECT location_id, userID, is_public FROM locations
WHERE location_name = ? LIMIT 1''',
(location_name_clean,))
location = c.fetchone()
if not location:
conn.close()
return False, f"Location '{location_name}' not found"
location_id, location_userID, is_public = location
# Check permissions
# Users can only delete their own private locations
# Admins can delete any location if delete_public_locations_admins_only is enabled
is_admin = False
if get_delete_public_locations_admins_only():
from modules.system import isNodeAdmin
is_admin = isNodeAdmin(userID)
# Check if user owns this location
is_owner = (str(location_userID) == str(userID))
# Determine if deletion is allowed
can_delete = False
if is_public:
# Public locations: only admins can delete if admin-only is enabled
if get_delete_public_locations_admins_only():
can_delete = is_admin
else:
# If not admin-only, then anyone can delete public locations
can_delete = True
else:
# Private locations: owner can always delete
can_delete = is_owner
if not can_delete:
conn.close()
if is_public and get_delete_public_locations_admins_only():
return False, "Only admins can delete public locations."
else:
return False, f"You can only delete your own locations. This location belongs to another user."
# Delete the location
c.execute('''DELETE FROM locations WHERE location_id = ?''', (location_id,))
conn.commit()
conn.close()
visibility = "public" if is_public else "private"
logger.debug(f"Location: Deleted {visibility} location '{location_name}' from database")
return True, f"Location '{location_name}' deleted"
except Exception as e:
logger.error(f"Location: Failed to delete location: {e}")
return False, f"Error deleting location: {e}"
def calculate_heading_and_distance(lat1, lon1, lat2, lon2):
"""Calculate heading (bearing) and distance between two points"""
if lat1 == 0 and lon1 == 0:
return None, None, "Current location not available"
if lat2 == 0 and lon2 == 0:
return None, None, "Target location not available"
# Calculate distance using Haversine formula
r = 6371 # Earth radius in kilometers
lat1_rad = math.radians(lat1)
lon1_rad = math.radians(lon1)
lat2_rad = math.radians(lat2)
lon2_rad = math.radians(lon2)
dlon = lon2_rad - lon1_rad
dlat = lat2_rad - lat1_rad
a = math.sin(dlat / 2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2)**2
c = 2 * math.asin(math.sqrt(a))
distance_km = c * r
# Calculate bearing
x = math.sin(dlon) * math.cos(lat2_rad)
y = math.cos(lat1_rad) * math.sin(lat2_rad) - (math.sin(lat1_rad) * math.cos(lat2_rad) * math.cos(dlon))
initial_bearing = math.atan2(x, y)
initial_bearing = math.degrees(initial_bearing)
compass_bearing = (initial_bearing + 360) % 360
return compass_bearing, distance_km, None
def log_locationData_toMap(userID, location, message): def log_locationData_toMap(userID, location, message):
""" """
Logs location data to a CSV file for meshing purposes. Logs location data to a CSV file for meshing purposes.
@@ -1219,40 +1721,277 @@ def mapHandler(userID, deviceID, channel_number, message, snr, rssi, hop):
""" """
Handles 'map' commands from meshbot. Handles 'map' commands from meshbot.
Usage: Usage:
map <description text> - Log current location with description map save <name> [description] - Save current location with a name
map save public <name> [desc] - Save public location (all can see)
map <name> - Get heading and distance to a saved location
map public <name> - Get heading to public location (ignores private)
map delete <name> - Delete a location
map list - List all saved locations
map log <description> - Log current location with description (CSV, legacy)
""" """
command = str(command) # Ensure command is always a string command = str(command) # Ensure command is always a string
if command.strip().lower() == "?": if command.strip().lower() == "help":
return ( return (
"Usage:\n" f"'map save <name> [description]' - Save private\n"
" 🗺map <description text> - Log your current location with a description\n" f"'map save public <name> [desc]' - Save public\n"
"Example:\n" f"'map <name>' - heading to saved\n"
" 🗺map Found a new mesh node near the park" f"'map public <name>' - heading to public\n"
f"'map delete <name>' \n"
f"'map list' - List\n"
f"'map log <description>' - Log CSV\n"
) )
description = command.strip() # Handle "save" command
# if no description provided, set to default if command.lower().startswith("save "):
if not description: save_cmd = command[5:].strip()
description = "Logged:" is_public = False
# Sanitize description for CSV injection
if description and description[0] in ('=', '+', '-', '@'): # Check for "public" keyword
description = "'" + description if save_cmd.lower().startswith("public "):
is_public = True
# if there is SNR and RSSI info, append to description save_cmd = save_cmd[7:].strip() # Remove "public " prefix
if snr is not None and rssi is not None:
description += f" SNR:{snr}dB RSSI:{rssi}dBm" parts = save_cmd.split(" ", 1)
if len(parts) < 1 or not parts[0]:
if is_public:
return "🚫Usage: map save public <name> [description]"
else:
return "🚫Usage: map save <name> [description]"
location_name = parts[0]
description = parts[1] if len(parts) > 1 else ""
# Add SNR/RSSI info to description if available
if snr is not None and rssi is not None:
if description:
description += f" SNR:{snr}dB RSSI:{rssi}dBm"
else:
description = f"SNR:{snr}dB RSSI:{rssi}dBm"
if hop is not None:
if description:
description += f" Meta:{hop}"
else:
description = f"Meta:{hop}"
if not location or len(location) != 2 or lat == 0 or lon == 0:
return "🚫Location data is missing or invalid."
# Get altitude for the node
altitude = get_node_altitude(userID, deviceID)
success, msg, _ = save_location_to_db(location_name, lat, lon, description, str(userID), is_public, altitude)
if success:
return f"📍{msg}"
else:
return f"🚫{msg}"
# if there is hop info, append to description # Handle "list" command
if hop is not None: if command.strip().lower() == "list":
description += f" Meta:{hop}" return list_locations_from_db(str(userID))
# Handle "delete" command
if command.lower().startswith("delete "):
location_name = command[7:].strip() # Remove "delete " prefix
if not location_name:
return "🚫Usage: map delete <name>"
success, msg = delete_location_from_db(location_name, str(userID))
if success:
return f"🗑️{msg}"
else:
return f"🚫{msg}"
# Handle "public" command to retrieve public locations (even if user has private with same name)
if command.lower().startswith("public "):
location_name = command[7:].strip() # Remove "public " prefix
if not location_name:
return "🚫Usage: map public <name>"
saved_location = get_public_location_from_db(location_name)
if saved_location:
# Calculate heading and distance from current location
if not location or len(location) != 2 or lat == 0 or lon == 0:
result = f"📍{saved_location['name']} (Public): {saved_location['lat']:.5f}, {saved_location['lon']:.5f}"
if saved_location.get('altitude') is not None:
result += f" @ {saved_location['altitude']:.1f}m"
result += "\n🚫Current location not available for heading"
return result
bearing, distance_km, error = calculate_heading_and_distance(
lat, lon, saved_location['lat'], saved_location['lon']
)
if error:
return f"📍{saved_location['name']} (Public): {error}"
# Format distance
if my_settings.use_metric:
distance_str = f"{distance_km:.2f} km"
else:
distance_miles = distance_km * 0.621371
if distance_miles < 0.25:
# Convert to feet for short distances
distance_feet = distance_miles * 5280
distance_str = f"{distance_feet:.0f} ft"
else:
distance_str = f"{distance_miles:.2f} miles"
# Format bearing with cardinal direction
bearing_rounded = round(bearing)
cardinal = ""
if bearing_rounded == 0 or bearing_rounded == 360:
cardinal = "N"
elif bearing_rounded == 90:
cardinal = "E"
elif bearing_rounded == 180:
cardinal = "S"
elif bearing_rounded == 270:
cardinal = "W"
elif 0 < bearing_rounded < 90:
cardinal = "NE"
elif 90 < bearing_rounded < 180:
cardinal = "SE"
elif 180 < bearing_rounded < 270:
cardinal = "SW"
elif 270 < bearing_rounded < 360:
cardinal = "NW"
result = f"📍{saved_location['name']} (Public)\n"
result += f"🧭Heading: {bearing_rounded}° {cardinal}\n"
result += f"📏Distance: {distance_str}"
# Calculate altitude difference if both are available
current_altitude = get_node_altitude(userID, deviceID)
saved_altitude = saved_location.get('altitude')
if current_altitude is not None and saved_altitude is not None:
altitude_diff_m = saved_altitude - current_altitude # message altitude - DB altitude
altitude_diff_ft = altitude_diff_m * 3.28084 # Convert meters to feet
altitude_diff_ft_rounded = round(altitude_diff_ft) # Round to nearest foot
if altitude_diff_ft_rounded > 0:
result += f"\nAltitude: +{altitude_diff_ft_rounded}ft" # Message is higher
elif altitude_diff_ft_rounded < 0:
result += f"\nAltitude: {altitude_diff_ft_rounded}ft" # Message is lower (negative already has -)
else:
result += f"\nAltitude: ±0ft"
if saved_location['description']:
result += f"\n📝{saved_location['description']}"
return result
else:
return f"🚫Public location '{location_name}' not found."
# Handle "log" command for CSV logging
if command.lower().startswith("log "):
description = command[4:].strip() # Remove "log " prefix
# if no description provided, set to default
if not description:
description = "Logged:"
# Sanitize description for CSV injection
if description and description[0] in ('=', '+', '-', '@'):
description = "'" + description
# location should be a tuple: (lat, lon) # if there is SNR and RSSI info, append to description
if not location or len(location) != 2: if snr is not None and rssi is not None:
return "🚫Location data is missing or invalid." description += f" SNR:{snr}dB RSSI:{rssi}dBm"
# if there is hop info, append to description
if hop is not None:
description += f" Meta:{hop}"
success = log_locationData_toMap(userID, location, description) # location should be a tuple: (lat, lon)
if success: if not location or len(location) != 2:
return f"📍Location logged " return "🚫Location data is missing or invalid."
else:
return "🚫Failed to log location. Please try again." success = log_locationData_toMap(userID, location, description)
if success:
return f"📍Location logged (CSV)"
else:
return "🚫Failed to log location. Please try again."
# Handle location name lookup (get heading)
if command.strip():
location_name = command.strip()
saved_location = get_location_from_db(location_name, str(userID))
if saved_location:
# Calculate heading and distance from current location
if not location or len(location) != 2 or lat == 0 or lon == 0:
result = f"📍{saved_location['name']}: {saved_location['lat']:.5f}, {saved_location['lon']:.5f}"
if saved_location.get('altitude') is not None:
result += f" @ {saved_location['altitude']:.1f}m"
result += "\n🚫Current location not available for heading"
return result
bearing, distance_km, error = calculate_heading_and_distance(
lat, lon, saved_location['lat'], saved_location['lon']
)
if error:
return f"📍{saved_location['name']}: {error}"
# Format distance
if my_settings.use_metric:
distance_str = f"{distance_km:.2f} km"
else:
distance_miles = distance_km * 0.621371
if distance_miles < 0.25:
# Convert to feet for short distances
distance_feet = distance_miles * 5280
distance_str = f"{distance_feet:.0f} ft"
else:
distance_str = f"{distance_miles:.2f} miles"
# Format bearing with cardinal direction
bearing_rounded = round(bearing)
cardinal = ""
if bearing_rounded == 0 or bearing_rounded == 360:
cardinal = "N"
elif bearing_rounded == 90:
cardinal = "E"
elif bearing_rounded == 180:
cardinal = "S"
elif bearing_rounded == 270:
cardinal = "W"
elif 0 < bearing_rounded < 90:
cardinal = "NE"
elif 90 < bearing_rounded < 180:
cardinal = "SE"
elif 180 < bearing_rounded < 270:
cardinal = "SW"
elif 270 < bearing_rounded < 360:
cardinal = "NW"
result = f"📍{saved_location['name']}\n"
result += f"🧭Heading: {bearing_rounded}° {cardinal}\n"
result += f"📏Distance: {distance_str}"
# Calculate altitude difference if both are available
current_altitude = get_node_altitude(userID, deviceID)
saved_altitude = saved_location.get('altitude')
if current_altitude is not None and saved_altitude is not None:
altitude_diff_m = saved_altitude - current_altitude # message altitude - DB altitude
altitude_diff_ft = altitude_diff_m * 3.28084 # Convert meters to feet
altitude_diff_ft_rounded = round(altitude_diff_ft) # Round to nearest foot
if altitude_diff_ft_rounded > 0:
result += f"\nAltitude: +{altitude_diff_ft_rounded}ft" # Message is higher
elif altitude_diff_ft_rounded < 0:
result += f"\nAltitude: {altitude_diff_ft_rounded}ft" # Message is lower (negative already has -)
else:
result += f"\nAltitude: ±0ft"
if saved_location['description']:
result += f"\n📝{saved_location['description']}"
return result
else:
# Location not found
return f"🚫Location '{location_name}' not found. Use 'map list' to see available locations."
# Empty command - show help
return "🗺Use 'map help' for help"
# Initialize the locations database when module is imported
initialize_locations_database()

View File

@@ -135,6 +135,10 @@ if 'inventory' not in config:
config['inventory'] = {'enabled': 'False', 'inventory_db': 'data/inventory.db', 'disable_penny': 'False'} config['inventory'] = {'enabled': 'False', 'inventory_db': 'data/inventory.db', 'disable_penny': 'False'}
config.write(open(config_file, 'w')) config.write(open(config_file, 'w'))
if 'location' not in config:
config['location'] = {'locations_db': 'data/locations.db', 'public_location_admin_manage': 'False', 'delete_public_locations_admins_only': 'False'}
config.write(open(config_file, 'w'))
# interface1 settings # interface1 settings
interface1_type = config['interface'].get('type', 'serial') interface1_type = config['interface'].get('type', 'serial')
port1 = config['interface'].get('port', '') port1 = config['interface'].get('port', '')
@@ -393,6 +397,11 @@ try:
inventory_db = config['inventory'].get('inventory_db', 'data/inventory.db') inventory_db = config['inventory'].get('inventory_db', 'data/inventory.db')
disable_penny = config['inventory'].getboolean('disable_penny', False) disable_penny = config['inventory'].getboolean('disable_penny', False)
# location mapping
locations_db = config['location'].get('locations_db', 'data/locations.db')
public_location_admin_manage = config['location'].getboolean('public_location_admin_manage', False)
delete_public_locations_admins_only = config['location'].getboolean('delete_public_locations_admins_only', False)
# E-Mail Settings # E-Mail Settings
sysopEmails = config['smtp'].get('sysopEmails', '').split(',') sysopEmails = config['smtp'].get('sysopEmails', '').split(',')
enableSMTP = config['smtp'].getboolean('enableSMTP', False) enableSMTP = config['smtp'].getboolean('enableSMTP', False)