diff --git a/app/device_manager.py b/app/device_manager.py index f9cfba9..8e6dae7 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -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: diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index d033f0d..f67ecb8 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -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 # ============================================================================= diff --git a/app/routes/api.py b/app/routes/api.py index 8c552af..ab03819 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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 diff --git a/app/static/js/app.js b/app/static/js/app.js index 591761b..b655165 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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 */ diff --git a/app/templates/base.html b/app/templates/base.html index 8d0f814..abbfb1b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -165,11 +165,11 @@ Database backup & restore - @@ -334,6 +334,74 @@ + + +