From 34747d2610d8108d591edaf018a6c50b23657ba2 Mon Sep 17 00:00:00 2001 From: Rightup Date: Thu, 2 Jul 2026 16:18:54 +0100 Subject: [PATCH] feat: add packet retrieval by ID endpoint and corresponding database methods --- repeater/data_acquisition/sqlite_handler.py | 32 +++++++- .../data_acquisition/storage_collector.py | 11 ++- repeater/web/api_endpoints.py | 12 +++ repeater/web/openapi.yaml | 15 ++++ tests/test_sqlite_handler_easy.py | 73 ++++++++++++++++++- 5 files changed, 137 insertions(+), 6 deletions(-) diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 833755e..6612a38 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -671,7 +671,7 @@ class SQLiteHandler: except Exception: fwd_path_val = str(fwd_path) - conn.execute( + cursor = conn.execute( """ INSERT INTO packets ( timestamp, type, route, length, rssi, snr, score, @@ -714,6 +714,7 @@ class SQLiteHandler: ), ) self._invalidate_hot_caches() + return cursor.lastrowid except Exception as e: logger.error(f"Failed to store packet in SQLite: {e}") @@ -992,6 +993,7 @@ class SQLiteHandler: packets = conn.execute( """ SELECT + id, timestamp, type, route, length, rssi, snr, score, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, transport_codes, payload, payload_length, @@ -1044,6 +1046,7 @@ class SQLiteHandler: base_query = """ SELECT + id, timestamp, type, route, length, rssi, snr, score, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, transport_codes, payload, payload_length, @@ -1176,6 +1179,7 @@ class SQLiteHandler: packet = conn.execute( """ SELECT + id, timestamp, type, route, length, rssi, snr, score, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, header, transport_codes, payload, payload_length, @@ -1193,6 +1197,32 @@ class SQLiteHandler: logger.error(f"Failed to get packet by hash: {e}") return None + def get_packet_by_id(self, packet_id: int) -> Optional[dict]: + try: + with self._connect() as conn: + conn.row_factory = sqlite3.Row + + packet = conn.execute( + """ + SELECT + id, + timestamp, type, route, length, rssi, snr, score, + transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, + header, transport_codes, payload, payload_length, + tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet, + lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy + FROM packets + WHERE id = ? + """, + (packet_id,), + ).fetchone() + + return dict(packet) if packet else None + + except Exception as e: + logger.error(f"Failed to get packet by id: {e}") + return None + def get_packet_type_stats(self, hours: int = 24) -> dict: try: now = time.time() diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py index db35608..1a3303e 100644 --- a/repeater/data_acquisition/storage_collector.py +++ b/repeater/data_acquisition/storage_collector.py @@ -45,9 +45,7 @@ class StorageCollector: letsmesh_config = config.get("letsmesh", {}) or {} mqtt_config = config.get("mqtt", {}) or {} has_brokers_configured = ( - bool(mqtt_brokers_config.get("brokers")) - or bool(letsmesh_config) - or bool(mqtt_config) + bool(mqtt_brokers_config.get("brokers")) or bool(letsmesh_config) or bool(mqtt_config) ) if has_brokers_configured and local_identity: try: @@ -217,7 +215,9 @@ class StorageCollector: def _record_packet_blocking(self, packet_record: dict, skip_mqtt: bool): """Store, aggregate, update metrics, and publish one packet (writer thread).""" - self.sqlite_handler.store_packet(packet_record) + packet_id = self.sqlite_handler.store_packet(packet_record) + if packet_id is not None: + packet_record["id"] = packet_id cumulative_counts = self.sqlite_handler.get_cumulative_counts() self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts) self._publish_packet_sync(packet_record, skip_mqtt) @@ -424,6 +424,9 @@ class StorageCollector: def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]: return self.sqlite_handler.get_packet_by_hash(packet_hash) + def get_packet_by_id(self, packet_id: int) -> Optional[dict]: + return self.sqlite_handler.get_packet_by_id(packet_id) + def get_rrd_data( self, start_time: Optional[int] = None, diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index e60161c..7be19fa 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -2962,6 +2962,18 @@ class APIEndpoints: logger.error(f"Error getting packet by hash: {e}") return self._error(e) + @cherrypy.expose + @cherrypy.tools.json_out() + def packet_by_id(self, packet_id=None): + try: + if packet_id is None: + return self._error("packet_id parameter required") + packet = self._get_storage().get_packet_by_id(int(packet_id)) + return self._success(packet) if packet else self._error("Packet not found") + except Exception as e: + logger.error(f"Error getting packet by id: {e}") + return self._error(e) + @cherrypy.expose @cherrypy.tools.json_out() def rrd_data(self): diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index 25d8813..7610770 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -862,6 +862,21 @@ paths: '200': description: Packet details + /packet_by_id: + get: + tags: [Packets] + summary: Get packet by ID + parameters: + - name: packet_id + in: query + required: true + schema: + type: integer + description: Packet database ID to lookup + responses: + '200': + description: Packet details + /packet_type_stats: get: tags: [Packets] diff --git a/tests/test_sqlite_handler_easy.py b/tests/test_sqlite_handler_easy.py index f744d91..bb0d3f9 100644 --- a/tests/test_sqlite_handler_easy.py +++ b/tests/test_sqlite_handler_easy.py @@ -1,7 +1,7 @@ import base64 -from pathlib import Path import sys import types +from pathlib import Path import pytest @@ -170,6 +170,77 @@ def test_store_and_delete_advert(tmp_path): assert h.delete_advert(advert_id) is False +def test_store_packet_returns_inserted_row_id(tmp_path): + h = _make_handler(tmp_path) + + packet_id = h.store_packet( + { + "timestamp": 123.0, + "type": 1, + "route": 2, + "length": 3, + "transmitted": True, + "packet_hash": "pkt-1", + } + ) + + assert isinstance(packet_id, int) + assert packet_id > 0 + + with h._connect() as conn: + row = conn.execute( + "SELECT id, type, route, length FROM packets WHERE id = ?", + (packet_id,), + ).fetchone() + + assert row is not None + assert row[0] == packet_id + assert row[1] == 1 + assert row[2] == 2 + assert row[3] == 3 + + +def test_recent_packet_queries_include_ids_and_preserve_duplicate_hash_rows(tmp_path): + h = _make_handler(tmp_path) + + first_id = h.store_packet( + { + "timestamp": 100.0, + "type": 1, + "route": 1, + "length": 8, + "transmitted": False, + "is_duplicate": False, + "packet_hash": "same-hash", + } + ) + second_id = h.store_packet( + { + "timestamp": 101.0, + "type": 1, + "route": 1, + "length": 8, + "transmitted": True, + "is_duplicate": True, + "packet_hash": "same-hash", + } + ) + + recent = h.get_recent_packets(limit=10) + assert len(recent) == 2 + assert {packet["id"] for packet in recent} == {first_id, second_id} + assert [packet["packet_hash"] for packet in recent] == ["same-hash", "same-hash"] + + filtered = h.get_filtered_packets(limit=10) + assert len(filtered) == 2 + assert {packet["id"] for packet in filtered} == {first_id, second_id} + + by_id = h.get_packet_by_id(int(second_id)) + assert by_id is not None + assert by_id["id"] == second_id + assert by_id["packet_hash"] == "same-hash" + + def test_verify_api_token_last_used_throttle(tmp_path, monkeypatch): h = _make_handler(tmp_path) h._api_token_last_used_interval_sec = 300