diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 0f198c9..75edcda 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -107,6 +107,14 @@ def _init_database(self): conn.execute("CREATE INDEX IF NOT EXISTS idx_adverts_timestamp ON adverts(timestamp)") conn.execute("CREATE INDEX IF NOT EXISTS idx_adverts_pubkey ON adverts(pubkey)") conn.execute("CREATE INDEX IF NOT EXISTS idx_noise_timestamp ON noise_floor(timestamp)") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS crc_errors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp REAL NOT NULL + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_crc_errors_timestamp ON crc_errors(timestamp)") conn.execute("CREATE INDEX IF NOT EXISTS idx_transport_keys_name ON transport_keys(name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_transport_keys_parent ON transport_keys(parent_id)") @@ -478,6 +486,47 @@ def store_noise_floor(self, record: dict): except Exception as e: logger.error(f"Failed to store noise floor in SQLite: {e}") + def store_crc_error(self, timestamp: float): + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.execute( + "INSERT INTO crc_errors (timestamp) VALUES (?)", + (timestamp,) + ) + except Exception as e: + logger.error(f"Failed to store CRC error in SQLite: {e}") + + def get_crc_error_count(self, hours: int = 24) -> dict: + try: + cutoff = time.time() - (hours * 3600) + + with sqlite3.connect(self.sqlite_path) as conn: + count = conn.execute( + "SELECT COUNT(*) FROM crc_errors WHERE timestamp > ?", + (cutoff,) + ).fetchone()[0] + + oldest = conn.execute( + "SELECT MIN(timestamp) FROM crc_errors WHERE timestamp > ?", + (cutoff,) + ).fetchone()[0] + + newest = conn.execute( + "SELECT MAX(timestamp) FROM crc_errors WHERE timestamp > ?", + (cutoff,) + ).fetchone()[0] + + return { + "crc_error_count": count, + "hours": hours, + "oldest_event": oldest, + "newest_event": newest + } + + except Exception as e: + logger.error(f"Failed to get CRC error count: {e}") + return {"crc_error_count": 0, "hours": hours, "oldest_event": None, "newest_event": None} + def get_packet_stats(self, hours: int = 24) -> dict: try: cutoff = time.time() - (hours * 3600) @@ -841,10 +890,13 @@ def cleanup_old_data(self, days: int = 7): result = conn.execute("DELETE FROM noise_floor WHERE timestamp < ?", (cutoff,)) noise_deleted = result.rowcount + result = conn.execute("DELETE FROM crc_errors WHERE timestamp < ?", (cutoff,)) + crc_deleted = result.rowcount + conn.commit() - if packets_deleted > 0 or adverts_deleted > 0 or noise_deleted > 0: - logger.info(f"Cleaned up {packets_deleted} old packets, {adverts_deleted} old adverts, {noise_deleted} old noise measurements") + if packets_deleted > 0 or adverts_deleted > 0 or noise_deleted > 0 or crc_deleted > 0: + logger.info(f"Cleaned up {packets_deleted} old packets, {adverts_deleted} old adverts, {noise_deleted} old noise measurements, {crc_deleted} old CRC errors") except Exception as e: logger.error(f"Failed to cleanup old data: {e}") diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py index 1019415..89c4093 100644 --- a/repeater/data_acquisition/storage_collector.py +++ b/repeater/data_acquisition/storage_collector.py @@ -203,6 +203,12 @@ def record_noise_floor(self, noise_floor_dbm: float): self.sqlite_handler.store_noise_floor(noise_record) self.mqtt_handler.publish(noise_record, "noise_floor") + def record_crc_error(self, timestamp: float = None): + self.sqlite_handler.store_crc_error(timestamp or time.time()) + + def get_crc_error_count(self, hours: int = 24) -> dict: + return self.sqlite_handler.get_crc_error_count(hours) + def get_packet_stats(self, hours: int = 24) -> dict: return self.sqlite_handler.get_packet_stats(hours) diff --git a/repeater/main.py b/repeater/main.py index dd9c785..35b3ddc 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -6,7 +6,7 @@ from repeater.config import get_radio_for_board, load_config from repeater.config_manager import ConfigManager from repeater.engine import RepeaterHandler -from repeater.web.http_server import HTTPStatsServer, _log_buffer +from repeater.web.http_server import HTTPStatsServer, _log_buffer, _crc_tracker from repeater.handler_helpers import TraceHelper, DiscoveryHelper, AdvertHelper, LoginHelper, TextHelper, PathHelper, ProtocolRequestHelper from repeater.packet_router import PacketRouter from repeater.identity_manager import IdentityManager @@ -47,6 +47,8 @@ def __init__(self, config: dict, radio=None): root_logger = logging.getLogger() _log_buffer.setLevel(getattr(logging, log_level)) root_logger.addHandler(_log_buffer) + _crc_tracker.setLevel(logging.WARNING) + root_logger.addHandler(_crc_tracker) async def initialize(self): diff --git a/repeater/web/__init__.py b/repeater/web/__init__.py index 77ae3f9..f5ca7e4 100644 --- a/repeater/web/__init__.py +++ b/repeater/web/__init__.py @@ -1,4 +1,4 @@ -from .http_server import HTTPStatsServer, StatsApp, LogBuffer, _log_buffer +from .http_server import HTTPStatsServer, StatsApp, LogBuffer, _log_buffer, CRCErrorTracker, _crc_tracker from .api_endpoints import APIEndpoints from .cad_calibration_engine import CADCalibrationEngine @@ -6,7 +6,9 @@ 'HTTPStatsServer', 'StatsApp', 'LogBuffer', + 'CRCErrorTracker', 'APIEndpoints', 'CADCalibrationEngine', - '_log_buffer' -] \ No newline at end of file + '_log_buffer', + '_crc_tracker' +] diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index e3c3155..718abfa 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -62,6 +62,9 @@ # GET /api/noise_floor_stats?hours=24 - Get noise floor statistics # GET /api/noise_floor_chart_data?hours=24 - Get noise floor chart data +# Radio Health +# GET /api/crc_count?hours=24 - Get CRC error count for time period + # CAD Calibration # POST /api/cad_calibration_start {"samples": 8, "delay": 100} - Start CAD calibration # POST /api/cad_calibration_stop - Stop CAD calibration @@ -1387,6 +1390,19 @@ def noise_floor_chart_data(self, hours: int = 24): logger.error(f"Error fetching noise floor chart data: {e}") return self._error(e) + @cherrypy.expose + @cherrypy.tools.json_out() + def crc_count(self, hours: int = 24): + """Get CRC error count for the specified time period.""" + try: + storage = self._get_storage() + hours = int(hours) + stats = storage.get_crc_error_count(hours=hours) + return self._success(stats) + except Exception as e: + logger.error(f"Error fetching CRC error count: {e}") + return self._error(e) + @cherrypy.expose def cad_calibration_stream(self): cherrypy.response.headers['Content-Type'] = 'text/event-stream' diff --git a/repeater/web/http_server.py b/repeater/web/http_server.py index 2740e7f..a0820e6 100644 --- a/repeater/web/http_server.py +++ b/repeater/web/http_server.py @@ -59,6 +59,30 @@ def emit(self, record): _log_buffer = LogBuffer(max_lines=100) +class CRCErrorTracker(logging.Handler): + """Logging handler that detects CRC errors from SX1262_wrapper and persists them to SQLite.""" + + def __init__(self): + super().__init__() + self._storage = None + + def set_storage(self, storage_collector): + """Attach storage collector for persistent writes. Called during server init.""" + self._storage = storage_collector + + def emit(self, record): + try: + if record.name == "SX1262_wrapper" and "CRC error detected" in record.getMessage(): + if self._storage: + self._storage.record_crc_error(record.created) + except Exception: + self.handleError(record) + + +# Global CRC error tracker instance +_crc_tracker = CRCErrorTracker() + + class DocEndpoint: """Simple wrapper to serve API docs at /doc""" @@ -191,6 +215,14 @@ def __init__( stats_getter, node_name, pub_key, send_advert_func, config, event_loop, daemon_instance, config_path ) + # Connect CRC error tracker to storage for persistent writes + try: + if daemon_instance and hasattr(daemon_instance, 'repeater_handler') and daemon_instance.repeater_handler: + _crc_tracker.set_storage(daemon_instance.repeater_handler.storage) + logger.info("CRC error tracker connected to storage") + except Exception as e: + logger.warning(f"Could not connect CRC error tracker to storage: {e}") + # Create auth endpoints (APIEndpoints has the config_manager) self.auth_app = AuthEndpoints(self.config, self.jwt_handler, self.token_manager, self.app.api.config_manager)