Add transitional support for 2-byte prefixes while keeping legacy 1-byte compatibility

- Update prefix command to accept BOTH legacy 2-char prefixes and
  configured prefix_hex_chars (e.g. 4-char) during firmware transition
- Replace strict length validation with dual-length validation (2 or N)
- Ensure prefix lookups work with either input length via LIKE matching
- Update related SQL prefix extraction to use configured prefix length
- Add fallback handling in path parsing for legacy 2-char route data

Notes:
- This is an interim compatibility change to support mixed networks
  where RF path data is still 1-byte while bot config may be 2-byte.
- Needs additional testing across real multi-hop scenarios and mixed
  bot configurations.
- Translation updates are incomplete: only English strings were updated;
  other translation files still need review.
- Behavior and UX may need refinement after real-world testing.
This commit is contained in:
Ian Rifkin
2026-02-27 23:48:27 -05:00
parent a4174beddf
commit 36a8a67543
12 changed files with 168 additions and 105 deletions
+3
View File
@@ -154,6 +154,9 @@ respond_to_dms = true
# Example: channel_keywords = help,ping,test,hello
# channel_keywords =
# Set a custom prefix length for the public keys to identify repeaters
prefix_bytes = 1
[Banned_Users]
# List of banned sender names (comma-separated). Matching is prefix (starts-with):
# "Awful Username" also matches "Awful Username 🍆". No bot responses in channels or DMs.
+13 -4
View File
@@ -224,8 +224,16 @@ class PathCommand(BaseCommand):
path_input = path_input.replace(',', ' ').replace(':', ' ')
# Extract hex values using regex
hex_pattern = r'[0-9a-fA-F]{2}'
# Try configured width first
n = getattr(self.bot, "prefix_hex_chars", 2)
hex_pattern = rf'[0-9a-fA-F]{{{n}}}'
hex_matches = re.findall(hex_pattern, path_input)
# Backward compatibility:
# if no matches and we're expecting >2 chars, try legacy 2-char paths
if not hex_matches and n > 2:
legacy_pattern = r'[0-9a-fA-F]{2}'
hex_matches = re.findall(legacy_pattern, path_input)
if not hex_matches:
return self.translate('commands.path.no_valid_hex')
@@ -266,7 +274,7 @@ class PathCommand(BaseCommand):
api_data = None
# Query the database for repeaters with matching prefixes
# Node IDs are typically the first 2 characters of the public key
# Node IDs are the configured prefix of the public key (see Bot.prefix_bytes)
for node_id in node_ids:
# Test dependency injection: use provided lookup when available
if lookup_func is not None:
@@ -1313,7 +1321,8 @@ class PathCommand(BaseCommand):
best_method = None
for repeater in repeaters:
candidate_prefix = repeater.get('public_key', '')[:2].lower() if repeater.get('public_key') else None
pk = repeater.get('public_key') or ''
candidate_prefix = self.bot.key_prefix(pk).lower() if pk else None
candidate_public_key = repeater.get('public_key', '').lower() if repeater.get('public_key') else None
if not candidate_prefix:
continue
@@ -1712,7 +1721,7 @@ class PathCommand(BaseCommand):
else:
# Try to decode even single nodes (e.g., "01" should be decoded to a repeater name)
# Check if path_part looks like it contains hex values
hex_pattern = r'[0-9a-fA-F]{2}'
hex_pattern = rf'[0-9a-fA-F]{{{self.bot.prefix_hex_chars}}}'
if re.search(hex_pattern, path_part):
# Looks like hex values, try to decode
return await self._decode_path(path_part)
+76 -49
View File
@@ -26,19 +26,19 @@ class PrefixCommand(BaseCommand):
# Plugin metadata
name = "prefix"
keywords = ['prefix', 'repeater', 'lookup']
description = "Look up repeaters by two-character prefix (e.g., 'prefix 1A')"
description = "Look up repeaters by prefix (e.g., 'prefix 1A' or 'prefix 2299')"
category = "meshcore_info"
requires_dm = False
cooldown_seconds = 2
requires_internet = False # Will be set to True in __init__ if API is configured
# Documentation
short_description = "Look up repeaters by two-character prefix and show their locations (if known)"
usage = "prefix <XX|free|refresh>"
examples = ["prefix 1A", "prefix free"]
short_description = "Look up repeaters by prefix and show their locations (if known)"
usage = "prefix <XX|XXXX|free|refresh>"
examples = ["prefix 1A", "prefix 2299", "prefix free"]
parameters = [
{"name": "prefix", "description": "Two-character prefix (e.g., 1A, 2B)"},
{"name": "free", "description": "Show available/unused prefixes"}
{"name": "prefix", "description": "Prefix in hex (2 chars or configured length)"},
{"name": "free", "description": "Show available/unused prefixes (may be disabled)"},
]
def __init__(self, bot: Any):
@@ -252,17 +252,18 @@ class PrefixCommand(BaseCommand):
"""
try:
# Query all repeaters with valid coordinates
query = '''
SELECT SUBSTR(public_key, 1, 2) as prefix, public_key, name,
latitude, longitude,
COALESCE(last_advert_timestamp, last_heard) as last_seen
FROM complete_contact_tracking
WHERE role IN ('repeater', 'roomserver')
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND latitude != 0
AND longitude != 0
'''
n = int(getattr(self.bot, "prefix_hex_chars", 2))
query = f"""
SELECT SUBSTR(public_key, 1, {n}) AS prefix,
COUNT(*) AS repeater_count,
AVG(latitude) AS avg_lat,
AVG(longitude) AS avg_lon,
MAX(COALESCE(last_advert_timestamp, last_heard)) AS most_recent
FROM complete_contact_tracking
WHERE role IN ('repeater', 'roomserver')
AND LENGTH(public_key) >= {n}
GROUP BY prefix
"""
results = self.bot.db_manager.execute_query(query)
@@ -380,17 +381,18 @@ class PrefixCommand(BaseCommand):
"""
try:
# Get all known prefixes from database
query = '''
SELECT SUBSTR(public_key, 1, 2) as prefix,
COUNT(*) as repeater_count,
AVG(latitude) as avg_lat,
AVG(longitude) as avg_lon,
MAX(COALESCE(last_advert_timestamp, last_heard)) as most_recent
FROM complete_contact_tracking
WHERE role IN ('repeater', 'roomserver')
AND LENGTH(public_key) >= 2
GROUP BY prefix
'''
n = int(getattr(self.bot, "prefix_hex_chars", 2))
query = f"""
SELECT SUBSTR(public_key, 1, {n}) AS prefix,
COUNT(*) AS repeater_count,
AVG(latitude) AS avg_lat,
AVG(longitude) AS avg_lon,
MAX(COALESCE(last_advert_timestamp, last_heard)) AS most_recent
FROM complete_contact_tracking
WHERE role IN ('repeater', 'roomserver')
AND LENGTH(public_key) >= {n}
GROUP BY prefix
"""
results = self.bot.db_manager.execute_query(query)
@@ -451,8 +453,9 @@ class PrefixCommand(BaseCommand):
# Also include free prefixes (not in database) that aren't neighbors or excluded
# Generate all valid hex prefixes (01-FE, excluding 00 and FF)
for i in range(1, 255): # 1 to 254 (exclude 0 and 255)
prefix = f"{i:02X}"
max_val = (16 ** self.bot.prefix_hex_chars)
for i in range(1, max_val - 1): # still excluding all-zeros and all-FF..FF
prefix = f"{i:0{self.bot.prefix_hex_chars}X}"
prefix_lower = prefix.lower()
# Skip if already in database (already processed above)
@@ -803,6 +806,11 @@ class PrefixCommand(BaseCommand):
# Handle free/available command
if command == "FREE" or command == "AVAILABLE":
if getattr(self.bot, "prefix_hex_chars", 2) > 2:
# Keep behavior consistent: send a response and return True
await self._send_prefix_response(message, "Feature disabled for multi-byte prefixes.")
return True
free_prefixes, total_free, has_data = await self.get_free_prefixes()
if not has_data:
response = self.translate('commands.prefix.unable_determine_free')
@@ -839,10 +847,22 @@ class PrefixCommand(BaseCommand):
if len(parts) >= 3 and parts[2].upper() == "ALL":
include_all = True
# Validate prefix format
if len(command) != 2 or not command.isalnum():
response = self.translate('commands.prefix.invalid_format')
return await self.send_response(message, response)
# Validate prefix format:
# - allow legacy 2-char prefixes (current mesh hop IDs)
# - allow configured N-char prefixes (e.g., 4) for pubkey-prefix lookups
n = int(getattr(self.bot, "prefix_hex_chars", 2))
allowed_lengths = {2, n}
if len(command) not in allowed_lengths:
# If you updated translations to mention {{prefix_hex_chars}}, great,
# but this is clearer during the transition:
response = f"Invalid prefix format. Expected 2 or {n} hex characters."
return await self.send_response(message, response)
import re
if not re.fullmatch(r"[0-9a-fA-F]+", command):
response = f"Invalid prefix format. Expected 2 or {n} hex characters."
return await self.send_response(message, response)
# Get prefix data
prefix_data = await self.get_prefix_data(command, include_all=include_all)
@@ -1055,7 +1075,6 @@ class PrefixCommand(BaseCommand):
AND last_heard >= datetime('now', '-{self.prefix_heard_days} days')
ORDER BY name
'''
# The prefix should match the first two characters of the public key
prefix_pattern = f"{prefix}%"
@@ -1190,23 +1209,29 @@ class PrefixCommand(BaseCommand):
# When using database, use prefix_free_days to filter which prefixes are considered "used"
# Only repeaters heard within prefix_free_days will be considered as using a prefix
try:
# If distance filtering is enabled, we need location data to filter
n = int(getattr(self.bot, "prefix_hex_chars", 2))
# If distance filtering is enabled, we need location data to filter
if self.distance_filtering_enabled:
query = f'''
SELECT DISTINCT SUBSTR(public_key, 1, 2) as prefix, latitude, longitude
FROM complete_contact_tracking
WHERE role IN ('repeater', 'roomserver')
AND LENGTH(public_key) >= 2
AND last_heard >= datetime('now', '-{self.prefix_free_days} days')
SELECT DISTINCT SUBSTR(public_key, 1, {n}) as prefix,
latitude,
longitude
FROM complete_contact_tracking
WHERE role IN ('repeater', 'roomserver')
AND LENGTH(public_key) >= {n}
AND last_heard >= datetime('now', '-{self.prefix_free_days} days')
'''
else:
query = f'''
SELECT DISTINCT SUBSTR(public_key, 1, 2) as prefix
FROM complete_contact_tracking
WHERE role IN ('repeater', 'roomserver')
AND LENGTH(public_key) >= 2
AND last_heard >= datetime('now', '-{self.prefix_free_days} days')
SELECT DISTINCT SUBSTR(public_key, 1, {n}) as prefix
FROM complete_contact_tracking
WHERE role IN ('repeater', 'roomserver')
AND LENGTH(public_key) >= {n}
AND last_heard >= datetime('now', '-{self.prefix_free_days} days')
'''
results = self.bot.db_manager.execute_query(query)
for row in results:
prefix = row['prefix'].upper()
@@ -1236,10 +1261,12 @@ class PrefixCommand(BaseCommand):
self.logger.warning("No data available for free prefixes lookup (empty cache and database)")
return [], 0, False
# Generate all valid hex prefixes (01-FE, excluding 00 and FF)
# Generate all valid hex prefixes (exclude all-zeros and all-FF)
all_prefixes = []
for i in range(1, 255): # 1 to 254 (exclude 0 and 255)
prefix = f"{i:02X}"
max_val = 16 ** self.bot.prefix_hex_chars
for i in range(1, max_val - 1):
prefix = f"{i:0{self.bot.prefix_hex_chars}X}"
all_prefixes.append(prefix)
# Find free prefixes
+11
View File
@@ -79,6 +79,11 @@ class MeshCoreBot:
except (OSError, ValueError, sqlite3.Error) as e:
self.logger.error(f"Failed to initialize database manager: {e}")
raise
# Set length of prefix
self.prefix_bytes = self.config.getint("Bot", "prefix_bytes", fallback=1)
self.prefix_hex_chars = self.prefix_bytes * 2
self.logger.info(f"Prefix mode: {self.prefix_bytes} bytes ({self.prefix_hex_chars} hex chars)")
# Store start time in database for web viewer access
try:
@@ -1532,3 +1537,9 @@ long_jokes = false
self.logger.error(f"Error sending startup advert: {e}")
import traceback
self.logger.error(traceback.format_exc())
def key_prefix(self, public_key: str) -> str:
return public_key[:self.prefix_hex_chars]
def is_valid_prefix(self, prefix: str) -> bool:
return len(prefix) == self.prefix_hex_chars
+10 -10
View File
@@ -166,8 +166,8 @@ class MeshGraph:
return
# Normalize prefixes to lowercase
from_prefix = from_prefix.lower()[:2]
to_prefix = to_prefix.lower()[:2]
from_prefix = from_prefix.lower()[:self.bot.prefix_hex_chars]
to_prefix = to_prefix.lower()[:self.bot.prefix_hex_chars]
# Intern public key strings so repeated identical keys share one object in RAM
if from_public_key:
@@ -797,8 +797,8 @@ class MeshGraph:
Returns:
bool: True if edge exists.
"""
from_prefix = from_prefix.lower()[:2]
to_prefix = to_prefix.lower()[:2]
from_prefix = from_prefix.lower()[:self.bot.prefix_hex_chars]
to_prefix = to_prefix.lower()[:self.bot.prefix_hex_chars]
return (from_prefix, to_prefix) in self.edges
def get_edge(self, from_prefix: str, to_prefix: str) -> Optional[Dict]:
@@ -811,8 +811,8 @@ class MeshGraph:
Returns:
Dict with edge data or None if not found.
"""
from_prefix = from_prefix.lower()[:2]
to_prefix = to_prefix.lower()[:2]
from_prefix = from_prefix.lower()[:self.bot.prefix_hex_chars]
to_prefix = to_prefix.lower()[:self.bot.prefix_hex_chars]
return self.edges.get((from_prefix, to_prefix))
def get_outgoing_edges(self, prefix: str) -> List[Dict]:
@@ -826,7 +826,7 @@ class MeshGraph:
Returns:
List of edge dictionaries.
"""
prefix = prefix.lower()[:2]
prefix = prefix.lower()[:self.bot.prefix_hex_chars]
to_prefixes = self._outgoing_index.get(prefix)
if not to_prefixes:
return []
@@ -848,7 +848,7 @@ class MeshGraph:
Returns:
List of edge dictionaries.
"""
prefix = prefix.lower()[:2]
prefix = prefix.lower()[:self.bot.prefix_hex_chars]
from_prefixes = self._incoming_index.get(prefix)
if not from_prefixes:
return []
@@ -1052,8 +1052,8 @@ class MeshGraph:
List of (candidate_prefix, score) tuples sorted by score (highest first).
Score is 0.0-1.0 based on path strength.
"""
from_prefix = from_prefix.lower()[:2]
to_prefix = to_prefix.lower()[:2]
from_prefix = from_prefix.lower()[:self.bot.prefix_hex_chars]
to_prefix = to_prefix.lower()[:self.bot.prefix_hex_chars]
candidates: Dict[str, float] = {}
+2 -2
View File
@@ -2045,7 +2045,7 @@ class MessageHandler:
self.logger.debug("Mesh graph: No public key in advert data, skipping graph update")
return
advertiser_prefix = advertiser_key[:2].lower()
advertiser_prefix = advertiser_key[:self.bot.prefix_hex_chars].lower()
# Parse path from hex string
path_nodes = []
@@ -3115,7 +3115,7 @@ class MessageHandler:
try:
node_data = {
'public_key': public_key,
'prefix': public_key[:2].lower() if public_key else '',
'prefix': public_key[:self.bot.prefix_hex_chars].lower() if public_key else '',
'name': contact_name,
'role': 'repeater'
}
+3 -3
View File
@@ -59,7 +59,7 @@ class TransmissionTracker:
if hasattr(device_info, 'public_key'):
pubkey = device_info.public_key
if isinstance(pubkey, str) and len(pubkey) >= 2:
self.bot_prefix = pubkey[:2].lower()
self.bot_prefix = pubkey[:self.bot.prefix_hex_chars].lower()
elif isinstance(pubkey, bytes) and len(pubkey) >= 1:
self.bot_prefix = f"{pubkey[0]:02x}".lower()
self.logger.debug(f"Bot prefix set to: {self.bot_prefix}")
@@ -300,7 +300,7 @@ class TransmissionTracker:
last_node = path_nodes[-1]
if isinstance(last_node, str) and len(last_node) >= 2:
# Take first 2 characters as prefix
prefix = last_node[:2].lower()
prefix = last_node[:self.bot.prefix_hex_chars].lower()
# Filter out our own prefix
if prefix != self.bot_prefix:
return [prefix]
@@ -318,7 +318,7 @@ class TransmissionTracker:
if parts:
last_part = parts[-1]
if len(last_part) >= 2:
prefix = last_part[:2].lower()
prefix = last_part[:self.bot.prefix_hex_chars].lower()
# Filter out our own prefix
if prefix != self.bot_prefix:
return [prefix]
+2 -2
View File
@@ -1567,8 +1567,8 @@ def parse_path_string(path_str: str) -> List[str]:
# Replace common separators with spaces
path_str = path_str.replace(',', ' ').replace(':', ' ')
# Extract hex values using regex (2-character hex pairs)
hex_pattern = r'[0-9a-fA-F]{2}'
# Extract hex values using regex (prefix_hex_chars-wide hex tokens)
hex_pattern = rf'[0-9a-fA-F]{{{prefix_hex_chars}}}'
hex_matches = re.findall(hex_pattern, path_str)
# Convert to uppercase for consistency
+8 -5
View File
@@ -370,7 +370,7 @@ class BotDataViewer:
else:
# Space/comma-separated format
path_input = path_input.replace(',', ' ').replace(':', ' ')
hex_pattern = r'[0-9a-fA-F]{2}'
hex_pattern = rf'[0-9a-fA-F]{{{prefix_hex_chars}}}'
hex_matches = re.findall(hex_pattern, path_input)
if not hex_matches:
@@ -675,7 +675,7 @@ class BotDataViewer:
best_method = None
for repeater in repeaters:
candidate_prefix = repeater.get('public_key', '')[:2].lower() if repeater.get('public_key') else None
candidate_prefix = repeater.get('public_key', '')[:self.bot.prefix_hex_chars].lower() if repeater.get('public_key') else None
candidate_public_key = repeater.get('public_key', '').lower() if repeater.get('public_key') else None
if not candidate_prefix:
continue
@@ -1137,7 +1137,10 @@ class BotDataViewer:
@self.app.route('/mesh')
def mesh():
"""Mesh graph visualization page"""
return render_template('mesh.html')
return render_template(
'mesh.html',
prefix_hex_chars=self.bot.prefix_hex_chars
)
# Favicon routes
@self.app.route('/apple-touch-icon.png')
@@ -5167,7 +5170,7 @@ class BotDataViewer:
else:
# Space/comma-separated format
path_input = path_hex.replace(',', ' ').replace(':', ' ')
hex_pattern = r'[0-9a-fA-F]{2}'
hex_pattern = rf'[0-9a-fA-F]{{{prefix_hex_chars}}}'
hex_matches = re.findall(hex_pattern, path_input)
if not hex_matches:
@@ -5310,7 +5313,7 @@ class BotDataViewer:
best_method = None
for repeater in repeaters:
candidate_prefix = repeater.get('public_key', '')[:2].lower() if repeater.get('public_key') else None
candidate_prefix = repeater.get('public_key', '')[:self.bot.prefix_hex_chars].lower() if repeater.get('public_key') else None
candidate_public_key = repeater.get('public_key', '').lower() if repeater.get('public_key') else None
if not candidate_prefix:
continue
+34 -24
View File
@@ -378,35 +378,45 @@
let isInitialMapLoad = true; // Track if this is the first time rendering the map with nodes
let highlightedPath = null; // Currently highlighted path data
let pathHighlightTimeout = null; // Debounce timer for path resolution
const PREFIX_HEX_CHARS = {{ prefix_hex_chars|default(2) }};
// Helper function to create unique node identifier
function getNodeId(node) {
return `${node.prefix}-${node.latitude.toFixed(6)}-${node.longitude.toFixed(6)}`;
}
// Detect if input is a hex path (2+ hex values)
function detectPathInput(input) {
if (!input || input.trim().length === 0) return false;
// Normalize input: remove commas, spaces, colons
const normalized = input.replace(/[,\s:]/g, '');
// Check if it's a continuous hex string (e.g., "8601a5")
// If it's all hex and has 4+ characters (2+ hex pairs), it's a path
if (/^[0-9a-fA-F]{4,}$/.test(normalized)) {
return true;
}
// Also check for space-separated hex values
const hexPattern = /\b[0-9a-fA-F]{2}\b/g;
const matches = input.match(hexPattern);
// If we have 2+ hex values, treat as path
return matches && matches.length >= 2;
}
// Resolve path via API
async function resolvePath(pathInput) {
function detectPathInput(input) {
if (!input || input.trim().length === 0) return false;
// Pull from template if you can, otherwise default.
// If you already have this elsewhere on the page, reuse it.
const prefixHexChars = PREFIX_HEX_CHARS;
// Normalize input: remove commas, spaces, colons
const normalized = input.replace(/[,\s:]/g, '');
// Check if it's a continuous hex string (e.g., "8601a5" or "8601A58F02")
// If it's all hex and has at least 2 tokens, treat as path.
// Optional: require it to align on token boundaries to reduce false positives.
const minChars = prefixHexChars * 2; // 2+ tokens
if (new RegExp(`^[0-9a-fA-F]{${minChars},}$`).test(normalized)) {
// If you want to be slightly stricter (still minimal), uncomment:
// if (normalized.length % prefixHexChars === 0) return true;
return true;
}
// Also check for space-separated hex values
const hexPattern = new RegExp(`\\b[0-9a-fA-F]{${prefixHexChars}}\\b`, 'g');
const matches = input.match(hexPattern);
// If we have 2+ hex values, treat as path
return matches && matches.length >= 2;
}
// Resolve path via API
async function resolvePath(pathInput) {
try {
const response = await fetch('/api/mesh/resolve-path', {
method: 'POST',
+4 -4
View File
@@ -104,8 +104,8 @@ def create_test_edge(
to_public_key = (to_prefix.lower() * 16)[:64]
return {
'from_prefix': from_prefix.lower()[:2],
'to_prefix': to_prefix.lower()[:2],
'from_prefix': from_prefix.lower()[:self.bot.prefix_hex_chars],
'to_prefix': to_prefix.lower()[:self.bot.prefix_hex_chars],
'from_public_key': from_public_key,
'to_public_key': to_public_key,
'observation_count': observation_count,
@@ -125,7 +125,7 @@ def create_test_path(node_ids: List[str]) -> List[str]:
Returns:
List of node IDs (normalized to lowercase)
"""
return [node_id.lower()[:2] for node_id in node_ids]
return [node_id.lower()[:self.bot.prefix_hex_chars] for node_id in node_ids]
def populate_test_graph(mesh_graph, edges: List[Dict[str, Any]]):
@@ -145,7 +145,7 @@ def populate_test_graph(mesh_graph, edges: List[Dict[str, Any]]):
geographic_distance=edge.get('geographic_distance')
)
# Manually set observation_count and timestamps if needed
edge_key = (edge['from_prefix'].lower()[:2], edge['to_prefix'].lower()[:2])
edge_key = (edge['from_prefix'].lower()[:self.bot.prefix_hex_chars], edge['to_prefix'].lower()[:self.bot.prefix_hex_chars])
if edge_key in mesh_graph.edges:
if edge.get('observation_count', 1) > 1:
mesh_graph.edges[edge_key]['observation_count'] = edge['observation_count']
+2 -2
View File
@@ -245,7 +245,7 @@
"path": {
"description": "Decode hex path data to show which repeaters were involved in message routing",
"help": "Path: path [hex] - Decode path to show repeaters. Use path alone for current message path, or path [7e,01] for specific path.",
"no_valid_hex": "❌ No valid hex values found in path data. Use format like: 11,98,a4,49,cd,5f,01",
"no_valid_hex": "❌ No valid hex values found in path data.",
"no_path": "❌ No path information available in current message",
"error": "Error processing path: {error}",
"error_decoding": "❌ Error decoding path: {error}",
@@ -273,7 +273,7 @@
"refresh_not_available": "❌ Refresh not available - no API URL configured. Using local database only.",
"cache_refreshed": "🔄 Repeater prefix cache refreshed!",
"unable_determine_free": "❌ Unable to determine free prefixes. Try 'prefix refresh' first.",
"invalid_format": "❌ Invalid prefix format. Use two characters (e.g., prefix 1A)",
"invalid_format": "❌ Invalid prefix format. Expected {prefix_hex_chars} hex characters.",
"no_repeaters_found": "❌ No repeaters found with prefix '{prefix}'",
"no_free_prefixes": "❌ No free prefixes found (all 254 valid prefixes are in use)",
"available_prefixes": "Available Prefixes ({shown} of {total} free):",