diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 843a8ee..640b649 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -2181,15 +2181,35 @@ class APIEndpoints: return self._error("No configuration updates provided") # Use ConfigManager to update and save configuration - # Web changes (CORS, web_path) don't require live update + # Persist web changes first, then apply to running HTTP server. result = self.config_manager.update_and_save(updates=updates, live_update=False) if result.get("success"): + live_applied = False + frontend_switched = False + app = ( + getattr(getattr(self.daemon_instance, "http_server", None), "app", None) + if self.daemon_instance + else None + ) + if app and hasattr(app, "apply_web_config"): + try: + frontend_switched = bool(app.apply_web_config()) + live_applied = True + except Exception as exc: + logger.warning("Failed to apply web config live: %s", exc) logger.info(f"Web configuration updated: {list(updates.keys())}") return self._success( { "persisted": result.get("saved", False), - "message": "Web configuration saved successfully. Restart required for changes to take effect.", + "live_applied": live_applied, + "frontend_switched": frontend_switched, + "restart_required": not live_applied, + "message": ( + "Web configuration applied immediately." + if live_applied + else "Web configuration saved. Restart required for changes to take effect." + ), } ) else: @@ -3569,7 +3589,21 @@ class APIEndpoints: samples = data.get("samples", 8) delay = data.get("delay", 100) known_signal_present = data.get("known_signal_present", False) - cad_symbol_num = data.get("cad_symbol_num", 2) + radio = getattr(self.daemon_instance, "radio", None) if self.daemon_instance else None + default_cad_symbol_num = ( + self.config.get("radio", {}).get("cad", {}).get("symbol_num", 2) + ) + radio_symbol_num = getattr(radio, "_custom_cad_symbol_num", None) if radio else None + if radio_symbol_num in {1, 2, 4, 8, 16}: + default_cad_symbol_num = radio_symbol_num + try: + default_cad_symbol_num = int(default_cad_symbol_num) + except (TypeError, ValueError): + default_cad_symbol_num = 2 + if default_cad_symbol_num not in {1, 2, 4, 8, 16}: + default_cad_symbol_num = 2 + + cad_symbol_num = data.get("cad_symbol_num", default_cad_symbol_num) cad_timeout_ms = data.get("cad_timeout_ms", 500) self.cad_calibration.session_config = { "known_signal_present": known_signal_present, @@ -3618,6 +3652,15 @@ class APIEndpoints: return self._error("Radio CAD support is not available") if self.event_loop is None: return self._error("Event loop not available") + default_cad_symbol_num = getattr( + radio, "_custom_cad_symbol_num", None + ) or self.config.get("radio", {}).get("cad", {}).get("symbol_num", 2) + try: + default_cad_symbol_num = int(default_cad_symbol_num) + except (TypeError, ValueError): + default_cad_symbol_num = 2 + if default_cad_symbol_num not in {1, 2, 4, 8, 16}: + default_cad_symbol_num = 2 samples = self.cad_calibration._normalize_int( data.get("samples", 1), default=1, minimum=1, maximum=32 @@ -3635,10 +3678,13 @@ class APIEndpoints: maximum=255, ) cad_symbol_num = self.cad_calibration._normalize_int( - data.get("cad_symbol_num", 2), default=2, minimum=1, maximum=16 + data.get("cad_symbol_num", default_cad_symbol_num), + default=default_cad_symbol_num, + minimum=1, + maximum=16, ) if cad_symbol_num not in {1, 2, 4, 8, 16}: - cad_symbol_num = 2 + cad_symbol_num = default_cad_symbol_num cad_timeout_ms = self.cad_calibration._normalize_int( data.get("cad_timeout_ms", 500), default=500, minimum=50, maximum=5000 ) @@ -3730,6 +3776,7 @@ class APIEndpoints: data = cherrypy.request.json or {} peak = data.get("peak") min_val = data.get("min_val") + cad_symbol_num = data.get("cad_symbol_num") detection_rate = data.get("detection_rate", 0) if peak is None or min_val is None: @@ -3741,8 +3788,17 @@ class APIEndpoints: except (TypeError, ValueError): return self._error("peak and min_val must be integers") + if cad_symbol_num is None: + cad_symbol_num = self.config.get("radio", {}).get("cad", {}).get("symbol_num", 2) + try: + cad_symbol_num = int(cad_symbol_num) + except (TypeError, ValueError): + return self._error("cad_symbol_num must be an integer") + if not (0 <= peak <= 255) or not (0 <= min_val <= 255): return self._error("CAD thresholds must be between 0 and 255") + if cad_symbol_num not in {1, 2, 4, 8, 16}: + return self._error("cad_symbol_num must be one of: 1, 2, 4, 8, 16") if ( self.daemon_instance @@ -3751,7 +3807,14 @@ class APIEndpoints: ): if hasattr(self.daemon_instance.radio, "set_custom_cad_thresholds"): self.daemon_instance.radio.set_custom_cad_thresholds(peak=peak, min_val=min_val) - logger.info(f"Applied CAD settings to radio: peak={peak}, min={min_val}") + if hasattr(self.daemon_instance.radio, "set_custom_cad_symbol_num"): + self.daemon_instance.radio.set_custom_cad_symbol_num(cad_symbol_num) + logger.info( + "Applied CAD settings to radio: peak=%s, min=%s, symbols=%s", + peak, + min_val, + cad_symbol_num, + ) if "radio" not in self.config: self.config["radio"] = {} @@ -3760,18 +3823,30 @@ class APIEndpoints: self.config["radio"]["cad"]["peak_threshold"] = peak self.config["radio"]["cad"]["min_threshold"] = min_val + self.config["radio"]["cad"]["symbol_num"] = cad_symbol_num saved = self.config_manager.save_to_file() if not saved: return self._error("Failed to save configuration to file") logger.info( - f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%" + "Saved CAD settings to config: peak=%s, min=%s, symbols=%s, rate=%.1f%%", + peak, + min_val, + cad_symbol_num, + detection_rate, ) return { "success": True, - "message": f"CAD settings saved: peak={peak}, min={min_val}", - "settings": {"peak": peak, "min_val": min_val, "detection_rate": detection_rate}, + "message": ( + f"CAD settings saved: peak={peak}, min={min_val}, symbols={cad_symbol_num}" + ), + "settings": { + "peak": peak, + "min_val": min_val, + "cad_symbol_num": cad_symbol_num, + "detection_rate": detection_rate, + }, } except cherrypy.HTTPError: # Re-raise HTTP errors (like 405 Method Not Allowed) without logging diff --git a/repeater/web/cad_calibration_engine.py b/repeater/web/cad_calibration_engine.py index 37d95ea..f958ebe 100644 --- a/repeater/web/cad_calibration_engine.py +++ b/repeater/web/cad_calibration_engine.py @@ -124,15 +124,36 @@ class CADCalibrationEngine: ) -> list[dict]: semtech_peak, semtech_min = self._default_thresholds_for_sf(sf) if known_signal_present: + required_detection_rate = 85.0 + + def _known_signal_sort_key(r: dict): + detection_rate = float(r.get("detection_rate", 0.0) or 0.0) + instability = int(r.get("timeouts", 0) or 0) + int(r.get("errors", 0) or 0) + peak = int(r.get("det_peak", semtech_peak)) + min_val = int(r.get("det_min", semtech_min)) + attempts = int(r.get("attempts", 0) or r.get("samples", 0) or 0) + aggressiveness_penalty = max(0, semtech_peak - peak) + ( + 2 * max(0, semtech_min - min_val) + ) + is_stable = instability == 0 + meets_detection_floor = detection_rate >= required_detection_rate + qualification_tier = ( + 2 if (is_stable and meets_detection_floor) else (1 if is_stable else 0) + ) + return ( + qualification_tier, + -aggressiveness_penalty, + min_val, + peak, + detection_rate, + -instability, + attempts, + int(r.get("detections", 0) or 0), + ) + return sorted( results, - key=lambda r: ( - r.get("detection_rate", 0.0), - -(r.get("timeouts", 0) + r.get("errors", 0)), - r.get("detections", 0), - -abs(r.get("det_peak", semtech_peak) - semtech_peak), - -abs(r.get("det_min", semtech_min) - semtech_min), - ), + key=_known_signal_sort_key, reverse=True, ) @@ -157,6 +178,33 @@ class CADCalibrationEngine: + abs(float(result.get("detection_rate", 0.0))) ) + @staticmethod + def _merge_cad_result_samples(base: dict, extra: dict) -> dict: + merged = dict(base) + attempts = int(base.get("attempts", 0) or 0) + int(extra.get("attempts", 0) or 0) + detections = int(base.get("detections", 0) or 0) + int(extra.get("detections", 0) or 0) + non_detections = int(base.get("non_detections", 0) or 0) + int( + extra.get("non_detections", 0) or 0 + ) + timeouts = int(base.get("timeouts", 0) or 0) + int(extra.get("timeouts", 0) or 0) + errors = int(base.get("errors", 0) or 0) + int(extra.get("errors", 0) or 0) + cad_done_count = int(base.get("cad_done_count", 0) or 0) + int( + extra.get("cad_done_count", 0) or 0 + ) + merged.update( + { + "samples": attempts, + "attempts": attempts, + "detections": detections, + "non_detections": non_detections, + "timeouts": timeouts, + "errors": errors, + "cad_done_count": cad_done_count, + "detection_rate": (detections / attempts) * 100 if attempts > 0 else 0.0, + } + ) + return merged + def _build_zoom_candidates( self, centers: list[dict], @@ -254,19 +302,53 @@ class CADCalibrationEngine: semtech_peak, semtech_min = self._default_thresholds_for_sf(sf) if known_signal_present: + required_detection_rate = 95.0 + + def _known_signal_sort_key(r: dict): + detection_rate = float(r.get("detection_rate", 0.0) or 0.0) + instability = int(r.get("timeouts", 0) or 0) + int(r.get("errors", 0) or 0) + peak = int(r.get("det_peak", semtech_peak)) + min_val = int(r.get("det_min", semtech_min)) + attempts = int(r.get("attempts", 0) or r.get("samples", 0) or 0) + aggressiveness_penalty = max(0, semtech_peak - peak) + ( + 2 * max(0, semtech_min - min_val) + ) + is_stable = instability == 0 + meets_detection_floor = detection_rate >= required_detection_rate + qualification_tier = ( + 2 if (is_stable and meets_detection_floor) else (1 if is_stable else 0) + ) + return ( + qualification_tier, + -aggressiveness_penalty, + min_val, + peak, + detection_rate, + -instability, + attempts, + int(r.get("detections", 0) or 0), + ) + ranked = sorted( results, - key=lambda r: ( - r.get("detection_rate", 0.0), - -(r.get("timeouts", 0) + r.get("errors", 0)), - -abs(r.get("det_peak", semtech_peak) - semtech_peak), - -abs(r.get("det_min", semtech_min) - semtech_min), - ), + key=_known_signal_sort_key, reverse=True, ) + met_required = any( + (float(r.get("detection_rate", 0.0) or 0.0) >= required_detection_rate) + and (int(r.get("timeouts", 0) or 0) + int(r.get("errors", 0) or 0) == 0) + for r in results + ) return ( ranked[0], - "Recommended using known-signal measurements (maximize CAD_DETECTED while minimizing timeouts/errors).", + ( + "Recommended using known-signal qualification-first selection " + f"(require ≥{required_detection_rate:.0f}% detection with zero timeouts/errors, " + "then choose the least-sensitive stable setting that meets it)." + if met_required + else "No candidate met the strict known-signal qualification floor; " + "selected the most stable least-sensitive fallback from available results." + ), ) ranked = sorted( @@ -322,99 +404,33 @@ class CADCalibrationEngine: cad_symbol_num = int(self.session_config.get("cad_symbol_num", 2)) cad_timeout_seconds = float(self.session_config.get("cad_timeout_seconds", 0.5)) - # Coarse-to-fine search settings (bounded budget, no broad exhaustive sweeps) - coarse_peak_lower = max(1, int(base_peak) - 12) - coarse_peak_upper = min(255, int(base_peak) + 12) - coarse_min_lower = max(1, int(base_min) - 5) - coarse_min_upper = min(255, int(base_min) + 5) - max_total_tests = 84 current = 0 - estimated_total = 0 - self.progress = {"current": 0, "total": estimated_total} + self.progress = {"current": 0, "total": 0} - # Run calibration in event loop with staged coarse-to-fine search if self.event_loop: - stage_definitions: list[dict[str, Any]] = [ - { - "stage_key": "coarse", - "label": "coarse scan", - "builder": lambda: [ - (peak, min_val) - for peak in self._build_stepped_range( - coarse_peak_lower, coarse_peak_upper, 4, anchor=int(base_peak) - ) - for min_val in self._build_stepped_range( - coarse_min_lower, coarse_min_upper, 2, anchor=int(base_min) - ) - ], - }, - { - "stage_key": "zoom1", - "label": "zoom refinement 1", - "builder": lambda: self._build_zoom_candidates( - self._rank_results_for_search( - list(self.results.values()), known_signal_present, sf - )[:3], - peak_radius=4, - min_radius=2, - ), - }, - { - "stage_key": "zoom2", - "label": "zoom refinement 2", - "builder": lambda: self._build_zoom_candidates( - self._rank_results_for_search( - list(self.results.values()), known_signal_present, sf - )[:2], - peak_radius=2, - min_radius=1, - ), - }, - { - "stage_key": "fine", - "label": "fine polish", - "builder": lambda: self._build_zoom_candidates( - self._rank_results_for_search( - list(self.results.values()), known_signal_present, sf - )[:1], - peak_radius=1, - min_radius=1, - ), - }, - ] - best_score_before_stage: Optional[float] = None - for stage_index, stage in enumerate(stage_definitions, start=1): - if not self.running or current >= max_total_tests: - break - - raw_candidates: list[tuple[int, int]] = stage["builder"]() - candidates = [ - candidate - for candidate in raw_candidates - if f"{candidate[0]}-{candidate[1]}" not in self.results + if known_signal_present: + semtech_peak, semtech_min = self._default_thresholds_for_sf(sf) + required_detection_rate = 85.0 + evaluation_samples = max(30, min(60, samples * 3)) + max_escalation_steps = min(12, max(0, semtech_peak - 1)) + candidate_pairs = [ + (max(1, semtech_peak - step), semtech_min) + for step in range(max_escalation_steps + 1) ] - remaining_budget = max_total_tests - current - candidates = candidates[:remaining_budget] - if not candidates: - continue + self.progress["total"] = len(candidate_pairs) - estimated_total = max(self.progress.get("total", 0), current + len(candidates)) - self.progress["total"] = estimated_total - - peak_values = [candidate[0] for candidate in candidates] - min_values = [candidate[1] for candidate in candidates] self.broadcast_to_clients( { "type": "status", "message": ( - f"Calibration stage {stage_index}/{len(stage_definitions)} " - f"({stage['label']}): testing {len(candidates)} combinations" + "Calibration stage 1/2 (default baseline): testing Semtech default " + "thresholds first; escalate only if required." ), "test_ranges": { - "peak_min": min(peak_values), - "peak_max": max(peak_values), - "min_min": min(min_values), - "min_max": max(min_values), + "peak_min": candidate_pairs[-1][0], + "peak_max": candidate_pairs[0][0], + "min_min": semtech_min, + "min_max": semtech_min, "spreading_factor": sf, "bandwidth": runtime_cfg["bandwidth"], "frequency": runtime_cfg["frequency"], @@ -422,32 +438,32 @@ class CADCalibrationEngine: "current_min": base_min, "cad_symbol_num": cad_symbol_num, "known_signal_present": known_signal_present, - "total_tests": estimated_total, - "pass_index": stage_index, - "max_passes": len(stage_definitions), - "stage": stage["stage_key"], + "total_tests": len(candidate_pairs), + "pass_index": 1, + "max_passes": 2, + "stage": "default-anchor", }, } ) - for det_peak, det_min in candidates: + qualified_candidate: Optional[dict] = None + for index, (det_peak, det_min) in enumerate(candidate_pairs, start=1): if not self.running: break - current += 1 + current = index self.progress["current"] = current - self.broadcast_to_clients( { "type": "progress", "current": current, - "total": estimated_total, + "total": len(candidate_pairs), "det_peak": det_peak, "det_min": det_min, "known_signal_present": known_signal_present, - "pass_index": stage_index, - "max_passes": len(stage_definitions), - "stage": stage["stage_key"], + "pass_index": 1, + "max_passes": 2, + "stage": "default-anchor", } ) @@ -456,7 +472,7 @@ class CADCalibrationEngine: radio, det_peak, det_min, - samples=samples, + samples=evaluation_samples, cad_symbol_num=cad_symbol_num, cad_timeout_seconds=cad_timeout_seconds, ), @@ -464,48 +480,304 @@ class CADCalibrationEngine: ) try: - result = future.result(timeout=30) + result = future.result(timeout=45) self.results[f"{det_peak}-{det_min}"] = result self.broadcast_to_clients( { "type": "result", - "pass_index": stage_index, - "stage": stage["stage_key"], + "pass_index": 1, + "stage": "default-anchor", **result, } ) + + instability = int(result.get("timeouts", 0) or 0) + int( + result.get("errors", 0) or 0 + ) + detection_rate = float(result.get("detection_rate", 0.0) or 0.0) + if instability == 0 and detection_rate >= required_detection_rate: + qualified_candidate = result + self.broadcast_to_clients( + { + "type": "status", + "message": ( + f"Qualification met at P{det_peak}/M{det_min} " + f"(rate {detection_rate:.1f}%, stable). " + "Stopping escalation at first qualifying candidate." + ), + } + ) + break except Exception as e: logger.error(f"CAD test failed for peak={det_peak}, min={det_min}: {e}") if self.running and delay_ms > 0: time.sleep(delay_ms / 1000.0) - if not self.running or not self.results: - break - - ranked_results = self._rank_results_for_search( - list(self.results.values()), known_signal_present, sf - ) - best_score_after_stage = self._search_objective_value( - ranked_results[0], known_signal_present - ) - min_improvement = 2.0 if known_signal_present else 1.0 - if ( - stage_index >= 2 - and best_score_before_stage is not None - and (best_score_after_stage - best_score_before_stage) < min_improvement - ): + if self.running and self.results and qualified_candidate is None: self.broadcast_to_clients( { "type": "status", "message": ( - f"Calibration converged after {stage['label']} " - f"(improvement < 2%)." + "No candidate met strict qualification floor " + f"(≥{required_detection_rate:.0f}% with zero timeouts/errors). " + "Using least-sensitive stable fallback from tested candidates." ), } ) - break - best_score_before_stage = best_score_after_stage + else: + # Quiet-mode keeps the previous coarse-to-fine search behaviour. + coarse_peak_lower = max(1, int(base_peak) - 12) + coarse_peak_upper = min(255, int(base_peak) + 12) + coarse_min_lower = max(1, int(base_min) - 5) + coarse_min_upper = min(255, int(base_min) + 5) + max_total_tests = 84 + estimated_total = 0 + self.progress = {"current": 0, "total": estimated_total} + stage_definitions: list[dict[str, Any]] = [ + { + "stage_key": "coarse", + "label": "coarse scan", + "builder": lambda: [ + (peak, min_val) + for peak in self._build_stepped_range( + coarse_peak_lower, coarse_peak_upper, 4, anchor=int(base_peak) + ) + for min_val in self._build_stepped_range( + coarse_min_lower, coarse_min_upper, 2, anchor=int(base_min) + ) + ], + }, + { + "stage_key": "zoom1", + "label": "zoom refinement 1", + "builder": lambda: self._build_zoom_candidates( + self._rank_results_for_search( + list(self.results.values()), known_signal_present, sf + )[:3], + peak_radius=4, + min_radius=2, + ), + }, + { + "stage_key": "zoom2", + "label": "zoom refinement 2", + "builder": lambda: self._build_zoom_candidates( + self._rank_results_for_search( + list(self.results.values()), known_signal_present, sf + )[:2], + peak_radius=2, + min_radius=1, + ), + }, + { + "stage_key": "fine", + "label": "fine polish", + "builder": lambda: self._build_zoom_candidates( + self._rank_results_for_search( + list(self.results.values()), known_signal_present, sf + )[:1], + peak_radius=1, + min_radius=1, + ), + }, + ] + best_score_before_stage: Optional[float] = None + for stage_index, stage in enumerate(stage_definitions, start=1): + if not self.running or current >= max_total_tests: + break + + raw_candidates: list[tuple[int, int]] = stage["builder"]() + candidates = [ + candidate + for candidate in raw_candidates + if f"{candidate[0]}-{candidate[1]}" not in self.results + ] + remaining_budget = max_total_tests - current + candidates = candidates[:remaining_budget] + if not candidates: + continue + + estimated_total = max( + self.progress.get("total", 0), current + len(candidates) + ) + self.progress["total"] = estimated_total + + peak_values = [candidate[0] for candidate in candidates] + min_values = [candidate[1] for candidate in candidates] + self.broadcast_to_clients( + { + "type": "status", + "message": ( + f"Calibration stage {stage_index}/{len(stage_definitions)} " + f"({stage['label']}): testing {len(candidates)} combinations" + ), + "test_ranges": { + "peak_min": min(peak_values), + "peak_max": max(peak_values), + "min_min": min(min_values), + "min_max": max(min_values), + "spreading_factor": sf, + "bandwidth": runtime_cfg["bandwidth"], + "frequency": runtime_cfg["frequency"], + "current_peak": base_peak, + "current_min": base_min, + "cad_symbol_num": cad_symbol_num, + "known_signal_present": known_signal_present, + "total_tests": estimated_total, + "pass_index": stage_index, + "max_passes": len(stage_definitions), + "stage": stage["stage_key"], + }, + } + ) + + for det_peak, det_min in candidates: + if not self.running: + break + + current += 1 + self.progress["current"] = current + + self.broadcast_to_clients( + { + "type": "progress", + "current": current, + "total": estimated_total, + "det_peak": det_peak, + "det_min": det_min, + "known_signal_present": known_signal_present, + "pass_index": stage_index, + "max_passes": len(stage_definitions), + "stage": stage["stage_key"], + } + ) + + future = asyncio.run_coroutine_threadsafe( + self.test_cad_config( + radio, + det_peak, + det_min, + samples=samples, + cad_symbol_num=cad_symbol_num, + cad_timeout_seconds=cad_timeout_seconds, + ), + self.event_loop, + ) + + try: + result = future.result(timeout=30) + self.results[f"{det_peak}-{det_min}"] = result + self.broadcast_to_clients( + { + "type": "result", + "pass_index": stage_index, + "stage": stage["stage_key"], + **result, + } + ) + except Exception as e: + logger.error( + f"CAD test failed for peak={det_peak}, min={det_min}: {e}" + ) + + if self.running and delay_ms > 0: + time.sleep(delay_ms / 1000.0) + + if not self.running or not self.results: + break + + ranked_results = self._rank_results_for_search( + list(self.results.values()), known_signal_present, sf + ) + best_score_after_stage = self._search_objective_value( + ranked_results[0], known_signal_present + ) + min_improvement = 1.0 + if ( + stage_index >= 2 + and best_score_before_stage is not None + and (best_score_after_stage - best_score_before_stage) < min_improvement + ): + self.broadcast_to_clients( + { + "type": "status", + "message": ( + f"Calibration converged after {stage['label']} " + f"(improvement < {min_improvement:.0f}%)." + ), + } + ) + break + best_score_before_stage = best_score_after_stage + + # Adaptive confidence pass remains for quiet-mode only. + if self.running and self.results: + ranked_for_verify = self._rank_results_for_search( + list(self.results.values()), known_signal_present, sf + ) + finalist_count = min(3, len(ranked_for_verify)) + target_samples = max(30, min(60, samples * 3)) + extra_samples = max(0, target_samples - samples) + if finalist_count > 0 and extra_samples > 0: + self.broadcast_to_clients( + { + "type": "status", + "message": ( + f"Verification pass: re-testing top {finalist_count} " + f"candidates with +{extra_samples} samples each." + ), + } + ) + for index, candidate in enumerate( + ranked_for_verify[:finalist_count], start=1 + ): + if not self.running: + break + det_peak = int(candidate.get("det_peak", 22)) + det_min = int(candidate.get("det_min", 10)) + self.broadcast_to_clients( + { + "type": "status", + "message": ( + f"Verification {index}/{finalist_count}: " + f"P{det_peak}/M{det_min}" + ), + } + ) + future = asyncio.run_coroutine_threadsafe( + self.test_cad_config( + radio, + det_peak, + det_min, + samples=extra_samples, + cad_symbol_num=cad_symbol_num, + cad_timeout_seconds=cad_timeout_seconds, + ), + self.event_loop, + ) + try: + verification_result = future.result(timeout=30) + result_key = f"{det_peak}-{det_min}" + base_result = self.results.get(result_key, candidate) + merged_result = self._merge_cad_result_samples( + base_result, verification_result + ) + self.results[result_key] = merged_result + self.broadcast_to_clients( + { + "type": "result", + "stage": "verification", + **merged_result, + } + ) + except Exception as e: + logger.error( + "CAD finalist verification failed for peak=%s, min=%s: %s", + det_peak, + det_min, + e, + ) if self.running: best_result = None @@ -513,6 +785,9 @@ class CADCalibrationEngine: recommendation_reason = "No recommendation generated." signal_activity_observed = False known_signal_effective = known_signal_present + quiet_mode_invalid = False + quiet_mode_invalid_reason = "" + aggregate_detection_rate = 0.0 qualification = ( "Known compatible LoRa signal present during calibration." if known_signal_present @@ -527,11 +802,25 @@ class CADCalibrationEngine: total_attempts = sum(int(r.get("attempts", 0) or 0) for r in all_results) total_detections = sum(int(r.get("detections", 0) or 0) for r in all_results) best_rate = float(best_result.get("detection_rate", 0.0) or 0.0) + aggregate_detection_rate = ( + (float(total_detections) / float(total_attempts)) * 100.0 + if total_attempts > 0 + else 0.0 + ) min_detection_floor = max(5, int(total_attempts * 0.03)) signal_activity_observed = ( total_detections >= min_detection_floor and best_rate >= 15.0 ) known_signal_effective = known_signal_present or signal_activity_observed + if not known_signal_present and ( + signal_activity_observed or aggregate_detection_rate > 10.0 + ): + quiet_mode_invalid = True + quiet_mode_invalid_reason = ( + "Quiet-mode run observed significant channel activity " + f"(aggregate CAD detection {aggregate_detection_rate:.1f}%). " + "Re-run quiet baseline during a truly idle channel." + ) if not known_signal_present and signal_activity_observed: qualification = ( @@ -551,6 +840,9 @@ class CADCalibrationEngine: "known_signal_present": known_signal_present, "signal_activity_observed": signal_activity_observed, "known_signal_effective": known_signal_effective, + "quiet_mode_invalid": quiet_mode_invalid, + "quiet_mode_invalid_reason": quiet_mode_invalid_reason, + "aggregate_detection_rate": aggregate_detection_rate, "qualification": qualification, "total_tests": len(self.results), } diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index aeefe12..19a0919 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -1446,6 +1446,11 @@ paths: maximum: 255 description: CAD minimum value example: 64 + cad_symbol_num: + type: integer + enum: [1, 2, 4, 8, 16] + default: 2 + description: CAD symbol count to persist for runtime CAD detections. responses: '200': description: Settings saved