feat(settings): add Settings modal with configurable DM retry parameters

Replace hardcoded DM retry logic with user-configurable settings stored
in app_settings DB. Settings modal opens from menu with tab-based UI
(ready for future settings tabs). Defaults: 3 direct + 1 flood retries
(was 8+2), 30s/60s intervals, 60s grace period.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-21 13:18:03 +01:00
parent 0108ea9149
commit 6f1a5462e9
5 changed files with 237 additions and 45 deletions
+23 -9
View File
@@ -1089,21 +1089,32 @@ class DeviceManager:
"""Background retry with same timestamp for dedup on receiver.
Strategy depends on whether contact has a known DIRECT path:
- DIRECT path known: up to 10 attempts (8 DIRECT + 2 FLOOD), 30s wait
- No path (FLOOD): up to 3 attempts, 60s wait
- DIRECT path known: direct_max_retries DIRECT + direct_flood_retries FLOOD
- No path (FLOOD): flood_max_retries attempts
Settings loaded from app_settings DB table (key: dm_retry_settings).
"""
from meshcore.events import EventType
# Load configurable retry settings from DB
_defaults = {
'direct_max_retries': 3, 'direct_flood_retries': 1,
'flood_max_retries': 3, 'direct_interval': 30,
'flood_interval': 60, 'grace_period': 60,
}
saved = self.db.get_setting_json('dm_retry_settings', {})
cfg = {**_defaults, **(saved or {})}
has_path = contact.get('out_path_len', -1) > 0
if has_path:
max_attempts = 10
flood_at = 8 # reset path to flood at this attempt
min_wait = 30.0 # seconds between DIRECT attempts
# +1 counts the initial send
max_attempts = cfg['direct_max_retries'] + cfg['direct_flood_retries'] + 1
flood_at = cfg['direct_max_retries'] + 1
min_wait = float(cfg['direct_interval'])
else:
max_attempts = 3
flood_at = None # already flood, no reset needed
min_wait = 60.0 # seconds between FLOOD attempts
max_attempts = cfg['flood_max_retries'] + 1
flood_at = None
min_wait = float(cfg['flood_interval'])
wait_s = max(suggested_timeout / 1000 * 1.2, min_wait)
mode = "DIRECT" if has_path else "FLOOD"
@@ -1124,6 +1135,9 @@ class DeviceManager:
# Retry with same timestamp, incrementing attempt
for attempt in range(1, max_attempts):
if flood_at and attempt >= flood_at:
# Switch to FLOOD mode: reset path and use flood interval
min_wait = float(cfg['flood_interval'])
wait_s = max(suggested_timeout / 1000 * 1.2, min_wait)
try:
await self.mc.commands.reset_path(contact)
logger.info(f"DM retry {attempt}: reset path to flood")
@@ -1161,7 +1175,7 @@ class DeviceManager:
logger.warning(f"DM retry exhausted ({max_attempts} {mode} attempts) for dm_id={dm_id}")
# Keep pending acks for grace period so late ACKs can still be matched
self._retry_tasks.pop(dm_id, None)
await asyncio.sleep(60)
await asyncio.sleep(cfg['grace_period'])
stale = [k for k, v in self._pending_acks.items() if v == dm_id]
if stale:
for k in stale:
-20
View File
@@ -443,26 +443,6 @@ def check_dm_delivery(ack_codes: list) -> Tuple[bool, Dict, str]:
return False, {}, str(e)
def get_retry_ack_codes() -> set:
"""Get retry ACK codes. Simplified in v2."""
return set()
def get_auto_retry_config() -> Tuple[bool, Dict]:
"""Get auto-retry config. Using meshcore library's built-in retry."""
return True, {
'enabled': True,
'max_attempts': 3,
'max_flood': 2,
'note': 'v2 uses meshcore library built-in retry (send_msg_with_retry)'
}
def set_auto_retry_config(enabled=None, max_attempts=None, max_flood=None) -> Tuple[bool, Dict]:
"""Set auto-retry config. Stub in v2."""
return get_auto_retry_config()
# =============================================================================
# Device Settings
# =============================================================================
+55 -14
View File
@@ -257,6 +257,42 @@ def save_retention_settings(retention_settings: dict) -> bool:
return False
# =============================================================================
# DM Retry Settings
# =============================================================================
DM_RETRY_DEFAULTS = {
'direct_max_retries': 3, # DIRECT retries before switching to FLOOD
'direct_flood_retries': 1, # FLOOD retries after DIRECT exhausted
'flood_max_retries': 3, # FLOOD retries when no path known
'direct_interval': 30, # seconds between DIRECT retries
'flood_interval': 60, # seconds between FLOOD retries
'grace_period': 60, # seconds to wait for late ACKs after exhaustion
}
def get_dm_retry_settings() -> dict:
"""Get DM retry settings from database."""
db = _get_db()
if db:
saved = db.get_setting_json('dm_retry_settings', {})
return {**DM_RETRY_DEFAULTS, **saved}
return dict(DM_RETRY_DEFAULTS)
def save_dm_retry_settings(settings: dict) -> bool:
"""Save DM retry settings to database."""
db = _get_db()
if not db:
return False
try:
db.set_setting_json('dm_retry_settings', settings)
return True
except Exception as e:
logger.error(f"Failed to save DM retry settings: {e}")
return False
@api_bp.route('/messages', methods=['GET'])
def get_messages():
"""
@@ -2071,32 +2107,37 @@ def send_dm_message():
@api_bp.route('/dm/auto_retry', methods=['GET'])
def get_auto_retry_config():
"""Get auto-retry configuration."""
"""Get DM retry settings."""
try:
success, data = cli.get_auto_retry_config()
if success:
return jsonify(data), 200
return jsonify({'success': False, 'error': 'Failed to get config'}), 500
return jsonify(get_dm_retry_settings()), 200
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/dm/auto_retry', methods=['POST'])
def set_auto_retry_config():
"""Update auto-retry configuration."""
"""Update DM retry settings."""
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Missing JSON body'}), 400
success, result = cli.set_auto_retry_config(
enabled=data.get('enabled'),
max_attempts=data.get('max_attempts'),
max_flood=data.get('max_flood')
)
if success:
return jsonify(result), 200
return jsonify({'success': False, 'error': 'Failed to update config'}), 500
# Validate numeric fields
valid_keys = set(DM_RETRY_DEFAULTS.keys())
settings = {}
for key in valid_keys:
if key in data:
val = data[key]
if not isinstance(val, (int, float)) or val < 0:
return jsonify({'success': False, 'error': f'Invalid value for {key}'}), 400
settings[key] = int(val)
if not settings:
return jsonify({'success': False, 'error': 'No valid settings provided'}), 400
if save_dm_retry_settings(settings):
return jsonify({**get_dm_retry_settings(), 'success': True}), 200
return jsonify({'success': False, 'error': 'Failed to save settings'}), 500
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
+89
View File
@@ -1637,6 +1637,95 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('statsTabBtn')?.addEventListener('shown.bs.tab', loadDeviceStats);
});
// =============================================================================
// Settings Modal
// =============================================================================
const DM_RETRY_DEFAULTS = {
direct_max_retries: 3,
direct_flood_retries: 1,
flood_max_retries: 3,
direct_interval: 30,
flood_interval: 60,
grace_period: 60
};
const DM_RETRY_FIELDS = {
direct_max_retries: 'settDirectMaxRetries',
direct_flood_retries: 'settDirectFloodRetries',
flood_max_retries: 'settFloodMaxRetries',
direct_interval: 'settDirectInterval',
flood_interval: 'settFloodInterval',
grace_period: 'settGracePeriod'
};
function populateDmRetryForm(data) {
for (const [key, elId] of Object.entries(DM_RETRY_FIELDS)) {
const el = document.getElementById(elId);
if (el) el.value = data[key] ?? DM_RETRY_DEFAULTS[key];
}
}
async function loadDmRetrySettings() {
try {
const resp = await fetch('/api/dm/auto_retry');
if (resp.ok) {
const data = await resp.json();
populateDmRetryForm(data);
}
} catch (e) {
console.error('Failed to load DM retry settings:', e);
}
}
async function saveDmRetrySettings() {
const payload = {};
for (const [key, elId] of Object.entries(DM_RETRY_FIELDS)) {
const el = document.getElementById(elId);
const val = parseInt(el.value, 10);
if (isNaN(val) || val < parseInt(el.min) || val > parseInt(el.max)) {
showNotification(`Invalid value for ${el.previousElementSibling?.textContent || key}`, 'danger');
el.focus();
return;
}
payload[key] = val;
}
try {
const resp = await fetch('/api/dm/auto_retry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.ok) {
showNotification('Settings saved', 'success');
} else {
const err = await resp.json();
showNotification(err.error || 'Failed to save', 'danger');
}
} catch (e) {
showNotification('Failed to save settings', 'danger');
}
}
document.addEventListener('DOMContentLoaded', () => {
const settingsModal = document.getElementById('settingsModal');
if (settingsModal) {
settingsModal.addEventListener('show.bs.modal', loadDmRetrySettings);
}
const dmRetryForm = document.getElementById('dmRetrySettingsForm');
if (dmRetryForm) {
dmRetryForm.addEventListener('submit', (e) => {
e.preventDefault();
saveDmRetrySettings();
});
}
document.getElementById('settingsResetBtn')?.addEventListener('click', () => {
populateDmRetryForm(DM_RETRY_DEFAULTS);
});
});
/**
* Cleanup inactive contacts
*/
+70 -2
View File
@@ -165,11 +165,11 @@
<small class="d-block text-muted">Database backup & restore</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" id="settingsBtn" disabled>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#settingsModal" data-bs-dismiss="offcanvas">
<i class="bi bi-gear" style="font-size: 1.5rem;"></i>
<div>
<span>Settings</span>
<small class="d-block text-muted">Coming soon</small>
<small class="d-block text-muted">Application settings</small>
</div>
</button>
</div>
@@ -334,6 +334,74 @@
</div>
</div>
<!-- Settings Modal -->
<div class="modal fade" id="settingsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-gear"></i> Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabSettingsMessages" type="button">Messages</button>
</li>
<!-- Future tabs added here -->
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tabSettingsMessages">
<div id="settingsMessagesContent">
<form id="dmRetrySettingsForm">
<h6 class="text-muted mb-2">When path is known (DIRECT)</h6>
<div class="mb-2">
<label class="form-label mb-0 small">Direct retries</label>
<input type="number" class="form-control form-control-sm" id="settDirectMaxRetries" min="0" max="20" value="3">
<div class="form-text">Attempts via known path before switching to flood</div>
</div>
<div class="mb-2">
<label class="form-label mb-0 small">Flood retries after direct</label>
<input type="number" class="form-control form-control-sm" id="settDirectFloodRetries" min="0" max="5" value="1">
<div class="form-text">Flood attempts after direct retries exhausted</div>
</div>
<div class="mb-3">
<label class="form-label mb-0 small">Interval (seconds)</label>
<input type="number" class="form-control form-control-sm" id="settDirectInterval" min="5" max="300" value="30">
<div class="form-text">Wait between direct retries</div>
</div>
<h6 class="text-muted mb-2">When no path (FLOOD)</h6>
<div class="mb-2">
<label class="form-label mb-0 small">Max retries</label>
<input type="number" class="form-control form-control-sm" id="settFloodMaxRetries" min="0" max="10" value="3">
<div class="form-text">Flood attempts when no path is known</div>
</div>
<div class="mb-3">
<label class="form-label mb-0 small">Interval (seconds)</label>
<input type="number" class="form-control form-control-sm" id="settFloodInterval" min="5" max="300" value="60">
<div class="form-text">Wait between flood retries</div>
</div>
<h6 class="text-muted mb-2">Other</h6>
<div class="mb-3">
<label class="form-label mb-0 small">Grace period (seconds)</label>
<input type="number" class="form-control form-control-sm" id="settGracePeriod" min="10" max="300" value="60">
<div class="form-text">Wait for late ACKs after all retries exhausted</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="settingsResetBtn">Reset to defaults</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Map Modal (Leaflet) -->
<div class="modal fade" id="mapModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">