diff --git a/frontend/index.html b/frontend/index.html
index 0825005..132bd00 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1031,7 +1031,9 @@ function formatDuration(start, end) {
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
const m = Math.floor(s / 60);
- return m + 'm ' + (s % 60) + 's';
+ if (m < 60) return m + 'm ' + (s % 60) + 's';
+ const h = Math.floor(m / 60);
+ return h + 'h ' + (m % 60) + 'm';
}
function escapeHtml(str) {
diff --git a/simtrace2_pysniff/pcap.py b/simtrace2_pysniff/pcap.py
index 8578555..25ee06a 100644
--- a/simtrace2_pysniff/pcap.py
+++ b/simtrace2_pysniff/pcap.py
@@ -378,9 +378,9 @@ def _parse_tsresol(options, endian):
if code == 9 and length == 1:
b = options[i]
if b & 0x80:
- return 10 ** (-(b & 0x7F))
+ return 2 ** (-(b & 0x7F))
return 10 ** (-b)
- i += length + (length & 1) # options are padded to 32-bit
+ i += length + ((-length) % 4) # options are padded to a 32-bit boundary
return 1e-6 # default: microseconds
diff --git a/simtrace2_pysniff/server/database.py b/simtrace2_pysniff/server/database.py
index 03d3ebd..1a6d6b8 100644
--- a/simtrace2_pysniff/server/database.py
+++ b/simtrace2_pysniff/server/database.py
@@ -37,6 +37,11 @@ def _iso_now():
f'{time.time() % 1 * 1000:03.0f}'
+def _iso_from_ts(ts):
+ return time.strftime('%Y-%m-%dT%H:%M:%S.', time.localtime(ts)) + \
+ f'{ts % 1 * 1000:03.0f}'
+
+
class Database:
def __init__(self, db_path=None):
if db_path is None:
@@ -64,6 +69,12 @@ class Database:
(_iso_now(), session_id))
self._conn.commit()
+ def set_session_times_from_ts(self, session_id, first_ts, last_ts):
+ self._conn.execute(
+ 'UPDATE sessions SET started=?, ended=? WHERE id=?',
+ (_iso_from_ts(first_ts), _iso_from_ts(last_ts), session_id))
+ self._conn.commit()
+
def rename_session(self, session_id, name):
self._conn.execute(
'UPDATE sessions SET name=? WHERE id=?',
diff --git a/simtrace2_pysniff/server/server.py b/simtrace2_pysniff/server/server.py
index e8f04db..4a52ab7 100644
--- a/simtrace2_pysniff/server/server.py
+++ b/simtrace2_pysniff/server/server.py
@@ -322,12 +322,20 @@ class RequestHandler(BaseHTTPRequestHandler):
self.db.rename_session(session_id, name)
first_ts = packets[0][0]
+ last_ts = packets[-1][0]
rows = []
for ts, msg_type, payload in packets:
elapsed = round(ts - first_ts, 6) if ts is not None else 0.0
rows.append((max(0.0, elapsed), msg_type, payload, 0))
self.db.insert_messages(session_id, rows)
- self.db.close_session(session_id)
+ # Use the packet timestamps as the session window when they are real
+ # (classic pcap / pcapng EPB). pcapng SPB-only files have synthetic
+ # 0,1,2,… timestamps, so fall back to the import wall-clock time.
+ _REAL_TS_MIN = 946684800 # 2000-01-01
+ if first_ts is not None and first_ts > _REAL_TS_MIN:
+ self.db.set_session_times_from_ts(session_id, first_ts, last_ts)
+ else:
+ self.db.close_session(session_id)
self._send_json({
'session_id': session_id,
diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py
index 6546e65..67b6ffa 100644
--- a/tests/test_pcap_import.py
+++ b/tests/test_pcap_import.py
@@ -87,6 +87,37 @@ class TestParsePcapng(unittest.TestCase):
self.assertEqual(list(parse_pcapng(shb + idb + epb)), [])
+class TestTsresol(unittest.TestCase):
+ def test_nanoseconds_after_padded_option(self):
+ from simtrace2_pysniff.pcap import _parse_tsresol
+ # if_name (code 2, len 2, "lo") then if_tsresol (code 9, len 1, 9), LE.
+ opts = (struct.pack('IHHq', 0x1A2B3C4D, 1, 0, -1))
+ idb = _pcapng_block(0x00000001,
+ struct.pack('>HHI', LINKTYPE_ETHERNET, 0, 65535) +
+ struct.pack('>HH', 9, 1) + b'\x09' + b'\x00\x00\x00' +
+ struct.pack('>HH', 0, 0))
+ # epoch 1600000000.5 s → 1600000000500000000 ns
+ raw = 1600000000500000000
+ ts_hi, ts_lo = raw >> 32, raw & 0xFFFFFFFF
+ frame = wrap_gsmtap(_gsmtap_hdr(GSMTAP_SIM_ATR) + b'\x3b\x04')
+ epb = _pcapng_block(0x00000006,
+ struct.pack('>IIIII', 0, ts_hi, ts_lo, len(frame), len(frame)) + frame)
+ parsed = list(parse_pcapng(shb + idb + epb))
+ self.assertEqual(len(parsed), 1)
+ self.assertAlmostEqual(parsed[0][0], 1600000000.5, places=6)
+
+
class TestImportEndpoint(unittest.TestCase):
def _handler(self, body):
from simtrace2_pysniff.server.server import RequestHandler
@@ -136,6 +167,32 @@ class TestImportEndpoint(unittest.TestCase):
self.assertIn('No GSMTAP data', h._json['error'])
self.assertEqual(h.db.count_sessions(), 0)
+ def test_import_session_window_from_timestamps(self):
+ from datetime import datetime
+ # Two packets one hour apart in epoch time.
+ t0 = 1600000000.0
+ packets = [
+ (_gsmtap_hdr(GSMTAP_SIM_ATR), b'\x3b\x00', t0),
+ (_gsmtap_hdr(GSMTAP_SIM_APDU), b'\x80\xf2\x00\x00\x00', t0 + 3600.0),
+ ]
+ h, _ = self._handler(build_pcap(packets))
+ h._handle_import_pcap({'name': ['window']})
+ session = h.db.get_session(h._json['session_id'])
+ started = datetime.fromisoformat(session['started'])
+ ended = datetime.fromisoformat(session['ended'])
+ self.assertEqual((ended - started).total_seconds(), 3600.0)
+
+ def test_import_synthetic_ts_falls_back(self):
+ # SPB-style synthetic timestamps (0, 1, …) → keep wall-clock fallback.
+ packets = [
+ (_gsmtap_hdr(GSMTAP_SIM_ATR), b'\x3b\x00', 0.0),
+ (_gsmtap_hdr(GSMTAP_SIM_APDU), b'\x80\xf2\x00\x00\x00', 1.0),
+ ]
+ h, _ = self._handler(build_pcap(packets))
+ h._handle_import_pcap({'name': ['synth']})
+ session = h.db.get_session(h._json['session_id'])
+ self.assertTrue(session['ended'] is not None)
+
class TestVersion(unittest.TestCase):
def test_package_version(self):