mirror of
https://github.com/SpudGunMan/meshing-around.git
synced 2026-03-28 17:32:36 +01:00
Merge pull request #278 from peanutyost/main
Added Features to Location module
This commit is contained in:
@@ -200,6 +200,12 @@ lat = 48.50
|
||||
lon = -123.0
|
||||
fuzzConfigLocation = True
|
||||
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
|
||||
useMetric = False
|
||||
|
||||
@@ -353,16 +353,15 @@ The system uses SQLite with four tables:
|
||||
| `howfar` | Distance traveled since last check |
|
||||
| `howtall` | Calculate height using sun angle |
|
||||
| `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`.
|
||||
|
||||
Certainly! Here’s a README help section for your `mapHandler` command, suitable for users of your meshbot:
|
||||
|
||||
---
|
||||
|
||||
## 📍 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
|
||||
|
||||
@@ -370,23 +369,117 @@ The `map` command allows you to log your current GPS location with a custom desc
|
||||
```
|
||||
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:
|
||||
```
|
||||
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
|
||||
|
||||
- The bot records your user ID, latitude, longitude, and your description in a CSV file (`data/map_data.csv`).
|
||||
- If your location data is missing or invalid, you’ll receive an error message.
|
||||
- You can view or process the CSV file later for mapping or analysis.
|
||||
- **Database Storage:** All locations are stored in a SQLite database (`data/locations.db` by default, configurable via `locations_db` in config.ini).
|
||||
- **Location Types:**
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import modules.settings as my_settings
|
||||
import math
|
||||
import csv
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
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}")
|
||||
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):
|
||||
"""
|
||||
Logs location data to a CSV file for meshing purposes.
|
||||
@@ -1219,19 +1721,172 @@ def mapHandler(userID, deviceID, channel_number, message, snr, rssi, hop):
|
||||
"""
|
||||
Handles 'map' commands from meshbot.
|
||||
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
|
||||
|
||||
if command.strip().lower() == "?":
|
||||
if command.strip().lower() == "help":
|
||||
return (
|
||||
"Usage:\n"
|
||||
" 🗺️map <description text> - Log your current location with a description\n"
|
||||
"Example:\n"
|
||||
" 🗺️map Found a new mesh node near the park"
|
||||
f"'map save <name> [description]' - Save private\n"
|
||||
f"'map save public <name> [desc]' - Save public\n"
|
||||
f"'map <name>' - heading to saved\n"
|
||||
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 command.lower().startswith("save "):
|
||||
save_cmd = command[5:].strip()
|
||||
is_public = False
|
||||
|
||||
# Check for "public" keyword
|
||||
if save_cmd.lower().startswith("public "):
|
||||
is_public = True
|
||||
save_cmd = save_cmd[7:].strip() # Remove "public " prefix
|
||||
|
||||
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}"
|
||||
|
||||
# Handle "list" command
|
||||
if command.strip().lower() == "list":
|
||||
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"\n⛰️Altitude: +{altitude_diff_ft_rounded}ft" # Message is higher
|
||||
elif altitude_diff_ft_rounded < 0:
|
||||
result += f"\n⛰️Altitude: {altitude_diff_ft_rounded}ft" # Message is lower (negative already has -)
|
||||
else:
|
||||
result += f"\n⛰️Altitude: ±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:"
|
||||
@@ -1253,6 +1908,90 @@ def mapHandler(userID, deviceID, channel_number, message, snr, rssi, hop):
|
||||
|
||||
success = log_locationData_toMap(userID, location, description)
|
||||
if success:
|
||||
return f"📍Location logged "
|
||||
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"\n⛰️Altitude: +{altitude_diff_ft_rounded}ft" # Message is higher
|
||||
elif altitude_diff_ft_rounded < 0:
|
||||
result += f"\n⛰️Altitude: {altitude_diff_ft_rounded}ft" # Message is lower (negative already has -)
|
||||
else:
|
||||
result += f"\n⛰️Altitude: ±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()
|
||||
|
||||
@@ -135,6 +135,10 @@ if 'inventory' not in config:
|
||||
config['inventory'] = {'enabled': 'False', 'inventory_db': 'data/inventory.db', 'disable_penny': 'False'}
|
||||
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_type = config['interface'].get('type', 'serial')
|
||||
port1 = config['interface'].get('port', '')
|
||||
@@ -393,6 +397,11 @@ try:
|
||||
inventory_db = config['inventory'].get('inventory_db', 'data/inventory.db')
|
||||
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
|
||||
sysopEmails = config['smtp'].get('sysopEmails', '').split(',')
|
||||
enableSMTP = config['smtp'].getboolean('enableSMTP', False)
|
||||
|
||||
Reference in New Issue
Block a user