Files
sigrok_iso7816_stream/tools/detect_pins.py
T
catarrh d3715c9385 v1.5.0: invalid-INS one-byte re-frame + explicit discard logging
Residual CONCAT mis-frames (4gmodem/A55_sim2/S21p_sim2) traced to a silent
5-byte drop at an invalid-INS T=0 header (0x0f 00 a4 08 04). Drop only the
stray leading byte and re-frame the remaining four via _replay so the real
command (00 a4 08 04 02 2f 00 -> 61 1c) is recovered.

- _note_discard()/_consume_first(): every consumed-but-unemitted byte is
  now logged; idle 0x00/0xFF discards are rate-limited, real data always
  gets an ANN_WARN annotation.
- All read-until-SW loops log skipped non-SW bytes.
- All traces: CONCAT 0; BAD_FCS/GARBAGE 0; test_8/test_7 RESULT OK;
  47/47 unit tests; baseline byte-identical.

History rewritten to a single root commit so no example trace is stored
in the repo (fixtures are local-only, gitignored). README keeps only
generic <trace>.sr placeholders; AGENTS.md retains the fixture names.
2026-09-09 15:51:09 +03:00

1087 lines
44 KiB
Python

#!/usr/bin/env python3
"""Auto-detect which FX2 channels carry the SIM signals (CLK / I-O / RST / VCC).
Reads an existing sigrok .sr capture, or records a short live one, then
classifies every channel and prints the wiring plus a ready-to-paste
start.sh command line.
Usage:
python3 tools/detect_pins.py capture.sr [--max-samples=N]
python3 tools/detect_pins.py --live [--samplerate=12M] [--seconds=2]
Requires: python3 + numpy. The probe window should cover a phone power-
on or card reset so VCC/RST/DATA show their activation ordering; static
channels are reported honestly as ambiguous instead of guessed.
Clock Stop Mode (phones gate or stop CLK between transactions) is handled:
CLK is detected from its *local* period and burst density, not the average
edge rate, so a gated clock is still found, and a stopped-high clock is not
mistaken for a supply line.
"""
import argparse
import configparser
import re
import subprocess
import sys
import tempfile
import time
import zipfile
import numpy as np
# Evidence-capture tunables ----------------------------------------------
KEEP_TRANS = 512 # transition timestamps kept per channel
MAX_ONSETS = 64 # burst onsets kept per channel
ONSET_SILENCE_SEC = 0.2 # quiet time that marks a DATA burst onset
IDLE_SILENCE_SEC = 0.05 # span with NO transitions on any pin = idle
# (Clock Stop Mode); excluded from high% so a
# gated clock reads ~50% duty during bursts.
RESET_WIN_BEFORE = 0.150 # RST pulse lead vs burst onset (seconds)
RESET_WIN_AFTER = 0.020
# ---------------------------------------------------------------- input --
def parse_rate(s):
'''Accept both .sr metadata spellings ("25 MHz") and CLI shorthand
("12M", "400kHz", bare "1000"). Case-insensitive; 'm' means mega.'''
m = re.fullmatch(r'\s*([\d.]+)\s*([GMk])?\s*(?:Hz)?\s*', s,
re.IGNORECASE)
if not m:
raise ValueError('bad samplerate string: %r' % s)
unit = m.group(2).lower() if m.group(2) else ''
mult = {'g': 1e9, 'm': 1e6, 'k': 1e3, '': 1}[unit]
return int(float(m.group(1)) * mult)
def load_sr(path, max_samples=None):
"""Return (samples uint8 array, samplerate, n_channels).
Members are streamed into a growing bytearray so multi-GB captures
never double in memory; max_samples bounds the result."""
zf = zipfile.ZipFile(path)
meta = configparser.ConfigParser()
meta.read_string(zf.read('metadata').decode())
sec = meta['device 1']
nch = int(sec['total probes'])
unitsize = int(sec.get('unitsize', 1))
if unitsize != 1:
# 16-bit profiles (9/16-channel sticks) are exactly what this
# tool exists to avoid -- generic 8-channel dongles stream
# unitsize=1.
raise ValueError('unsupported unitsize %d (need an 8-channel '
'unitsize=1 capture)' % unitsize)
samplerate = parse_rate(sec['samplerate'])
names = sorted((n for n in zf.namelist()
if re.fullmatch(r'logic-\d+-\d+', n)),
key=lambda n: int(n.split('-')[-1]))
buf = bytearray()
limit = None if max_samples is None else max_samples * unitsize
for n in names:
buf.extend(zf.read(n))
if limit is not None and len(buf) >= limit:
break
if limit is not None and len(buf) > limit:
del buf[limit:]
usable = len(buf) // unitsize * unitsize
arr = np.frombuffer(bytes(buf[:usable]), dtype=np.uint8)
return arr, samplerate, nch
# ------------------------------------------------------------ features ----
class ChannelAcc:
"""Incremental per-channel statistics over arbitrary chunk sizes.
Tracks: edge count, high ratio, first rise, last transition, and a
histogram of inter-transition gaps capped at GAP_CAP samples. The
capped-histogram median is what makes clock detection immune to
gating: gating adds a FEW enormous gaps which land in the overflow
bucket and never move the median away from half a clock period."""
GAP_CAP = 4096
def __init__(self, samplerate):
self.sr = samplerate
self.edges = 0
self.rises = 0
self.high = 0
self.total = 0
self.first_t = None # first transition (any direction)
self.first_rise = None # in samples
self.last_t = None # in samples
self._last = None
self.fr_idx = None # global index of first rise
self.post_n = 0 # samples at/after first rise
self.post_high = 0 # high samples at/after first rise
self.hist = np.zeros(self.GAP_CAP + 1, dtype=np.int64)
self.trans_kept = [] # first transition indices (samples)
self.onsets = [] # burst onsets (samples)
self._onset_gap = int(ONSET_SILENCE_SEC * samplerate)
def feed(self, x, base):
'''x: uint8 0/1 slice; base: global index of x[0].'''
n = len(x)
if n == 0:
return
if self._last is None:
self._last = int(x[0])
y = np.empty(n + 1, dtype=np.uint8)
y[0] = self._last
y[1:] = x
self._last = int(y[-1])
self.total += n
self.high += int(x.sum())
tt = np.nonzero(y[1:] != y[:-1])[0] # offsets into y[1:]
if len(tt) == 0:
# Even without transitions the post-rise window may cover us.
if self.fr_idx is not None:
self.post_n += n
self.post_high += int(x.sum())
return
g = base + tt # global positions
lv = y[1:][tt] # new levels
if len(self.trans_kept) < KEEP_TRANS:
need = KEEP_TRANS - len(self.trans_kept)
self.trans_kept.extend(int(v) for v in g[:need])
if len(self.onsets) < MAX_ONSETS:
# First transition of the stream counts as an onset too:
# a capture that starts right at a burst still anchors.
if self.last_t is None or \
(g[0] - self.last_t) >= self._onset_gap:
self.onsets.append(int(g[0]))
if len(g) > 1:
gg = np.diff(g)
for p in np.nonzero(gg >= self._onset_gap)[0]:
if len(self.onsets) < MAX_ONSETS:
self.onsets.append(int(g[p + 1]))
if self.first_t is None:
self.first_t = int(g[0])
rise_pos = np.nonzero(lv == 1)[0]
self.rises += len(rise_pos)
if self.fr_idx is None and len(rise_pos):
self.fr_idx = int(g[rise_pos[0]])
self.first_rise = self.fr_idx
# Gap series: chained across chunks AND within a chunk.
chain = g if self.last_t is None else \
np.concatenate((np.array([self.last_t], dtype=g.dtype), g))
if len(chain) >= 2:
gaps = np.minimum(np.diff(chain), self.GAP_CAP)
self.hist += np.bincount(gaps,
minlength=self.GAP_CAP + 1)
self.last_t = int(g[-1])
self.edges += len(g)
# Post-first-rise level accounting (split at the rise sample).
if self.fr_idx is not None:
k = min(max(self.fr_idx - base, 0), n)
self.post_n += (n - k)
self.post_high += int(x[k:].sum())
def stats(self):
tot = int(self.hist.sum())
med_gap = None
short_edges = None
long_edges = None
if tot:
cum = np.cumsum(self.hist)
med_gap = int(np.searchsorted(cum, (tot + 1) // 2))
# Burst density: count of inter-transition gaps that sit at (or
# below) twice the median, i.e. inside a real clock burst. Gating
# / Clock Stop Mode only adds a FEW enormous gaps (they land in the
# GAP_CAP overflow bucket), so the short/long split cleanly
# separates a gated clock's bursts from its idle stretches.
hi = min(med_gap * 2, self.GAP_CAP)
short_edges = int(cum[hi])
long_edges = tot - short_edges
return {
'edges': self.edges,
'rate': self.edges * self.sr / max(self.total, 1),
'high': self.high / max(self.total, 1),
'median_gap': med_gap,
'short_edges': short_edges,
'long_edges': long_edges,
'rises': self.rises,
'falls': self.edges - self.rises,
'post_duty': (self.post_high / self.post_n
if self.post_n else None),
'first_t': (self.first_t / self.sr
if self.first_t is not None else None),
'first_rise': (self.first_rise / self.sr
if self.first_rise is not None else None),
'last_act': (self.last_t / self.sr
if self.last_t is not None else None),
'trans': list(self.trans_kept),
'onsets': list(self.onsets),
}
# ----------------------------------------------------------- classifier ---
def classify(stats_list, samplerate, duration):
'''Return dict ch -> (verdict, confidence), plus notes list.'''
verdicts = {}
notes = []
rates = [s['rate'] for s in stats_list]
# CLK: the channel whose *local* toggling period implies a running clock
# (>= 1 MHz) and which actually clocks for a real stretch (enough
# short-period edges to be a burst, not one isolated transition). Gating /
# Clock Stop Mode inserts long idle gaps that only lower the *average* edge
# rate, so we score on the local period and burst density (median gap ->
# local_freq, plus short_edges from the gap histogram) rather than the
# average rate. A gated CLK is still detected this way.
def _local_freq(s):
med = s['median_gap']
if not med:
return 0.0
return samplerate / (2 * med)
clk = None
clock_chs = set()
for c, s in enumerate(stats_list):
if _local_freq(s) >= 1e6 and (s['short_edges'] or 0) >= 32:
clock_chs.add(c)
if clock_chs:
# The genuine CLK is the one with the most burst edges; any other
# clock-frequency channel is an alias/glitch and must not be claimed
# as VCC/RST below.
clk = max(clock_chs,
key=lambda c: stats_list[c].get('short_edges', 0))
verdicts[clk] = ('CLK', 'high')
clock_chs.discard(clk)
if clk is None:
notes.append('no channel shows a dominant local clock (>= 1 MHz) - '
'is the SIM CLK running (even if gated between bursts)?')
# Fast-toggling channels (clocks or dense data) can never be a supply or
# reset line; a stopped-high CLK would otherwise read as an always-high
# supply. Drop them from the VCC/RST candidate pools.
def _is_signal_line(c):
return c in clock_chs or c == clk or stats_list[c]['edges'] >= 200
rest = [c for c in range(len(stats_list))
if c != clk and c not in clock_chs]
def quasi(c):
s = stats_list[c]
return s['edges'] <= max(50, duration * 100)
quiet = [c for c in rest if quasi(c)]
busy = [c for c in rest if c not in quiet]
# DATA: busiest remaining channel, expected idle-high. Use the
# active-only high ratio so a gated clock's idle stretches do not drag
# the duty reading down.
data = None
if busy:
data = max(busy, key=lambda c: stats_list[c]['rate'])
h = stats_list[data].get('high_active', stats_list[data]['high'])
if h >= 0.80:
conf = 'high'
elif h >= 0.50:
# Heavy traffic legitimately pulls the idle ratio down.
conf = 'medium'
else:
conf = 'low'
notes.append('DATA candidate reads mostly LOW '
'(%.0f%% high) - stuck line?' % (h * 100))
verdicts[data] = ('DATA', conf)
if data is None:
notes.append('no I/O traffic seen - check DATA probe contact, '
'or no SIM init happened inside the window')
# VCC vs RST among quiet channels, anchored to DATA-traffic onset.
# Floating/unconnected pins pick up coupled glitches whose recorded
# rise is often EARLIER than the real activation events (noise starts
# as soon as the clock runs), so raw 'earliest rise' ordering is not
# trustworthy. Instead each transitioning candidate is fingerprinted:
# VCC -- rises once and stays high afterwards (post-duty >= 90 %,
# <= 2 falls)
# RST -- pulsed: brief excursion returning to idle, timed just
# before the DATA burst (delta in [-0.35 s, +0.05 s])
# noise-- everything else: sparse blips scattered across the window,
# or excursions nowhere near the traffic anchor.
t_ref = stats_list[data]['first_t'] if data is not None else None
onsets = stats_list[data]['onsets'] if data is not None else []
trans = [c for c in quiet if stats_list[c]['edges'] > 0
and not _is_signal_line(c)]
shigh = [c for c in quiet if c not in trans and stats_list[c]['high'] >= 0.90
and not _is_signal_line(c)]
slow = [c for c in quiet if c not in trans and c not in shigh]
pools = {'trans': list(trans), 'shigh': list(shigh),
'slow': list(slow), 'pulsed': [], 'sticky': []}
def mark_ambig(lst, label):
names = ', '.join(ch_name(c) for c in sorted(lst))
notes.append('%s ambiguous (%s) - swap-test needed' % (label, names))
for c in lst:
verdicts[c] = (label, 'ambiguous')
def feats(c):
s = stats_list[c]
delta = None
if s['first_rise'] is not None and t_ref is not None:
delta = s['first_rise'] - t_ref
span = 0.0
if s['first_rise'] is not None and s['last_act'] is not None:
span = max(s['last_act'] - s['first_rise'], 0.0)
return {'delta': delta, 'post': s['post_duty'],
'falls': s['falls'], 'span': span}
assigned_vcc = False
if t_ref is not None and trans:
pulsed, sticky, noise = [], [], []
for c in trans:
s = stats_list[c]
f = feats(c)
d, post, fl, sp = f['delta'], f['post'], f['falls'], f['span']
# Supply lines (and floating-high pins) are mostly-high with
# tiny dips -- never call them scatter-noise; they compete
# via pulse evidence / interactive confirmation instead.
supply_like = (post is not None and post >= 0.90) or \
(post is None and
s.get('high_active', s['high']) >= 0.95)
corr, _hits = reset_correlations(s['trans'], onsets,
samplerate)
scatter = s['edges'] >= 6 and sp > 0.5 * duration
far = d is not None and abs(d) > 1.5
if corr > 0:
pulsed.append((-corr, s['edges'], c))
f['corr'] = corr
elif supply_like:
sticky.append((s['edges'],
stats_list[c]['first_rise'] or 0, c))
f['corr'] = corr
else:
noise.append((c, dict(f, corr=corr)))
pulsed.sort(key=lambda t: (t[0], t[1], t[2]))
pools['pulsed'] = [c for _co, _e, c in pulsed]
if pulsed:
top = pulsed[0]
# Single-segment pulse hits stay medium confidence --
# probe_loop corroborates across segments.
verdicts[top[2]] = ('RST', 'medium')
notes.append('RST evidence: %s (%d reset-correlated '
'pulse(s), edges=%d)'
% (ch_name(top[2]), -top[0], top[1]))
sticky.sort(key=lambda t: (t[0], t[2]))
pools['sticky'] = [c for _e, _r, c in sticky]
if sticky:
vcc = sticky[0][2]
verdicts[vcc] = ('VCC', 'high')
assigned_vcc = True
notes.append('VCC evidence: %s (fewest edges among '
'always-high lines: %d)'
% (ch_name(vcc), sticky[0][0]))
noise_chs = {c for c, _f in noise}
assigned = {pulsed[0][2]} if pulsed else set()
if sticky:
assigned.add(sticky[0][2])
for c in trans:
if c not in assigned and c not in noise_chs:
verdicts.setdefault(c, ('-', ''))
for c, f in noise[:4]:
dd = ('%+.2fs' % f['delta']) if f['delta'] is not None else '--'
p = ('%.0f%%' % (f['post'] * 100)) if f['post'] is not None else '--'
extra = ', %d reset-correlated' % f['corr'] if f['corr'] else ''
notes.append('%s looks like noise (edges=%d, Δt_ref=%s, '
'post-rise duty=%s%s) - ignored'
% (ch_name(c), stats_list[c]['edges'], dd, p,
extra))
for c in shigh + slow:
verdicts.setdefault(c, ('-', ''))
elif shigh:
# No transitioning supply candidate in this window: a static-high
# line could be the (already-powered) VCC or a floating pin, and
# the two are indistinguishable without a power-on transition.
# Never assert VCC from an edge-free line alone -- mark it
# ambiguous so only transition-anchored (sticky) VCC evidence
# reaches the cross-segment consensus.
mark_ambig(shigh, 'VCC')
for c in slow:
verdicts[c] = ('-', '')
notes.append('no VCC/RST transitions seen - trigger a card reset '
'during the probe window for reliable separation')
else:
for c in quiet:
verdicts[c] = ('-', '')
notes.append('no pulled-up lines seen - was the SIM powered '
'during the probe window?')
return verdicts, notes, pools
def ch_name(c):
return 'D%d' % c if c < 8 else 'A%d' % (c - 8)
def reset_correlations(trans, onsets, samplerate):
'''Count DATA burst onsets whose preceding pulse excursion on this
channel starts inside [RESET_WIN_BEFORE, RESET_WIN_AFTER] seconds
relative to the onset AND CLOSES near it (within +100 ms). The
closing requirement excludes supply-like lines whose single rise
precedes traffic and never returns -- those are VCC candidates, not
reset pulses. Orientation-agnostic: any consecutive transition
pair is treated as one excursion. Returns (matched_onsets,
matched_positions).'''
lo = -int(RESET_WIN_BEFORE * samplerate)
hi = int(RESET_WIN_AFTER * samplerate)
close_hi = int(0.100 * samplerate)
hits = []
for o in onsets:
for k in range(len(trans) - 1):
d_start = trans[k] - o
if lo <= d_start <= hi:
d_end = trans[k + 1] - o
if abs(d_end) <= close_hi:
hits.append((o, trans[k]))
break
return len(hits), hits
# --------------------------------------------------------------- report ---
def format_suggestion(verdicts):
'''Return (mapping_lines, suggestion_line_or_None).
The suggestion covers whatever subset was identified; unknown
signals render as '?' and a partial start.sh command is still
emitted so capture can begin on the known channels.'''
got = {}
for c, (v, conf) in sorted(verdicts.items()):
base = v.split()[0]
if base in SIGNALS and conf != 'ambiguous' and base not in got:
got[base] = ch_name(c)
order = ['CLK', 'DATA', 'RST', 'VCC']
missing = [s for s in order if s not in got]
lines = ['Detected pin mapping:' if got else 'No signals identified:']
for s in order:
lines.append(' %-4s -> %s' % (s, got.get(s, '?')))
if not missing:
cmd = './start.sh --clk=%s --data=%s --rst=%s --vcc=%s' % (
got['CLK'], got['DATA'], got['RST'], got['VCC'])
elif got:
flags = ''.join(' --%s=%s' % (s.lower(), got[s])
for s in order if s in got)
cmd = 'Partial: ./start.sh%s (%s unidentified)' % (
flags, '/'.join(missing))
else:
cmd = None
return lines, cmd
def report(stats_list, samplerate, verdicts, notes, show_suggestion=True):
print('CH edges rate/s idle%% high%%* first-rise verdict')
for c, st in enumerate(stats_list):
v, conf = verdicts.get(c, ('-', ''))
fr = '%7.3fs' % st['first_rise'] if st['first_rise'] is not None \
else ' --'
h = st.get('high_active', st.get('high', 0.0))
idle = st.get('idle_frac', 0.0)
tag = v if not conf else '%s (%s)' % (v, conf)
print('%-4s %6d %9.0f %5.1f%% %5.1f%% %s %s'
% (ch_name(c), st['edges'], st['rate'], idle * 100, h * 100,
fr, tag))
print(' * high%% is over active (non-idle) time; idle = no transitions '
'on any pin')
print()
for n_ in notes:
print('note: %s' % n_)
if show_suggestion:
lines, cmd = format_suggestion(verdicts)
for ln in lines:
print(ln)
if cmd:
print('\nSuggested: %s' % cmd)
else:
print('\nAdjust probes/re-plug and re-run.')
CHUNK = 32_000_000
def _active_mask(trans, n, samplerate):
'''Bool array marking samples that are NOT in a long idle span.
A span with zero transitions on *any* channel longer than
IDLE_SILENCE_SEC is idle (Clock Stop Mode / no signal on any pin);
those samples are marked False so they are excluded from high% and
duty calculations. The pre-first and post-last spans are idle too.'''
active = np.ones(n, dtype=bool)
if len(trans) == 0:
return active
# Prefix before the first transition and suffix after the last.
active[:trans[0] + 1] = False
active[trans[-1]:] = False
if len(trans) < 2:
return active
gaps = np.diff(trans)
thr = int(IDLE_SILENCE_SEC * samplerate)
long = np.nonzero(gaps > thr)[0]
starts = trans[:-1] + 1
ends = trans[1:]
for k in long:
active[starts[k]:ends[k]] = False
return active
def _accumulate(arr, samplerate, nch):
accs = [ChannelAcc(samplerate) for _ in range(nch)]
for s in range(0, len(arr), CHUNK):
chunk = arr[s:s + CHUNK]
for c, acc in enumerate(accs):
acc.feed((chunk >> c) & 1, s)
# Global activity: transitions on ANY channel (any bit of the bus).
n = len(arr)
changed = np.empty(n, dtype=bool)
changed[0] = False
changed[1:] = arr[1:] != arr[:-1]
trans = np.nonzero(changed)[0]
active = _active_mask(trans, n, samplerate)
active_total = int(active.sum())
# Per-channel high count over active (non-idle) samples only.
high_active = [0] * nch
if active_total:
for s in range(0, n, CHUNK):
chunk = arr[s:s + CHUNK]
a = active[s:s + len(chunk)]
for c in range(nch):
bit = (chunk >> c) & 1
high_active[c] += int((bit & a).sum())
stats = [a.stats() for a in accs]
idle_frac = (1.0 - active_total / n) if n else 0.0
for c, st in enumerate(stats):
st['high_active'] = (high_active[c] / active_total) if active_total \
else 0.0
st['idle_frac'] = idle_frac
return stats
def classify_capture(arr, samplerate, nch):
stats_list = _accumulate(arr, samplerate, nch)
verdicts, notes, pools = classify(stats_list, samplerate,
len(arr) / samplerate)
return {'verdicts': verdicts, 'notes': notes, 'pools': pools,
'stats_list': stats_list,
'duration': len(arr) / samplerate}
def analyse(arr, samplerate, nch):
res = classify_capture(arr, samplerate, nch)
report(res['stats_list'], samplerate, res['verdicts'], res['notes'])
# ----------------------------------------------------------------- live ---
SIGNALS = ('CLK', 'VCC', 'RST', 'DATA')
CONF_RANK = {'low': 0, 'medium': 1, 'high': 2}
def mapping_score(verdicts):
'''Higher is better: one point per solid signal, confidence bonus,
ambiguity never counts.'''
score = 0
for _c, (v, conf) in verdicts.items():
base = v.split()[0]
if base in SIGNALS and conf != 'ambiguous':
score += 1 + 0.25 * CONF_RANK.get(conf, 0)
return score
def summary_line(verdicts, notes):
parts = []
for sig in SIGNALS:
best = None
amb = False
for _c, (v, conf) in verdicts.items():
if v.split()[0] != sig:
continue
if conf == 'ambiguous':
amb = True
elif best is None or CONF_RANK[conf] > CONF_RANK[best]:
best = conf
if best:
parts.append('%s ✓(%s)' % (sig, best))
elif amb:
parts.append('%s ?(ambiguous)' % sig)
else:
parts.append('%s ✗' % sig)
line = ' '.join(parts)
if any('no I/O traffic' in n_ for n_ in notes):
line += ' (no traffic seen on any line)'
return line
def countdown():
print('Trigger a SIM reset (or reboot) on the phone now. '
'Ctrl+C stops early.')
for i in reversed(range(3)):
print(' %d...' % (i + 1), flush=True)
time.sleep(1)
def capture_live(samplerate_str='12M', seconds=2.0):
with tempfile.NamedTemporaryFile(suffix='.sr', delete=False) as tf:
out = tf.name
# sigrok-cli 0.7.x spells the window options --time/--samples/--frames
# (there is no --limit-samples); --time matches the guided-flow
# contract directly.
cmd = ['sigrok-cli', '-d', 'fx2lafw',
'--config', 'samplerate=%s' % samplerate_str,
'--time', str(int(seconds * 1000)),
'-C', 'D0,D1,D2,D3,D4,D5,D6,D7',
'-O', 'srzip',
'-o', out]
print('$ ' + ' '.join(cmd))
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
if e.returncode < 0:
raise KeyboardInterrupt from None # user break during capture
sys.exit('capture failed: exit status %s' % e.returncode)
except FileNotFoundError as e:
sys.exit('capture failed: %s' % e)
print('Probe saved: %s (inspect in PulseView if the mapping looks wrong)' % out)
return out
def resolve_signal(verdicts_seq, sig):
'''Most recent non-ambiguous channel assigned to sig, or None.'''
for verdicts in reversed(verdicts_seq):
for c, (v, conf) in verdicts.items():
if v.split()[0] == sig and conf != 'ambiguous':
return c
return None
def _vcc_consensus(seg_verdicts, exclude):
'''Return the pin consistently classified as VCC across segments, or None.
A pin must have been assigned VCC by per-segment classify() in a strict
majority of the segments that produced any VCC (and at least 2 such
segments), and must not be CLK/DATA/RST or user-rejected. A floating pin
is spurious and flaps between channels, so it never clears this bar: the
result is either the genuine rail or nothing (honest unknown). This
deliberately ignores the edge-free static-high pool -- floating pins are
the most static lines and used to win VCC precisely because they carry no
signal.'''
votes = {}
any_vcc = 0
for vd in seg_verdicts:
picks = {c for c, (v, conf) in vd.items()
if v.split()[0] == 'VCC' and conf != 'ambiguous'
and c not in exclude}
if picks:
any_vcc += 1
for c in picks:
votes[c] = votes.get(c, 0) + 1
if any_vcc < 2 or not votes:
return None
best = max(votes, key=lambda c: (votes[c], -c))
if votes[best] * 2 <= any_vcc:
return None
return best
def ask_confirm(sig, ch, evidence):
'''Interactive confirmation. Returns True (accepted), False
(rejected), or None when stdin is not a tty (non-interactive).'''
if not sys.stdin.isatty():
return None
if ch is None:
return None
while True:
try:
ans = input('Confirm %s -> %s? %s[Y/n] '
% (sig, ch_name(ch), evidence)).strip().lower()
except EOFError:
return None
if ans in ('', 'y', 'yes'):
return True
if ans in ('n', 'no'):
return False
def ask_confirm_map(verdicts):
'''Single Y/n confirm of the whole pin mapping. Returns True (accepted),
False (rejected), or None when stdin is not a tty (non-interactive).'''
if not sys.stdin.isatty():
return None
while True:
try:
ans = input('Accept this pin mapping? [Y/n] ').strip().lower()
except EOFError:
return None
if ans in ('', 'y', 'yes'):
return True
if ans in ('n', 'no'):
return False
def probe_loop(samplerate_str='24M', seg_seconds=3.0, max_segments=20):
sr_int = parse_rate(samplerate_str)
cap = int(sr_int * seg_seconds * 1.1)
print('Probe: %s, all channels, %.1f s segments, up to %d tries.'
% (samplerate_str, seg_seconds, max_segments))
countdown()
best = None
agg = None # ch -> {'edges', 'rst'}
seg_verdicts = [] # per-segment verdict dicts, in order
rejected = {'RST': set(), 'VCC': set()}
try:
for seg in range(1, max_segments + 1):
print('\n=== segment %d ===' % seg)
path = capture_live(samplerate_str, seg_seconds)
arr, srate, nch = load_sr(path, cap)
if len(arr) >= cap:
print('note: segment truncated at %d samples '
'(raise --max-samples or lower --seconds)'
% len(arr))
res = classify_capture(arr, srate, nch)
res['score'] = mapping_score(res['verdicts'])
seg_verdicts.append(res['verdicts'])
print(summary_line(res['verdicts'], res['notes']))
if best is None or res['score'] >= best['score']:
best = dict(res, srate=srate, nsamp=len(arr), seg=seg)
# ---- cross-segment evidence aggregation ----
if agg is None:
agg = {c: {'edges': 0, 'rst': 0} for c in range(nch)}
pools = res['pools']
for c in agg:
agg[c]['edges'] += res['stats_list'][c]['edges']
for c in pools.get('pulsed', []):
agg[c]['rst'] += 1
clk_ch = resolve_signal(seg_verdicts, 'CLK')
data_ch = resolve_signal(seg_verdicts, 'DATA')
taken = {clk_ch, data_ch}
# Ranked candidates from aggregated evidence.
rst_ranked = sorted(
(c for c, a in agg.items()
if a['rst'] > 0 and c not in taken
and c not in rejected['RST']),
key=lambda c: (-agg[c]['rst'], agg[c]['edges']))
# Early finalize: once CLK and DATA are both resolved we stop
# probing. RST/VCC are optional for decoding (RST only enables
# warm-reset re-arm; VCC is informational), so we do not keep
# hunting for them -- we finalize with whichever were found.
if clk_ch is not None and data_ch is not None:
rst_top = rst_ranked[0] if rst_ranked else None
vcc_top = _vcc_consensus(
seg_verdicts,
(taken | rejected['VCC']
| ({rst_top} if rst_top is not None else set())))
final = {}
final[clk_ch] = ('CLK', 'high')
final[data_ch] = ('DATA', 'high')
if rst_top is not None:
final[rst_top] = ('RST', 'medium')
if vcc_top is not None:
final[vcc_top] = ('VCC', 'low')
lines, cmd = format_suggestion(final)
for ln in lines:
print(ln)
if cmd:
print('\nSuggested: %s' % cmd)
if not sys.stdin.isatty():
print('CLK+DATA resolved (headless) - stopping.')
return final
ok = ask_confirm_map(final)
if ok is False:
print('Mapping rejected - re-probing...', flush=True)
continue
print('\nMapping accepted at segment %d.' % seg)
return final
if seg < max_segments:
print('Trigger reset again if needed...', flush=True)
time.sleep(1)
except KeyboardInterrupt:
print('\n(stopped by user)')
if best is None or agg is None:
sys.exit('no usable segments captured')
# ---- final resolution -------------------------------------------------
final = {}
open_q = []
clk_ch = resolve_signal(seg_verdicts, 'CLK')
data_ch = resolve_signal(seg_verdicts, 'DATA')
if clk_ch is not None:
final[clk_ch] = ('CLK', 'high')
else:
open_q.append('CLK unidentified')
if data_ch is not None:
final[data_ch] = ('DATA', 'high')
else:
open_q.append('DATA unidentified')
taken = {clk_ch, data_ch}
rst_ranked = sorted((c for c, a in agg.items()
if a['rst'] > 0 and c not in taken
and c not in rejected['RST']),
key=lambda c: (-agg[c]['rst'], agg[c]['edges']))
if rst_ranked:
final[rst_ranked[0]] = ('RST', 'medium')
open_q.append('RST candidate %s (%d pulse segment(s)) - verify'
% (ch_name(rst_ranked[0]), agg[rst_ranked[0]]['rst']))
else:
open_q.append('RST never pulsed - trigger resets inside segments')
vcc_top = _vcc_consensus(
seg_verdicts,
(taken | rejected['VCC']
| ({rst_ranked[0]} if rst_ranked else set())))
if vcc_top is not None:
final[vcc_top] = ('VCC', 'low')
open_q.append('VCC candidate %s (consistent across segments) - verify'
% ch_name(vcc_top))
else:
open_q.append('VCC unresolved - no consistent VCC evidence; '
'static-high lines are not positively identifiable '
'(may be floating)')
print('\nBest result table: segment %d\n' % best['seg'])
report(best['stats_list'], best['srate'],
best['verdicts'], best['notes'], show_suggestion=False)
lines, _cmd = format_suggestion(final)
for ln in lines:
print(ln)
for q in open_q:
print('open: %s' % q)
# -------------------------------------------------------------- selftest --
def synth(n, sr, clk_ch=None, data_ch=None, rst_ch=None, vcc_ch=None,
gate_rst=True, pre_high=False, gate_clk=False):
'''Build an 8-channel packed stream with known signal placement.'''
t = np.arange(n) / sr
chans = {}
if gate_clk:
# Clock Stop Mode: CLK runs only inside the transaction window and
# is held HIGH (stop-high) the rest of the time -- exactly the case
# that used to confuse high/low-time based detection.
clk = np.ones(n, dtype=np.uint8)
window = (t >= 0.10) & (t < 0.35)
clk[window] = (((t[window] * 2e6).astype(np.int64)) % 2)
else:
clk = (((t * 2e6).astype(np.int64)) % 2).astype(np.uint8)
chans['clk'] = clk
vcc = (t >= 0.05).astype(np.uint8)
rst = ((t >= 0.10) & (t < 0.15)).astype(np.uint8)
if pre_high:
rst = np.ones(n, dtype=np.uint8) # no transitions at all
vcc = np.ones(n, dtype=np.uint8)
data = np.ones(n, dtype=np.uint8)
burst = (t >= 0.15) & (t < 0.30)
data[burst] = (((t[burst] * 1e5).astype(np.int64)) % 2)
chans['data'] = data
chans['rst'] = rst
chans['vcc'] = vcc
mapping = {'clk': clk_ch, 'data': data_ch, 'rst': rst_ch, 'vcc': vcc_ch}
arr = np.zeros(n, dtype=np.uint8)
for sig, ch in mapping.items():
if ch is not None:
arr |= chans[sig] << ch
return arr
def _classify_arr(arr, sr):
stats = _accumulate(arr, sr, 8)
v, notes, _pools = classify(stats, sr, len(arr) / sr)
return v, notes
def selftest():
failures = []
# parse_rate: CLI shorthand and .sr metadata spellings alike.
for s, want in [('12M', 12_000_000), ('12MHz', 12_000_000),
('25 MHz', 25_000_000), ('400kHz', 400_000),
('400 kHz', 400_000), ('1000', 1000)]:
got = parse_rate(s)
ok = got == want
print('selftest parse_rate %-9s: %s (%d)'
% (repr(s), 'PASS' if ok else 'FAIL', got))
if not ok:
failures.append('parse_rate %r' % s)
sr = 12_000_000
n = sr // 2 # 0.5 s window keeps it fast
# Case 1: scrambled but complete mapping.
arr = synth(n, sr, clk_ch=5, data_ch=3, rst_ch=0, vcc_ch=4)
v, notes = _classify_arr(arr, sr)
got = {verdict.split()[0]: ch_name(c) for c, (verdict, _) in v.items()}
want = {'CLK': 'D5', 'DATA': 'D3', 'RST': 'D0', 'VCC': 'D4'}
ok = all(got.get(k) == want[k] for k in want)
print('selftest scrambled-mapping: %s (got %s)' % ('PASS' if ok else 'FAIL', got))
if not ok:
failures.append('scrambled mapping')
# Case 1b: gated / stopped CLK (Clock Stop Mode) must still detect CLK
# -- the prior average-rate heuristic used to miss it and misread the
# stopped-high clock as a supply line.
arr = synth(n, sr, clk_ch=5, data_ch=3, rst_ch=0, vcc_ch=4, gate_clk=True)
v, notes = _classify_arr(arr, sr)
got = {verdict.split()[0]: ch_name(c) for c, (verdict, _) in v.items()}
ok = (got.get('CLK') == 'D5' and got.get('DATA') == 'D3'
and got.get('RST') == 'D0' and got.get('VCC') == 'D4')
# Idle-aware metrics: the gated CLK must read ~50% duty over its active
# (non-idle) windows, and most of the capture must be idle.
res = classify_capture(arr, sr, 8)
clk_c = next(c for c, (vv, _) in res['verdicts'].items()
if vv.split()[0] == 'CLK')
st = res['stats_list'][clk_c]
ha = st['high_active']
idle = st['idle_frac']
ok = ok and (0.40 <= ha <= 0.60) and idle > 0.2
print('selftest gated-clk (stop timer): %s (got %s, high_active=%.2f, '
'idle_frac=%.2f)' % ('PASS' if ok else 'FAIL', got, ha, idle))
if not ok:
failures.append('gated-clk case')
# Case 2: VCC/RST static-high whole window -> ambiguity must be flagged.
arr = synth(n, sr, clk_ch=2, data_ch=6, rst_ch=0, vcc_ch=4, pre_high=True)
v, notes = _classify_arr(arr, sr)
amb = [c for c, (verdict, conf) in v.items() if conf == 'ambiguous']
ok = len(amb) == 2 and v[2][0] == 'CLK' and v[6][0].startswith('DATA')
print('selftest static-pair ambiguity: %s (ambiguous=%s)'
% ('PASS' if ok else 'FAIL', [ch_name(c) for c in amb]))
if not ok:
failures.append('ambiguity case')
# Case 3: report() must emit the mapping block AND the suggested
# command for a complete detection. (A lowercase/uppercase key
# mismatch once made the Suggested line unreachable -- invisible to
# headless tests because they stubbed report() out.)
import io
import contextlib
stats = [dict(edges=10, rate=10, high=1.0, median_gap=3,
first_rise=0.01, last_act=0.02) for _ in range(8)]
complete = {2: ('CLK', 'high'), 3: ('DATA', 'high'),
4: ('RST', 'high'), 5: ('VCC', 'high')}
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
report(stats, 12_000_000, complete, [])
out = buf.getvalue()
ok = ('Suggested:' in out and '--clk=D2' in out and '--vcc=D5' in out
and 'Detected pin mapping:' in out)
print('selftest report-suggestion : %s' % ('PASS' if ok else 'FAIL'))
if not ok:
failures.append('report suggestion')
# Case 4: partial mapping still yields a usable partial command.
partial = {2: ('CLK', 'high'), 3: ('DATA', 'high')}
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
report(stats, 12_000_000, partial, [])
out = buf.getvalue()
ok = ('Partial: ./start.sh --clk=D2 --data=D3' in out
and 'RST/VCC unidentified' in out)
print('selftest report-partial : %s' % ('PASS' if ok else 'FAIL'))
if not ok:
failures.append('report partial')
# Case 5: floating/unconnected pins emit sparse coupled blips that
# START as early as the clock -- raw 'earliest rise' ordering used
# to hand VCC/RST to those ghosts (observed on real hardware).
arr = np.zeros(n, dtype=np.uint8)
t = np.arange(n) / sr
arr |= ((((t * 2e6).astype(np.int64)) % 2)).astype(np.uint8) << 5 # CLK D5
d = np.ones(n, dtype=np.uint8)
burst = (t >= 0.15) & (t < 0.30)
d[burst] = (((t[burst] * 1e5).astype(np.int64)) % 2)
arr |= d << 0 # DATA D0
arr |= (((t >= 0.10) & (t < 0.15)).astype(np.uint8)) << 4 # RST D4
arr |= ((t >= 0.05).astype(np.uint8)) << 2 # VCC D2
# two floating pins: thin blips scattered across the whole window
for ch, offs in ((1, [0.02, 0.09, 0.21, 0.33, 0.41, 0.47]),
(7, [0.01, 0.06, 0.18, 0.27, 0.38, 0.49])):
for o in offs:
i0 = int(o * n)
arr[i0:i0 + 40] |= (1 << ch)
v, notes = _classify_arr(arr, sr)
got = {verdict.split()[0]: ch_name(c) for c, (verdict, conf)
in v.items() if conf != 'ambiguous'}
want = {'CLK': 'D5', 'DATA': 'D0', 'RST': 'D4', 'VCC': 'D2'}
ok = all(got.get(k) == want[k] for k in want)
noisy_flagged = sum(1 for _c, (_vv, cf) in v.items() if cf != 'ambiguous'
) >= 4
print('selftest floating-noise : %s (got %s)'
% ('PASS' if ok else 'FAIL', got))
if not ok or not noisy_flagged:
failures.append('floating-noise case')
# Case 6: _vcc_consensus gating (the multi-segment VCC vote).
# Floating pins flap between channels across segments and must never
# clear the bar; only a pin consistently classified VCC in a strict
# majority (and >= 2 segments) is accepted.
taken = {1, 6, 0} # CLK, DATA, RST
base = {1: ('CLK', 'high'), 6: ('DATA', 'high'), 0: ('RST', 'medium')}
seg_d2 = {**base, 2: ('VCC', 'low')}
seg_d5 = {**base, 5: ('VCC', 'low')}
seg_d4 = {**base, 4: ('VCC', 'high')}
ok = (_vcc_consensus([base, base], taken) is None # no VCC ever
and _vcc_consensus([seg_d2], taken) is None # single segment
and _vcc_consensus([seg_d2, seg_d5], taken) is None # flapping D2/D5
and _vcc_consensus([seg_d4, seg_d4], taken) == 4 # consistent
and _vcc_consensus([seg_d4, seg_d4, seg_d2], taken) == 4) # 2/3
print('selftest vcc-consensus : %s' % ('PASS' if ok else 'FAIL'))
if not ok:
failures.append('vcc-consensus case')
if failures:
sys.exit('SELFTEST FAILED: %s' % ', '.join(failures))
print('selftest OK')
# ------------------------------------------------------------------ main --
def main():
# Windows consoles default to legacy codepages that cannot render
# the ✓/✗ markers used in summaries; degrade gracefully instead of
# raising UnicodeEncodeError mid-report.
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('capture', nargs='?', help='.sr capture file')
ap.add_argument('--live', action='store_true',
help='probe live (default when no capture file is given)')
ap.add_argument('--samplerate', default='24M')
ap.add_argument('--seconds', type=float, default=3.0,
help='segment length in seconds (default: %(default)s)')
ap.add_argument('--max-segments', type=int, default=20,
help='stop after this many segments (default: %(default)s)')
ap.add_argument('--max-samples', type=int, default=400_000_000,
help='cap for offline captures (default: %(default)s)')
ap.add_argument('--selftest', action='store_true')
args = ap.parse_args()
if args.selftest:
selftest()
return
if args.capture and args.live:
ap.error('pass either a capture file or --live, not both')
if args.capture:
arr, samplerate, nch = load_sr(path=args.capture,
max_samples=args.max_samples)
print('loaded %s: %d samples @ %s, %d channels\n'
% (args.capture, len(arr), samplerate, nch))
analyse(arr, samplerate, nch)
else:
# Live probing is the primary use -- bare invocation suffices.
probe_loop(args.samplerate, args.seconds, args.max_segments)
if __name__ == '__main__':
main()