Track device connection and capture gaps; surface sniff error flags
This commit is contained in:
1 parent
26043e5381
commit
5d91450b55
10 files changed
+287
-29
No files matched your search
+89
-5
@@ -76,6 +76,9 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
|
|||||||
.header-title a { color: var(--accent); text-decoration: none; }
|
.header-title a { color: var(--accent); text-decoration: none; }
|
||||||
.header-title a:hover { text-decoration: underline; }
|
.header-title a:hover { text-decoration: underline; }
|
||||||
.header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; padding: 8px 16px; }
|
.header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; padding: 8px 16px; }
|
||||||
|
.capture-chip { font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 12px; white-space: nowrap; }
|
||||||
|
.capture-chip.live { color: var(--green); background: var(--accent-bg); }
|
||||||
|
.capture-chip.warn { color: var(--warn); background: var(--warning-bg); }
|
||||||
.header-right a { color: var(--link); text-decoration: none; font-size: 13px; }
|
.header-right a { color: var(--link); text-decoration: none; font-size: 13px; }
|
||||||
.header-right a:hover { color: var(--link-hover); }
|
.header-right a:hover { color: var(--link-hover); }
|
||||||
.header-right button { padding: 4px 10px; font-size: 14px; cursor: pointer; background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; color: var(--text); }
|
.header-right button { padding: 4px 10px; font-size: 14px; cursor: pointer; background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; color: var(--text); }
|
||||||
@@ -115,6 +118,11 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
|
|||||||
.apdu-sw.sw-pending { color: var(--accent); }
|
.apdu-sw.sw-pending { color: var(--accent); }
|
||||||
.apdu-sw.sw-warn { color: var(--warn); }
|
.apdu-sw.sw-warn { color: var(--warn); }
|
||||||
.apdu-sw.sw-error { color: var(--error); }
|
.apdu-sw.sw-error { color: var(--error); }
|
||||||
|
.apdu-err { color: var(--error); white-space: nowrap; }
|
||||||
|
.apdu-gap { background: var(--warning-bg); border-bottom: 1px solid var(--warn); }
|
||||||
|
.apdu-gap .apdu-type { color: var(--warn); }
|
||||||
|
.apdu-gap .apdu-data { color: var(--warn); }
|
||||||
|
.device-warn { padding: 8px 12px; font-size: 12px; color: var(--warn); background: var(--warning-bg); border-bottom: 1px solid var(--warn); }
|
||||||
|
|
||||||
/* APDU detail panel */
|
/* APDU detail panel */
|
||||||
.apdu-detail { display: flex; flex-direction: column; max-height: 40%; overflow-y: auto; border-top: 2px solid var(--accent); background: var(--bg-panel); flex-shrink: 0; }
|
.apdu-detail { display: flex; flex-direction: column; max-height: 40%; overflow-y: auto; border-top: 2px solid var(--accent); background: var(--bg-panel); flex-shrink: 0; }
|
||||||
@@ -232,6 +240,7 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-title">SIM-ME <a href="https://osmocom.org/projects/baseband/wiki/GSMTAP" target="_blank" rel="noopener">GSMTAP</a> <span data-l10n="trace analyser">trace analyser</span> <span class="pwa-version" id="pwa-version"></span></div>
|
<div class="header-title">SIM-ME <a href="https://osmocom.org/projects/baseband/wiki/GSMTAP" target="_blank" rel="noopener">GSMTAP</a> <span data-l10n="trace analyser">trace analyser</span> <span class="pwa-version" id="pwa-version"></span></div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<span class="capture-chip" id="capture-chip" style="display:none"></span>
|
||||||
<button id="install-btn" data-l10n="Install PWA">Install PWA</button>
|
<button id="install-btn" data-l10n="Install PWA">Install PWA</button>
|
||||||
<a href="https://github.com/anttro/simtrace2-pysniff" target="_blank" rel="noopener">GitHub</a>
|
<a href="https://github.com/anttro/simtrace2-pysniff" target="_blank" rel="noopener">GitHub</a>
|
||||||
<button id="lang-btn" onclick="toggleLang()" title="Language / Язык">EN</button>
|
<button id="lang-btn" onclick="toggleLang()" title="Language / Язык">EN</button>
|
||||||
@@ -267,6 +276,7 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
|
|||||||
<span><span class="status-dot off" id="status-dot"></span><span id="status-text" data-l10n="Not connected">Not connected</span></span>
|
<span><span class="status-dot off" id="status-dot"></span><span id="status-text" data-l10n="Not connected">Not connected</span></span>
|
||||||
<span id="msg-count" style="color:var(--text-muted);">0 messages</span>
|
<span id="msg-count" style="color:var(--text-muted);">0 messages</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="device-warn" id="device-warn" style="display:none">⚠ <span id="device-warn-text"></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sessions Panel -->
|
<!-- Sessions Panel -->
|
||||||
@@ -348,6 +358,7 @@ const STATE = {
|
|||||||
serverName: '',
|
serverName: '',
|
||||||
serverVersion: '',
|
serverVersion: '',
|
||||||
captureMode: '',
|
captureMode: '',
|
||||||
|
deviceConnected: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE = () => STATE.serverUrl;
|
const API_BASE = () => STATE.serverUrl;
|
||||||
@@ -378,6 +389,7 @@ function ins_label(msg) {
|
|||||||
if (msg.type === 'pps') return 'PPS';
|
if (msg.type === 'pps') return 'PPS';
|
||||||
if (msg.type === 'change') return 'STATE CHANGE';
|
if (msg.type === 'change') return 'STATE CHANGE';
|
||||||
if (msg.type === 'fidi') return 'FIDI';
|
if (msg.type === 'fidi') return 'FIDI';
|
||||||
|
if (msg.type === 'gap') return 'CAPTURE GAP';
|
||||||
const d = msg.data;
|
const d = msg.data;
|
||||||
if (d && d.length >= 2) {
|
if (d && d.length >= 2) {
|
||||||
const ins = d.slice(2, 4);
|
const ins = d.slice(2, 4);
|
||||||
@@ -391,6 +403,7 @@ function _fallback_label(msg) {
|
|||||||
if (msg.type === 'pps') return 'PPS';
|
if (msg.type === 'pps') return 'PPS';
|
||||||
if (msg.type === 'change') return 'STATE CHANGE';
|
if (msg.type === 'change') return 'STATE CHANGE';
|
||||||
if (msg.type === 'fidi') return 'FIDI';
|
if (msg.type === 'fidi') return 'FIDI';
|
||||||
|
if (msg.type === 'gap') return 'CAPTURE GAP';
|
||||||
const data = msg.data;
|
const data = msg.data;
|
||||||
if (data && data.length >= 2) {
|
if (data && data.length >= 2) {
|
||||||
const ins = data.slice(2, 4);
|
const ins = data.slice(2, 4);
|
||||||
@@ -458,10 +471,16 @@ function data_text(m) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cmd_description(m) {
|
function cmd_description(m) {
|
||||||
|
if (m.type === 'gap') return t('device disconnected — messages may be missing');
|
||||||
if (m.decoded && m.decoded.summary) return m.decoded.summary;
|
if (m.decoded && m.decoded.summary) return m.decoded.summary;
|
||||||
return data_text(m);
|
return data_text(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function error_names(m) {
|
||||||
|
const d = m.decoded;
|
||||||
|
return (d && Array.isArray(d.errors) && d.errors.length) ? d.errors : null;
|
||||||
|
}
|
||||||
|
|
||||||
function sw_text(m) {
|
function sw_text(m) {
|
||||||
const d = m.decoded;
|
const d = m.decoded;
|
||||||
if (!d || !d.sw) return '';
|
if (!d || !d.sw) return '';
|
||||||
@@ -834,6 +853,7 @@ function atrPpsRows(decoded) {
|
|||||||
if (decoded.note) rows.push(['Note', decoded.note]);
|
if (decoded.note) rows.push(['Note', decoded.note]);
|
||||||
}
|
}
|
||||||
rows.push(['Raw', (decoded.raw || '')]);
|
rows.push(['Raw', (decoded.raw || '')]);
|
||||||
|
if (decoded.errors) rows.push(['Errors', decoded.errors.join(', ')]);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,6 +865,12 @@ function showDetail(msg, entry) {
|
|||||||
const d = el('apdu-detail');
|
const d = el('apdu-detail');
|
||||||
const decoded = msg.decoded;
|
const decoded = msg.decoded;
|
||||||
|
|
||||||
|
if (msg.type === 'gap') {
|
||||||
|
d.innerHTML = `<div class="detail-header"><strong>Capture Gap</strong><span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</span></div>
|
||||||
|
<div class="detail-body" style="color:var(--warn);">⚠ ${t('The device disconnected and reconnected — some APDUs may be missing from this trace.')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!decoded) {
|
if (!decoded) {
|
||||||
d.innerHTML = `<div class="detail-header"><strong>${ins_label(msg)}</strong><span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</span></div>
|
d.innerHTML = `<div class="detail-header"><strong>${ins_label(msg)}</strong><span style="color:var(--text-muted)">${fmt_elapsed(msg.elapsed)}</span></div>
|
||||||
<div class="detail-body" style="color:var(--text-muted);">No decoded data available for this message type.</div>`;
|
<div class="detail-body" style="color:var(--text-muted);">No decoded data available for this message type.</div>`;
|
||||||
@@ -959,6 +985,7 @@ function showDetail(msg, entry) {
|
|||||||
${responseRows(decoded.response)}
|
${responseRows(decoded.response)}
|
||||||
${fileRows(decoded.file)}
|
${fileRows(decoded.file)}
|
||||||
${sw}
|
${sw}
|
||||||
|
${decoded.errors ? `<tr><td class="label">Errors</td><td class="value" style="color:var(--error)">${decoded.errors.join(', ')}</td></tr>` : ''}
|
||||||
</table>
|
</table>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -1213,13 +1240,23 @@ function renderMessages(skipScroll) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = filtered.map(({ m, idx }) => {
|
list.innerHTML = filtered.map(({ m, idx }) => {
|
||||||
|
if (m.type === 'gap') {
|
||||||
|
return `<div class="apdu-entry apdu-gap" data-idx="${idx}">
|
||||||
|
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
||||||
|
<span class="apdu-type">⚠ CAPTURE GAP</span>
|
||||||
|
<span class="apdu-data">${cmd_description(m)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
const label = ins_label(m);
|
const label = ins_label(m);
|
||||||
const typeCls = m.type === 'atr' ? 'atr' : m.type === 'pps' ? 'pps' : '';
|
const typeCls = m.type === 'atr' ? 'atr' : m.type === 'pps' ? 'pps' : '';
|
||||||
|
const errs = error_names(m);
|
||||||
|
const errBadge = errs ? `<span class="apdu-err" title="${errs.join(', ')}">⚠</span>` : '';
|
||||||
return `<div class="apdu-entry" data-idx="${idx}">
|
return `<div class="apdu-entry" data-idx="${idx}">
|
||||||
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
|
||||||
<span class="apdu-type ${typeCls}">${label}</span>
|
<span class="apdu-type ${typeCls}">${label}</span>
|
||||||
<span class="apdu-sw ${sw_class(m)}">${sw_text(m)}</span>
|
<span class="apdu-sw ${sw_class(m)}">${sw_text(m)}</span>
|
||||||
<span class="apdu-data">${cmd_description(m)}</span>
|
<span class="apdu-data">${cmd_description(m)}</span>
|
||||||
|
${errBadge}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -1246,17 +1283,48 @@ function updateStatus() {
|
|||||||
text.textContent = t('Not connected');
|
text.textContent = t('Not connected');
|
||||||
}
|
}
|
||||||
count.textContent = t('{n} messages').replace('{n}', STATE.messages.length);
|
count.textContent = t('{n} messages').replace('{n}', STATE.messages.length);
|
||||||
|
updateDeviceWarn();
|
||||||
|
updateCaptureChip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCaptureChip() {
|
||||||
|
const chip = el('capture-chip');
|
||||||
|
if (!chip) return;
|
||||||
|
const disconnected = STATE.captureMode === 'direct' && STATE.captureActive && STATE.deviceConnected === false;
|
||||||
|
if (disconnected) {
|
||||||
|
chip.style.display = '';
|
||||||
|
chip.className = 'capture-chip warn';
|
||||||
|
chip.textContent = '⚠ ' + t('device disconnected — reconnecting…');
|
||||||
|
} else if (STATE.captureActive) {
|
||||||
|
chip.style.display = '';
|
||||||
|
chip.className = 'capture-chip live';
|
||||||
|
chip.textContent = '● ' + t('LIVE') + ' · ' + t('{n} messages').replace('{n}', STATE.messages.length);
|
||||||
|
} else {
|
||||||
|
chip.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDeviceWarn() {
|
||||||
|
const warn = el('device-warn');
|
||||||
|
if (!warn) return;
|
||||||
|
const disconnected = STATE.captureMode === 'direct' && STATE.captureActive && STATE.deviceConnected === false;
|
||||||
|
if (disconnected) {
|
||||||
|
warn.style.display = '';
|
||||||
|
el('device-warn-text').textContent = t('device disconnected — reconnecting…');
|
||||||
|
} else {
|
||||||
|
warn.style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────── Live Polling ────────────────────
|
// ──────────────────── Live Polling ────────────────────
|
||||||
let _lastAfter = 0;
|
let _lastAfter = 0;
|
||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
_lastAfter = 0;
|
|
||||||
if (STATE.livePoller) return;
|
if (STATE.livePoller) return;
|
||||||
STATE.livePoller = setInterval(async () => {
|
STATE.livePoller = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await API.getLatest(_lastAfter);
|
const data = await API.getLatest(_lastAfter);
|
||||||
|
STATE.deviceConnected = data.device_connected;
|
||||||
if (data.active) {
|
if (data.active) {
|
||||||
STATE.captureActive = true;
|
STATE.captureActive = true;
|
||||||
STATE.currentSessionId = data.session_id;
|
STATE.currentSessionId = data.session_id;
|
||||||
@@ -1289,6 +1357,7 @@ async function startCapture() {
|
|||||||
STATE.captureActive = true;
|
STATE.captureActive = true;
|
||||||
STATE.currentSessionId = data.session_id;
|
STATE.currentSessionId = data.session_id;
|
||||||
STATE.messages = [];
|
STATE.messages = [];
|
||||||
|
_lastAfter = 0;
|
||||||
buildTypeCounts();
|
buildTypeCounts();
|
||||||
buildTraceMap();
|
buildTraceMap();
|
||||||
renderSidePanel();
|
renderSidePanel();
|
||||||
@@ -1426,6 +1495,7 @@ async function importPcapFile(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSession(id) {
|
async function loadSession(id) {
|
||||||
|
if (STATE.captureActive && !confirm(t('Opening a session stops the active capture. Continue?'))) return;
|
||||||
try {
|
try {
|
||||||
// Stop any active capture so it can't write into the session being viewed.
|
// Stop any active capture so it can't write into the session being viewed.
|
||||||
await API.stopCapture();
|
await API.stopCapture();
|
||||||
@@ -1563,6 +1633,7 @@ function connectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disconnectServer() {
|
function disconnectServer() {
|
||||||
|
if (STATE.captureActive && !confirm(t('Capture is still running on the server — it will continue after you disconnect. Disconnect?'))) return;
|
||||||
if (_connTimer) {
|
if (_connTimer) {
|
||||||
clearInterval(_connTimer);
|
clearInterval(_connTimer);
|
||||||
_connTimer = null;
|
_connTimer = null;
|
||||||
@@ -1585,10 +1656,6 @@ function disconnectServer() {
|
|||||||
btn.classList.add('disconnected');
|
btn.classList.add('disconnected');
|
||||||
updateCaptureButton();
|
updateCaptureButton();
|
||||||
stopPolling();
|
stopPolling();
|
||||||
if (STATE.captureActive) {
|
|
||||||
STATE.captureActive = false;
|
|
||||||
STATE.currentSessionId = null;
|
|
||||||
}
|
|
||||||
renderTraceEmptyState();
|
renderTraceEmptyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1599,6 +1666,7 @@ async function checkConnection() {
|
|||||||
STATE.captureMode = data.capture_mode || '';
|
STATE.captureMode = data.capture_mode || '';
|
||||||
STATE.serverName = data.server || 'simtrace-analyser-server';
|
STATE.serverName = data.server || 'simtrace-analyser-server';
|
||||||
STATE.serverVersion = data.version || '';
|
STATE.serverVersion = data.version || '';
|
||||||
|
STATE.deviceConnected = data.device_connected;
|
||||||
el('conn-status').textContent = connectionStatusText();
|
el('conn-status').textContent = connectionStatusText();
|
||||||
el('conn-status').className = 'conn-status connected';
|
el('conn-status').className = 'conn-status connected';
|
||||||
updateVersionWarn(data.version);
|
updateVersionWarn(data.version);
|
||||||
@@ -1610,8 +1678,18 @@ async function checkConnection() {
|
|||||||
el('status-dot').className = 'status-dot ' + (data.capture_active ? 'live' : 'paused');
|
el('status-dot').className = 'status-dot ' + (data.capture_active ? 'live' : 'paused');
|
||||||
el('status-text').textContent = data.capture_active ? t('Capturing') : t('Connected');
|
el('status-text').textContent = data.capture_active ? t('Capturing') : t('Connected');
|
||||||
if (data.capture_active) {
|
if (data.capture_active) {
|
||||||
|
if (data.session_id && data.session_id !== STATE.currentSessionId) {
|
||||||
|
// A different capture session than the one we were viewing —
|
||||||
|
// reset the stream so we re-fetch it from the start.
|
||||||
|
STATE.currentSessionId = data.session_id;
|
||||||
|
STATE.messages = [];
|
||||||
|
_lastAfter = 0;
|
||||||
|
}
|
||||||
STATE.captureActive = true;
|
STATE.captureActive = true;
|
||||||
STATE.currentSessionId = data.session_id;
|
STATE.currentSessionId = data.session_id;
|
||||||
|
startPolling();
|
||||||
|
} else {
|
||||||
|
STATE.captureActive = false;
|
||||||
}
|
}
|
||||||
updateCaptureButton();
|
updateCaptureButton();
|
||||||
renderTraceEmptyState();
|
renderTraceEmptyState();
|
||||||
@@ -1734,6 +1812,12 @@ const LANG_RU = {
|
|||||||
'Import PCAP': 'Импорт PCAP',
|
'Import PCAP': 'Импорт PCAP',
|
||||||
'New capture': 'Новый захват',
|
'New capture': 'Новый захват',
|
||||||
'Server': 'Сервер',
|
'Server': 'Сервер',
|
||||||
|
'device disconnected — reconnecting…': 'устройство отключено — переподключение…',
|
||||||
|
'device disconnected — messages may be missing': 'устройство отключалось — сообщения могли быть потеряны',
|
||||||
|
'The device disconnected and reconnected — some APDUs may be missing from this trace.': 'Устройство отключалось и переподключалось — часть APDU может отсутствовать в трейсе.',
|
||||||
|
'LIVE': 'В ЭФИРЕ',
|
||||||
|
'Opening a session stops the active capture. Continue?': 'Открытие сессии остановит активный захват. Продолжить?',
|
||||||
|
'Capture is still running on the server — it will continue after you disconnect. Disconnect?': 'Захват всё ещё выполняется на сервере — он продолжится после отключения. Отключиться?',
|
||||||
'Same origin': 'Тот же источник',
|
'Same origin': 'Тот же источник',
|
||||||
'Custom URL': 'Свой URL',
|
'Custom URL': 'Свой URL',
|
||||||
'Connect': 'Подключить',
|
'Connect': 'Подключить',
|
||||||
|
|||||||
@@ -55,9 +55,6 @@ def build_parser():
|
|||||||
p.add_argument('--vendor', type=lambda x: int(x, 16),
|
p.add_argument('--vendor', type=lambda x: int(x, 16),
|
||||||
default=0x1d50, metavar='HEX',
|
default=0x1d50, metavar='HEX',
|
||||||
help='USB vendor ID (default: 0x1d50)')
|
help='USB vendor ID (default: 0x1d50)')
|
||||||
p.add_argument('--product', type=lambda x: int(x, 16),
|
|
||||||
default=None, metavar='HEX',
|
|
||||||
help='USB product ID (default: auto-detect)')
|
|
||||||
|
|
||||||
rec = p.add_argument_group('recovery options')
|
rec = p.add_argument_group('recovery options')
|
||||||
rec.add_argument('--no-reconnect', action='store_true',
|
rec.add_argument('--no-reconnect', action='store_true',
|
||||||
@@ -112,6 +109,7 @@ def main():
|
|||||||
reconnect_delay_max=args.reconnect_delay_max,
|
reconnect_delay_max=args.reconnect_delay_max,
|
||||||
backoff_factor=args.backoff_factor,
|
backoff_factor=args.backoff_factor,
|
||||||
inactivity_timeout=args.inactivity_timeout,
|
inactivity_timeout=args.inactivity_timeout,
|
||||||
|
vendor_id=args.vendor,
|
||||||
)
|
)
|
||||||
|
|
||||||
exiting = False
|
exiting = False
|
||||||
|
|||||||
+29
-10
@@ -45,13 +45,7 @@ def find_sniffer_device(vendor_id=USB_VENDOR_OPENMOKO):
|
|||||||
|
|
||||||
Returns a ``usb.core.Device`` on success, or ``None``.
|
Returns a ``usb.core.Device`` on success, or ``None``.
|
||||||
"""
|
"""
|
||||||
devices = list(usb.core.find(
|
devices = list(usb.core.find(find_all=True, idVendor=vendor_id))
|
||||||
find_all=True,
|
|
||||||
idVendor=vendor_id,
|
|
||||||
custom_match=lambda d: (
|
|
||||||
True
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
for dev in devices:
|
for dev in devices:
|
||||||
for cfg in dev:
|
for cfg in dev:
|
||||||
@@ -71,7 +65,9 @@ def _get_ep_addrs(dev):
|
|||||||
intf.bInterfaceSubClass == SIMTRACE_SNIFFER_USB_SUBCLASS):
|
intf.bInterfaceSubClass == SIMTRACE_SNIFFER_USB_SUBCLASS):
|
||||||
for ep in intf:
|
for ep in intf:
|
||||||
addr = ep.bEndpointAddress
|
addr = ep.bEndpointAddress
|
||||||
if addr & 0x80: # IN endpoint
|
# BULK IN endpoint only — the interrupt IN endpoint carries
|
||||||
|
# cardem notifications, not sniff data.
|
||||||
|
if addr & 0x80 and ep.bmAttributes & 0x03 == 0x02:
|
||||||
return addr
|
return addr
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -100,16 +96,19 @@ class SniffSession:
|
|||||||
reconnect_delay_min=1.0,
|
reconnect_delay_min=1.0,
|
||||||
reconnect_delay_max=30.0,
|
reconnect_delay_max=30.0,
|
||||||
backoff_factor=1.5,
|
backoff_factor=1.5,
|
||||||
inactivity_timeout=0.0):
|
inactivity_timeout=0.0,
|
||||||
|
vendor_id=USB_VENDOR_OPENMOKO):
|
||||||
self._reconnect = reconnect
|
self._reconnect = reconnect
|
||||||
self._reconnect_delay_min = reconnect_delay_min
|
self._reconnect_delay_min = reconnect_delay_min
|
||||||
self._reconnect_delay_max = reconnect_delay_max
|
self._reconnect_delay_max = reconnect_delay_max
|
||||||
self._backoff_factor = backoff_factor
|
self._backoff_factor = backoff_factor
|
||||||
self._inactivity_timeout = inactivity_timeout
|
self._inactivity_timeout = inactivity_timeout
|
||||||
|
self._vendor_id = vendor_id
|
||||||
|
|
||||||
self._dev = None
|
self._dev = None
|
||||||
self._devh = None
|
self._devh = None
|
||||||
self._ep_in = None
|
self._ep_in = None
|
||||||
|
self._connected = False
|
||||||
self._last_msg_time = 0.0
|
self._last_msg_time = 0.0
|
||||||
self._disconnect_count = 0
|
self._disconnect_count = 0
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ class SniffSession:
|
|||||||
if self._devh is not None:
|
if self._devh is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
dev = find_sniffer_device()
|
dev = find_sniffer_device(vendor_id=self._vendor_id)
|
||||||
if dev is None:
|
if dev is None:
|
||||||
raise DeviceDisconnected('No SIMtrace2 sniffer device found')
|
raise DeviceDisconnected('No SIMtrace2 sniffer device found')
|
||||||
|
|
||||||
@@ -147,6 +146,7 @@ class SniffSession:
|
|||||||
self._dev = dev
|
self._dev = dev
|
||||||
self._devh = devh
|
self._devh = devh
|
||||||
self._ep_in = ep_in
|
self._ep_in = ep_in
|
||||||
|
self._connected = True
|
||||||
self._last_msg_time = time.monotonic()
|
self._last_msg_time = time.monotonic()
|
||||||
self._disconnect_count = 0
|
self._disconnect_count = 0
|
||||||
|
|
||||||
@@ -167,6 +167,7 @@ class SniffSession:
|
|||||||
self._devh = None
|
self._devh = None
|
||||||
self._dev = None
|
self._dev = None
|
||||||
self._ep_in = None
|
self._ep_in = None
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
def _backoff_wait(self):
|
def _backoff_wait(self):
|
||||||
"""Sleep with exponential backoff before reconnecting."""
|
"""Sleep with exponential backoff before reconnecting."""
|
||||||
@@ -236,20 +237,38 @@ class SniffSession:
|
|||||||
f'Inactivity timeout ({elapsed:.1f}s > '
|
f'Inactivity timeout ({elapsed:.1f}s > '
|
||||||
f'{self._inactivity_timeout:.1f}s)')
|
f'{self._inactivity_timeout:.1f}s)')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self):
|
||||||
|
"""True while the USB sniffer device is open and claimed."""
|
||||||
|
return self._connected
|
||||||
|
|
||||||
def iter_messages(self):
|
def iter_messages(self):
|
||||||
"""Generator of SniffMessage objects.
|
"""Generator of SniffMessage objects.
|
||||||
|
|
||||||
Automatically reconnects on disconnect if ``reconnect=True``.
|
Automatically reconnects on disconnect if ``reconnect=True``.
|
||||||
Raises ``DeviceDisconnected`` if reconnect is disabled or exhausted.
|
Raises ``DeviceDisconnected`` if reconnect is disabled or exhausted.
|
||||||
|
|
||||||
|
When an established connection is lost and a reconnect is pending, a
|
||||||
|
single synthetic ``SniffMessage(type='gap')`` is yielded so callers can
|
||||||
|
record the interruption (messages may have been missed).
|
||||||
"""
|
"""
|
||||||
|
from .protocol import SniffMessage
|
||||||
|
|
||||||
|
was_connected = False
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
self._ensure_connected()
|
self._ensure_connected()
|
||||||
|
was_connected = True
|
||||||
yield from self._read_loop()
|
yield from self._read_loop()
|
||||||
except DeviceDisconnected:
|
except DeviceDisconnected:
|
||||||
self._close()
|
self._close()
|
||||||
if not self._reconnect:
|
if not self._reconnect:
|
||||||
raise
|
raise
|
||||||
|
if was_connected:
|
||||||
|
yield SniffMessage(type='gap', type_hex='0x00', data=b'',
|
||||||
|
flags=0, slot_nr=0, seq_nr=0,
|
||||||
|
timestamp=time.time())
|
||||||
|
was_connected = False
|
||||||
self._backoff_wait()
|
self._backoff_wait()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ class DirectSniffer:
|
|||||||
)
|
)
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self):
|
||||||
|
"""True while the USB sniffer device is open."""
|
||||||
|
return self._session.connected
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
@@ -81,6 +86,14 @@ class CaptureManager:
|
|||||||
def latest_msg_id(self):
|
def latest_msg_id(self):
|
||||||
return self._latest_msg_id
|
return self._latest_msg_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_connected(self):
|
||||||
|
"""True while the capture device is connected, or None when the
|
||||||
|
backend has no device concept (gsmtap)."""
|
||||||
|
if isinstance(self._backend, DirectSniffer):
|
||||||
|
return self._backend.connected
|
||||||
|
return None
|
||||||
|
|
||||||
def start_session(self):
|
def start_session(self):
|
||||||
if self._session_id is not None:
|
if self._session_id is not None:
|
||||||
self.stop_session()
|
self.stop_session()
|
||||||
|
|||||||
@@ -257,11 +257,11 @@ class Database:
|
|||||||
from .decode import decode_sniff_msg
|
from .decode import decode_sniff_msg
|
||||||
rows = self._conn.execute(
|
rows = self._conn.execute(
|
||||||
"SELECT data, type FROM messages WHERE session_id=? AND id < ? "
|
"SELECT data, type FROM messages WHERE session_id=? AND id < ? "
|
||||||
"AND type IN ('tpdu','atr','change') ORDER BY id DESC LIMIT 500",
|
"AND type IN ('tpdu','atr','change','gap') ORDER BY id DESC LIMIT 500",
|
||||||
(session_id, before_id)).fetchall()
|
(session_id, before_id)).fetchall()
|
||||||
states = {}
|
states = {}
|
||||||
for data, typ in reversed(rows):
|
for data, typ in reversed(rows):
|
||||||
if typ in ('atr', 'change'):
|
if typ in ('atr', 'change', 'gap'):
|
||||||
states = {}
|
states = {}
|
||||||
continue
|
continue
|
||||||
channel = data[0] & 0x03 if data and len(data) >= 1 else 0
|
channel = data[0] & 0x03 if data and len(data) >= 1 else 0
|
||||||
@@ -358,7 +358,22 @@ class Database:
|
|||||||
if msg['type'] == 'tpdu' and d and d.get('ins_hex'):
|
if msg['type'] == 'tpdu' and d and d.get('ins_hex'):
|
||||||
st['sel'], st['df'], st['sfi_map'] = self._advance_state(d, st['sel'], st['df'], st['sfi_map'])
|
st['sel'], st['df'], st['sfi_map'] = self._advance_state(d, st['sel'], st['df'], st['sfi_map'])
|
||||||
st['prev'] = self._context_from_decoded(d)
|
st['prev'] = self._context_from_decoded(d)
|
||||||
elif msg['type'] in ('atr', 'change'):
|
elif msg['type'] == 'atr':
|
||||||
|
states = {}
|
||||||
|
elif msg['type'] == 'gap':
|
||||||
|
# Device disconnected and reconnected — messages may have been
|
||||||
|
# missed, so the selection is uncertain.
|
||||||
|
states = {}
|
||||||
|
elif msg['type'] == 'change':
|
||||||
|
# Only card-level changes invalidate the selection. A
|
||||||
|
# waiting-time timeout interrupts a single TPDU but leaves the
|
||||||
|
# card selected, so the phone usually just retries.
|
||||||
|
from ..protocol import (
|
||||||
|
CHANGE_FLAG_CARD_INSERT, CHANGE_FLAG_CARD_EJECT,
|
||||||
|
CHANGE_FLAG_RESET_ASSERT, CHANGE_FLAG_RESET_DEASSERT,
|
||||||
|
)
|
||||||
|
if msg['flags'] & (CHANGE_FLAG_CARD_INSERT | CHANGE_FLAG_CARD_EJECT |
|
||||||
|
CHANGE_FLAG_RESET_ASSERT | CHANGE_FLAG_RESET_DEASSERT):
|
||||||
states = {}
|
states = {}
|
||||||
msgs.append(msg)
|
msgs.append(msg)
|
||||||
return msgs
|
return msgs
|
||||||
@@ -3258,13 +3258,28 @@ CHANGE_FLAGS = {
|
|||||||
1 << 4: 'Waiting time timeout',
|
1 << 4: 'Waiting time timeout',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Sniff data error flags (SNIFF_DATA_FLAG_ERROR_*, simtrace_prot.h).
|
||||||
|
DATA_FLAGS = {
|
||||||
|
1 << 5: 'incomplete',
|
||||||
|
1 << 6: 'malformed',
|
||||||
|
1 << 7: 'checksum error',
|
||||||
|
1 << 8: 'overrun',
|
||||||
|
1 << 9: 'framing error',
|
||||||
|
1 << 10: 'parity error',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _flag_names(flags, mapping):
|
||||||
|
names = []
|
||||||
|
for mask, name in sorted(mapping.items()):
|
||||||
|
if flags & mask:
|
||||||
|
names.append(name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
def decode_change(flags):
|
def decode_change(flags):
|
||||||
"""Decode SIMtrace2 sniff_change flags into a list of human-readable names."""
|
"""Decode SIMtrace2 sniff_change flags into a list of human-readable names."""
|
||||||
bits = []
|
bits = _flag_names(flags, CHANGE_FLAGS)
|
||||||
for mask, name in sorted(CHANGE_FLAGS.items()):
|
|
||||||
if flags & mask:
|
|
||||||
bits.append(name)
|
|
||||||
return {
|
return {
|
||||||
'type': 'change',
|
'type': 'change',
|
||||||
'flags_hex': f'{flags:08x}',
|
'flags_hex': f'{flags:08x}',
|
||||||
@@ -3272,6 +3287,14 @@ def decode_change(flags):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_data_flags(flags):
|
||||||
|
"""Decode SIMtrace2 sniff_data error flags into a list of names (or None)."""
|
||||||
|
if not flags:
|
||||||
|
return None
|
||||||
|
names = _flag_names(flags, DATA_FLAGS)
|
||||||
|
return names or None
|
||||||
|
|
||||||
|
|
||||||
def decode_fidi(raw_data):
|
def decode_fidi(raw_data):
|
||||||
"""Decode a SIMtrace2 sniff_fidi payload (1-byte Fi/Di)."""
|
"""Decode a SIMtrace2 sniff_fidi payload (1-byte Fi/Di)."""
|
||||||
if len(raw_data) < 1:
|
if len(raw_data) < 1:
|
||||||
@@ -3514,15 +3537,30 @@ def decode_sniff_msg(raw_data, msg_type, flags=0, prev=None):
|
|||||||
resolving GET RESPONSE.
|
resolving GET RESPONSE.
|
||||||
"""
|
"""
|
||||||
if msg_type == 'tpdu' and raw_data:
|
if msg_type == 'tpdu' and raw_data:
|
||||||
return decode_message(raw_data, prev=prev)
|
result = decode_message(raw_data, prev=prev)
|
||||||
|
if result is not None:
|
||||||
|
errs = decode_data_flags(flags)
|
||||||
|
if errs:
|
||||||
|
result['errors'] = errs
|
||||||
|
return result
|
||||||
if msg_type == 'change':
|
if msg_type == 'change':
|
||||||
return decode_change(flags)
|
return decode_change(flags)
|
||||||
if msg_type == 'fidi' and raw_data:
|
if msg_type == 'fidi' and raw_data:
|
||||||
return decode_fidi(raw_data)
|
return decode_fidi(raw_data)
|
||||||
if msg_type == 'atr':
|
if msg_type == 'atr':
|
||||||
return _decode_atr(raw_data)
|
result = _decode_atr(raw_data)
|
||||||
|
if result is not None:
|
||||||
|
errs = decode_data_flags(flags)
|
||||||
|
if errs:
|
||||||
|
result['errors'] = errs
|
||||||
|
return result
|
||||||
if msg_type == 'pps':
|
if msg_type == 'pps':
|
||||||
return _decode_pps(raw_data)
|
result = _decode_pps(raw_data)
|
||||||
|
if result is not None:
|
||||||
|
errs = decode_data_flags(flags)
|
||||||
|
if errs:
|
||||||
|
result['errors'] = errs
|
||||||
|
return result
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
'session_id': capture.session_id if capture else None,
|
'session_id': capture.session_id if capture else None,
|
||||||
'mode': active_session['mode'] if active_session else None,
|
'mode': active_session['mode'] if active_session else None,
|
||||||
'messages_count': self.db.count_messages(capture.session_id) if capture and capture.session_id else 0,
|
'messages_count': self.db.count_messages(capture.session_id) if capture and capture.session_id else 0,
|
||||||
|
'device_connected': capture.device_connected if capture else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
def _handle_list_sessions(self):
|
def _handle_list_sessions(self):
|
||||||
@@ -258,7 +259,8 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
def _handle_capture_latest(self, params):
|
def _handle_capture_latest(self, params):
|
||||||
after_id = int(params.get('after', [0])[0])
|
after_id = int(params.get('after', [0])[0])
|
||||||
if not self.capture or not self.capture.active:
|
if not self.capture or not self.capture.active:
|
||||||
self._send_json({'messages': [], 'next_after': after_id, 'active': False})
|
self._send_json({'messages': [], 'next_after': after_id, 'active': False,
|
||||||
|
'device_connected': self.capture.device_connected if self.capture else None})
|
||||||
return
|
return
|
||||||
msg_id = self.capture.latest_msg_id
|
msg_id = self.capture.latest_msg_id
|
||||||
messages = self.db.get_messages_after(self.capture.session_id, after_id)
|
messages = self.db.get_messages_after(self.capture.session_id, after_id)
|
||||||
@@ -267,12 +269,14 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
'next_after': msg_id,
|
'next_after': msg_id,
|
||||||
'active': True,
|
'active': True,
|
||||||
'session_id': self.capture.session_id,
|
'session_id': self.capture.session_id,
|
||||||
|
'device_connected': self.capture.device_connected,
|
||||||
})
|
})
|
||||||
|
|
||||||
def _handle_capture_status(self):
|
def _handle_capture_status(self):
|
||||||
self._send_json({
|
self._send_json({
|
||||||
'active': self.capture.active if self.capture else False,
|
'active': self.capture.active if self.capture else False,
|
||||||
'session_id': self.capture.session_id if self.capture else None,
|
'session_id': self.capture.session_id if self.capture else None,
|
||||||
|
'device_connected': self.capture.device_connected if self.capture else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
def _handle_capture_start(self):
|
def _handle_capture_start(self):
|
||||||
|
|||||||
@@ -57,6 +57,36 @@ class TestCaptureManager(unittest.TestCase):
|
|||||||
sn._session = _DisconnectingSession()
|
sn._session = _DisconnectingSession()
|
||||||
self.assertEqual(list(sn.iter_messages()), [])
|
self.assertEqual(list(sn.iter_messages()), [])
|
||||||
|
|
||||||
|
def test_capture_gap_message_inserted(self):
|
||||||
|
# A 'gap' marker yielded by the backend is stored as a message.
|
||||||
|
mgr, db = self._manager(_FakeBackend([
|
||||||
|
('gap', b'', 0),
|
||||||
|
('tpdu', b'\x80\xf2\x00\x00\x00', 0),
|
||||||
|
]))
|
||||||
|
sid = mgr.start_session()
|
||||||
|
self.assertEqual(mgr.stop_session(), sid)
|
||||||
|
msgs = db.get_messages(sid)
|
||||||
|
self.assertEqual([m['type'] for m in msgs], ['gap', 'tpdu'])
|
||||||
|
|
||||||
|
def test_direct_sniffer_connected(self):
|
||||||
|
from simtrace2_pysniff.server.capture import DirectSniffer
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
connected = True
|
||||||
|
|
||||||
|
sn = DirectSniffer()
|
||||||
|
sn._session = _FakeSession()
|
||||||
|
self.assertTrue(sn.connected)
|
||||||
|
_FakeSession.connected = False
|
||||||
|
self.assertFalse(sn.connected)
|
||||||
|
|
||||||
|
def test_device_connected_gsmtap_none(self):
|
||||||
|
from simtrace2_pysniff.server.capture import GsmtapListener
|
||||||
|
listener = GsmtapListener(bind_port=0)
|
||||||
|
self.addCleanup(listener.stop)
|
||||||
|
mgr, _ = self._manager(listener)
|
||||||
|
self.assertIsNone(mgr.device_connected)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -149,6 +149,19 @@ class TestChangeAndFidiDecoding(unittest.TestCase):
|
|||||||
self.assertEqual(r['fi_val'], 512)
|
self.assertEqual(r['fi_val'], 512)
|
||||||
self.assertEqual(r['di_val'], 64)
|
self.assertEqual(r['di_val'], 64)
|
||||||
|
|
||||||
|
def test_tpdu_error_flags_surfaced(self):
|
||||||
|
r = decode_sniff_msg(bytes.fromhex('00a40000023f009000'), 'tpdu',
|
||||||
|
flags=(1 << 7) | (1 << 6))
|
||||||
|
self.assertEqual(r['errors'], ['malformed', 'checksum error'])
|
||||||
|
|
||||||
|
def test_tpdu_no_error_flags(self):
|
||||||
|
r = decode_sniff_msg(bytes.fromhex('00a40000023f009000'), 'tpdu', flags=0)
|
||||||
|
self.assertNotIn('errors', r)
|
||||||
|
|
||||||
|
def test_atr_error_flags_surfaced(self):
|
||||||
|
r = decode_sniff_msg(bytes.fromhex('3b00'), 'atr', flags=(1 << 5))
|
||||||
|
self.assertEqual(r['errors'], ['incomplete'])
|
||||||
|
|
||||||
|
|
||||||
class TestTerminalResponse(unittest.TestCase):
|
class TestTerminalResponse(unittest.TestCase):
|
||||||
def test_tr_setup_event_list(self):
|
def test_tr_setup_event_list(self):
|
||||||
@@ -1192,6 +1205,49 @@ class TestSelectionTracking(unittest.TestCase):
|
|||||||
msgs = db.get_messages(sid)
|
msgs = db.get_messages(sid)
|
||||||
self.assertNotIn('file', msgs[2]['decoded'])
|
self.assertNotIn('file', msgs[2]['decoded'])
|
||||||
|
|
||||||
|
def test_timeout_wt_keeps_selection(self):
|
||||||
|
# A waiting-time timeout interrupts one TPDU but the card stays
|
||||||
|
# selected, so the selection must not be reset.
|
||||||
|
import tempfile
|
||||||
|
from simtrace2_pysniff.server.database import Database
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
|
||||||
|
db = Database(tmp.name)
|
||||||
|
sid = db.create_session('capture')
|
||||||
|
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('a0a40804047fff6f079000'))
|
||||||
|
db.insert_message(sid, 0.1, 'change', b'', 1 << 4) # TIMEOUT_WT
|
||||||
|
db.insert_message(sid, 0.2, 'tpdu',
|
||||||
|
bytes.fromhex('00b0000009') + bytes.fromhex('082905102143658709') + bytes.fromhex('9000'))
|
||||||
|
msgs = db.get_messages(sid)
|
||||||
|
self.assertEqual(msgs[2]['decoded']['file']['imsi'], '250011234567890')
|
||||||
|
|
||||||
|
def test_reset_assert_resets_selection(self):
|
||||||
|
import tempfile
|
||||||
|
from simtrace2_pysniff.server.database import Database
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
|
||||||
|
db = Database(tmp.name)
|
||||||
|
sid = db.create_session('capture')
|
||||||
|
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('a0a40804047fff6f079000'))
|
||||||
|
db.insert_message(sid, 0.1, 'change', b'', 1 << 2) # RESET_ASSERT
|
||||||
|
db.insert_message(sid, 0.2, 'tpdu',
|
||||||
|
bytes.fromhex('00b0000009') + bytes.fromhex('082905102143658709') + bytes.fromhex('9000'))
|
||||||
|
msgs = db.get_messages(sid)
|
||||||
|
self.assertNotIn('file', msgs[2]['decoded'])
|
||||||
|
|
||||||
|
def test_gap_resets_selection(self):
|
||||||
|
# A capture gap (device disconnected/reconnected) means messages may
|
||||||
|
# have been missed, so the selection must be reset.
|
||||||
|
import tempfile
|
||||||
|
from simtrace2_pysniff.server.database import Database
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
|
||||||
|
db = Database(tmp.name)
|
||||||
|
sid = db.create_session('capture')
|
||||||
|
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('a0a40804047fff6f079000'))
|
||||||
|
db.insert_message(sid, 0.1, 'gap', b'', 0)
|
||||||
|
db.insert_message(sid, 0.2, 'tpdu',
|
||||||
|
bytes.fromhex('00b0000009') + bytes.fromhex('082905102143658709') + bytes.fromhex('9000'))
|
||||||
|
msgs = db.get_messages(sid)
|
||||||
|
self.assertNotIn('file', msgs[2]['decoded'])
|
||||||
|
|
||||||
def test_failed_select_keeps_selection(self):
|
def test_failed_select_keeps_selection(self):
|
||||||
# A SELECT that fails (6A82) must not change the current file.
|
# A SELECT that fails (6A82) must not change the current file.
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ class TestVersion(unittest.TestCase):
|
|||||||
class FakeCapture:
|
class FakeCapture:
|
||||||
active = False
|
active = False
|
||||||
session_id = None
|
session_id = None
|
||||||
|
device_connected = None
|
||||||
|
|
||||||
class Fake(RequestHandler):
|
class Fake(RequestHandler):
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
|
|||||||
Reference in new issue
Block a user