diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 0f198c9..de17e01 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -76,6 +76,14 @@ class SQLiteHandler: ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS crc_errors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp REAL NOT NULL, + count INTEGER NOT NULL DEFAULT 1 + ) + """) + conn.execute(""" CREATE TABLE IF NOT EXISTS transport_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -107,6 +115,7 @@ class SQLiteHandler: 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 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 +487,54 @@ class SQLiteHandler: except Exception as e: logger.error(f"Failed to store noise floor in SQLite: {e}") + def store_crc_errors(self, record: dict): + """Store a CRC error batch (delta count since last poll).""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.execute(""" + INSERT INTO crc_errors (timestamp, count) + VALUES (?, ?) + """, ( + record.get("timestamp", time.time()), + record.get("count", 1) + )) + except Exception as e: + logger.error(f"Failed to store CRC errors in SQLite: {e}") + + def get_crc_error_count(self, hours: int = 24) -> int: + """Return total CRC errors within the given time window.""" + try: + cutoff = time.time() - (hours * 3600) + with sqlite3.connect(self.sqlite_path) as conn: + row = conn.execute( + "SELECT COALESCE(SUM(count), 0) FROM crc_errors WHERE timestamp > ?", + (cutoff,) + ).fetchone() + return row[0] if row else 0 + except Exception as e: + logger.error(f"Failed to get CRC error count: {e}") + return 0 + + def get_crc_error_history(self, hours: int = 24, limit: int = None) -> list: + """Return CRC error records within the given time window (chronological).""" + try: + cutoff = time.time() - (hours * 3600) + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + query = """ + SELECT timestamp, count + FROM crc_errors + WHERE timestamp > ? + ORDER BY timestamp DESC + """ + if limit: + query += f" LIMIT {int(limit)}" + rows = conn.execute(query, (cutoff,)).fetchall() + return [{"timestamp": r["timestamp"], "count": r["count"]} for r in reversed(rows)] + except Exception as e: + logger.error(f"Failed to get CRC error history: {e}") + return [] + def get_packet_stats(self, hours: int = 24) -> dict: try: cutoff = time.time() - (hours * 3600) @@ -841,10 +898,13 @@ class SQLiteHandler: 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 error records") 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 e065647..ce02c1e 100644 --- a/repeater/data_acquisition/storage_collector.py +++ b/repeater/data_acquisition/storage_collector.py @@ -209,6 +209,18 @@ class StorageCollector: self.sqlite_handler.store_noise_floor(noise_record) self.mqtt_handler.publish(noise_record, "noise_floor") + def record_crc_errors(self, count: int): + """Record a batch of CRC errors detected since last poll.""" + crc_record = {"timestamp": time.time(), "count": count} + self.sqlite_handler.store_crc_errors(crc_record) + self.mqtt_handler.publish(crc_record, "crc_errors") + + def get_crc_error_count(self, hours: int = 24) -> int: + return self.sqlite_handler.get_crc_error_count(hours) + + def get_crc_error_history(self, hours: int = 24, limit: int = None) -> list: + return self.sqlite_handler.get_crc_error_history(hours, limit) + def get_packet_stats(self, hours: int = 24) -> dict: return self.sqlite_handler.get_packet_stats(hours) diff --git a/repeater/engine.py b/repeater/engine.py index b62e74a..9d16059 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -95,6 +95,7 @@ class RepeaterHandler(BaseHandler): self.last_noise_measurement = time.time() self.noise_floor_interval = NOISE_FLOOR_INTERVAL # 30 seconds self._background_task = None + self._last_crc_error_count = 0 # Track radio counter for delta persistence # Cache transport keys for efficient lookup self._transport_keys_cache = None @@ -708,6 +709,10 @@ class RepeaterHandler(BaseHandler): # Get current noise floor from radio noise_floor_dbm = self.get_noise_floor() + # Get CRC error count from radio hardware + radio = self.dispatcher.radio if self.dispatcher else None + crc_error_count = getattr(radio, "crc_error_count", 0) if radio else 0 + # Get neighbors from database neighbors = self.storage.get_neighbors() if self.storage else {} @@ -724,6 +729,7 @@ class RepeaterHandler(BaseHandler): "neighbors": neighbors, "uptime_seconds": uptime_seconds, "noise_floor_dbm": noise_floor_dbm, + "crc_error_count": crc_error_count, # Add configuration data "config": { "node_name": repeater_config.get("node_name", "Unknown"), @@ -768,6 +774,7 @@ class RepeaterHandler(BaseHandler): # Check noise floor recording (every 30 seconds) if current_time - self.last_noise_measurement >= self.noise_floor_interval: await self._record_noise_floor_async() + await self._record_crc_errors_async() self.last_noise_measurement = current_time # Check advert sending (every N hours) @@ -803,6 +810,22 @@ class RepeaterHandler(BaseHandler): except Exception as e: logger.error(f"Error recording noise floor: {e}") + async def _record_crc_errors_async(self): + """Persist CRC error delta from the radio hardware counter.""" + if not self.storage: + return + + try: + radio = self.dispatcher.radio if self.dispatcher else None + current = getattr(radio, "crc_error_count", 0) if radio else 0 + delta = current - self._last_crc_error_count + if delta > 0: + self.storage.record_crc_errors(delta) + logger.debug(f"Recorded {delta} CRC errors (total: {current})") + self._last_crc_error_count = current + except Exception as e: + logger.error(f"Error recording CRC errors: {e}") + async def _send_periodic_advert_async(self): logger.info( f"Periodic advert timer triggered (interval: {self.send_advert_interval_hours}h)" diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index fda34a3..5e8fe86 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -1420,6 +1420,40 @@ class APIEndpoints: logger.error(f"Error fetching noise floor chart data: {e}") return self._error(e) + @cherrypy.expose + @cherrypy.tools.json_out() + def crc_error_count(self, hours: int = 24): + """Return total CRC errors within the given time window.""" + try: + storage = self._get_storage() + hours = int(hours) + count = storage.get_crc_error_count(hours=hours) + return self._success({ + "crc_error_count": count, + "hours": hours + }) + except Exception as e: + logger.error(f"Error fetching CRC error count: {e}") + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + def crc_error_history(self, hours: int = 24, limit: int = None): + """Return CRC error records within the given time window.""" + try: + storage = self._get_storage() + hours = int(hours) + limit = int(limit) if limit else None + history = storage.get_crc_error_history(hours=hours, limit=limit) + return self._success({ + "history": history, + "hours": hours, + "count": len(history) + }) + except Exception as e: + logger.error(f"Error fetching CRC error history: {e}") + return self._error(e) + @cherrypy.expose def cad_calibration_stream(self): cherrypy.response.headers['Content-Type'] = 'text/event-stream'