|
20 | 20 | from app.meshcore import cli, parser |
21 | 21 | from app.meshcore.regions import derive_scope_key_hex, is_valid_region_name |
22 | 22 | from app.config import config, runtime_config |
23 | | -from app.device_manager import decode_path_len |
| 23 | +from app.device_manager import decode_path_len, LETSMESH_ANALYZER_URL_TEMPLATE |
24 | 24 | from app.archiver import manager as archive_manager |
25 | 25 | from app.contacts_cache import get_all_names, get_all_contacts |
26 | 26 |
|
@@ -56,16 +56,15 @@ def _get_dm(): |
56 | 56 | CONTACTS_DETAILED_CACHE_TTL = 60 # seconds |
57 | 57 |
|
58 | 58 |
|
59 | | -ANALYZER_BASE_URL = 'https://analyzer.letsmesh.net/packets?packet_hash=' |
60 | 59 | GRP_TXT_TYPE_BYTE = 0x05 |
| 60 | +ANALYZER_PLACEHOLDER = '{packetHash}' |
61 | 61 |
|
62 | 62 |
|
63 | | -def compute_analyzer_url(pkt_payload): |
64 | | - """Compute MeshCore Analyzer URL from a hex-encoded pkt_payload.""" |
| 63 | +def compute_packet_hash(pkt_payload): |
| 64 | + """Compute MeshCore Analyzer packet hash (16 uppercase hex chars) from a hex-encoded pkt_payload.""" |
65 | 65 | try: |
66 | 66 | raw = bytes([GRP_TXT_TYPE_BYTE]) + bytes.fromhex(pkt_payload) |
67 | | - packet_hash = hashlib.sha256(raw).hexdigest()[:16].upper() |
68 | | - return f"{ANALYZER_BASE_URL}{packet_hash}" |
| 67 | + return hashlib.sha256(raw).hexdigest()[:16].upper() |
69 | 68 | except (ValueError, TypeError): |
70 | 69 | return None |
71 | 70 |
|
@@ -483,9 +482,9 @@ def get_messages(): |
483 | 482 | 'pkt_payload': pkt_payload, |
484 | 483 | } |
485 | 484 |
|
486 | | - # Enrich with echo data and analyzer URL |
| 485 | + # Enrich with echo data and packet hash (frontend builds analyzer URL) |
487 | 486 | if pkt_payload: |
488 | | - msg['analyzer_url'] = compute_analyzer_url(pkt_payload) |
| 487 | + msg['packet_hash'] = compute_packet_hash(pkt_payload) |
489 | 488 | echoes = db.get_echoes_for_message(pkt_payload) |
490 | 489 | if echoes: |
491 | 490 | msg['echo_count'] = len(echoes) |
@@ -590,7 +589,7 @@ def get_message_meta(msg_id): |
590 | 589 | } |
591 | 590 |
|
592 | 591 | if pkt_payload: |
593 | | - meta['analyzer_url'] = compute_analyzer_url(pkt_payload) |
| 592 | + meta['packet_hash'] = compute_packet_hash(pkt_payload) |
594 | 593 | echoes = db.get_echoes_for_message(pkt_payload) |
595 | 594 | if echoes: |
596 | 595 | meta['echo_count'] = len(echoes) |
@@ -4186,6 +4185,157 @@ def set_default_region_api(region_id): |
4186 | 4185 | return jsonify({'success': False, 'error': str(e)}), 500 |
4187 | 4186 |
|
4188 | 4187 |
|
| 4188 | +# ============================================================================= |
| 4189 | +# Analyzers (user-configured MeshCore Analyzer services) — Settings > Analyzer tab |
| 4190 | +# ============================================================================= |
| 4191 | + |
| 4192 | +def _validate_analyzer_url_template(url_template: str): |
| 4193 | + """Return (ok, error_msg). Validates the template the frontend will substitute.""" |
| 4194 | + if not url_template: |
| 4195 | + return False, 'URL is required' |
| 4196 | + if not (url_template.startswith('http://') or url_template.startswith('https://')): |
| 4197 | + return False, 'URL must start with http:// or https://' |
| 4198 | + if ANALYZER_PLACEHOLDER not in url_template: |
| 4199 | + return False, f'URL must contain the {ANALYZER_PLACEHOLDER} placeholder' |
| 4200 | + return True, None |
| 4201 | + |
| 4202 | + |
| 4203 | +@api_bp.route('/analyzers', methods=['GET']) |
| 4204 | +def list_analyzers_api(): |
| 4205 | + """List user-configured analyzers and the built-in Letsmesh URL template.""" |
| 4206 | + try: |
| 4207 | + db = _get_db() |
| 4208 | + if not db: |
| 4209 | + return jsonify({'success': False, 'error': 'Database not available'}), 500 |
| 4210 | + return jsonify({ |
| 4211 | + 'success': True, |
| 4212 | + 'analyzers': db.list_analyzers(), |
| 4213 | + 'letsmesh_url_template': LETSMESH_ANALYZER_URL_TEMPLATE, |
| 4214 | + }), 200 |
| 4215 | + except Exception as e: |
| 4216 | + logger.error(f"Error listing analyzers: {e}") |
| 4217 | + return jsonify({'success': False, 'error': str(e)}), 500 |
| 4218 | + |
| 4219 | + |
| 4220 | +@api_bp.route('/analyzers', methods=['POST']) |
| 4221 | +def create_analyzer_api(): |
| 4222 | + """Create a new analyzer. Body: {name, url_template}.""" |
| 4223 | + try: |
| 4224 | + data = request.get_json() or {} |
| 4225 | + name = (data.get('name') or '').strip() |
| 4226 | + url_template = (data.get('url_template') or '').strip() |
| 4227 | + |
| 4228 | + if not name: |
| 4229 | + return jsonify({'success': False, 'error': 'Name is required'}), 400 |
| 4230 | + ok, err = _validate_analyzer_url_template(url_template) |
| 4231 | + if not ok: |
| 4232 | + return jsonify({'success': False, 'error': err}), 400 |
| 4233 | + |
| 4234 | + db = _get_db() |
| 4235 | + if not db: |
| 4236 | + return jsonify({'success': False, 'error': 'Database not available'}), 500 |
| 4237 | + |
| 4238 | + import sqlite3 |
| 4239 | + try: |
| 4240 | + aid = db.create_analyzer(name, url_template) |
| 4241 | + except sqlite3.IntegrityError: |
| 4242 | + return jsonify({'success': False, 'error': f'Analyzer "{name}" already exists'}), 409 |
| 4243 | + |
| 4244 | + return jsonify({'success': True, 'analyzer': db.get_analyzer(aid)}), 201 |
| 4245 | + except Exception as e: |
| 4246 | + logger.error(f"Error creating analyzer: {e}") |
| 4247 | + return jsonify({'success': False, 'error': str(e)}), 500 |
| 4248 | + |
| 4249 | + |
| 4250 | +@api_bp.route('/analyzers/<int:analyzer_id>', methods=['PUT']) |
| 4251 | +def update_analyzer_api(analyzer_id): |
| 4252 | + """Update an analyzer. Body: {name?, url_template?, is_disabled?}.""" |
| 4253 | + try: |
| 4254 | + db = _get_db() |
| 4255 | + if not db: |
| 4256 | + return jsonify({'success': False, 'error': 'Database not available'}), 500 |
| 4257 | + |
| 4258 | + if db.get_analyzer(analyzer_id) is None: |
| 4259 | + return jsonify({'success': False, 'error': 'Analyzer not found'}), 404 |
| 4260 | + |
| 4261 | + data = request.get_json() or {} |
| 4262 | + name = data.get('name') |
| 4263 | + url_template = data.get('url_template') |
| 4264 | + is_disabled = data.get('is_disabled') |
| 4265 | + |
| 4266 | + if name is not None: |
| 4267 | + name = name.strip() |
| 4268 | + if not name: |
| 4269 | + return jsonify({'success': False, 'error': 'Name cannot be empty'}), 400 |
| 4270 | + if url_template is not None: |
| 4271 | + url_template = url_template.strip() |
| 4272 | + ok, err = _validate_analyzer_url_template(url_template) |
| 4273 | + if not ok: |
| 4274 | + return jsonify({'success': False, 'error': err}), 400 |
| 4275 | + |
| 4276 | + import sqlite3 |
| 4277 | + try: |
| 4278 | + db.update_analyzer(analyzer_id, name=name, url_template=url_template, |
| 4279 | + is_disabled=is_disabled) |
| 4280 | + except sqlite3.IntegrityError: |
| 4281 | + return jsonify({'success': False, 'error': f'Analyzer "{name}" already exists'}), 409 |
| 4282 | + |
| 4283 | + return jsonify({'success': True, 'analyzer': db.get_analyzer(analyzer_id)}), 200 |
| 4284 | + except Exception as e: |
| 4285 | + logger.error(f"Error updating analyzer: {e}") |
| 4286 | + return jsonify({'success': False, 'error': str(e)}), 500 |
| 4287 | + |
| 4288 | + |
| 4289 | +@api_bp.route('/analyzers/<int:analyzer_id>', methods=['DELETE']) |
| 4290 | +def delete_analyzer_api(analyzer_id): |
| 4291 | + """Delete an analyzer.""" |
| 4292 | + try: |
| 4293 | + db = _get_db() |
| 4294 | + if not db: |
| 4295 | + return jsonify({'success': False, 'error': 'Database not available'}), 500 |
| 4296 | + |
| 4297 | + if db.get_analyzer(analyzer_id) is None: |
| 4298 | + return jsonify({'success': False, 'error': 'Analyzer not found'}), 404 |
| 4299 | + |
| 4300 | + db.delete_analyzer(analyzer_id) |
| 4301 | + return jsonify({'success': True}), 200 |
| 4302 | + except Exception as e: |
| 4303 | + logger.error(f"Error deleting analyzer: {e}") |
| 4304 | + return jsonify({'success': False, 'error': str(e)}), 500 |
| 4305 | + |
| 4306 | + |
| 4307 | +@api_bp.route('/analyzers/default', methods=['DELETE']) |
| 4308 | +def clear_default_analyzer_api(): |
| 4309 | + """Clear the default-analyzer flag.""" |
| 4310 | + try: |
| 4311 | + db = _get_db() |
| 4312 | + if not db: |
| 4313 | + return jsonify({'success': False, 'error': 'Database not available'}), 500 |
| 4314 | + db.set_default_analyzer(None) |
| 4315 | + return jsonify({'success': True}), 200 |
| 4316 | + except Exception as e: |
| 4317 | + logger.error(f"Error clearing default analyzer: {e}") |
| 4318 | + return jsonify({'success': False, 'error': str(e)}), 500 |
| 4319 | + |
| 4320 | + |
| 4321 | +@api_bp.route('/analyzers/<int:analyzer_id>/default', methods=['POST']) |
| 4322 | +def set_default_analyzer_api(analyzer_id): |
| 4323 | + """Mark an analyzer as default. Clears any previous default in the same transaction.""" |
| 4324 | + try: |
| 4325 | + db = _get_db() |
| 4326 | + if not db: |
| 4327 | + return jsonify({'success': False, 'error': 'Database not available'}), 500 |
| 4328 | + |
| 4329 | + if db.get_analyzer(analyzer_id) is None: |
| 4330 | + return jsonify({'success': False, 'error': 'Analyzer not found'}), 404 |
| 4331 | + |
| 4332 | + db.set_default_analyzer(analyzer_id) |
| 4333 | + return jsonify({'success': True, 'analyzer': db.get_analyzer(analyzer_id)}), 200 |
| 4334 | + except Exception as e: |
| 4335 | + logger.error(f"Error setting default analyzer: {e}") |
| 4336 | + return jsonify({'success': False, 'error': str(e)}), 500 |
| 4337 | + |
| 4338 | + |
4189 | 4339 | # ============================================================================= |
4190 | 4340 | # Message Retention Settings |
4191 | 4341 | # ============================================================================= |
|
0 commit comments