Files
XDJ100SX/tools/xdj_pi_dev/signal_analyzer.py
T
Jeancarlo 8b0eb42fec add MCP developer tool with multi-unit support
First developer tool for the XDJ-100SX project. Connects Claude Code
directly to the Pi over SSH — push skin files, take screenshots, restart
Mixxx, flash Pico firmware, and more without leaving the editor.

Available MCP tools:
- run_command, read_file, write_file, list_files
- push_skin, pull_skin, push_skin_file, pull_skin_file
- push_midi, pull_midi
- take_screenshot, navigate_panel
- restart_mixxx
- check (preflight: SSH, Mixxx, Pico, audio)
- pico_bootloader, pico_flash
- discover_units — scan network for all reachable XDJ Pi units
- select_unit — switch active connection mid-session (multi-unit support)

Also adds --about flag and TUI About modal with authors and credits,
and fixes scrolling/close behavior on Help and About modals.

By: Jeancarlo Cardoso de Faria Filho (jaianlab) <jaianlabworks@gmail.com>
2026-05-08 01:24:15 -03:00

907 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Signal Analyzer — live GPIO/ADC event viewer for XDJ-100SX Pico firmware.
Two execution modes:
Web (TUI button): run_signal_analyzer_web(pi_client, stop_event, port)
CLI (--analyze): run_signal_analyzer_cli(pi_client)
All public functions take a pi_client as their first argument instead of
relying on a module-level global, following Dependency Inversion.
"""
from __future__ import annotations
import collections
import re
import sys
import threading
import time
from typing import Any, Callable
# ─── Pin map ─────────────────────────────────────────────────────────────────
SA_PIN_NAMES: dict[int, str] = {
0: "EJECT", 1: "TRACK PREV", 2: "TRACK NEXT", 3: "SEARCH BACK",
4: "SEARCH FWD", 5: "CUE", 6: "PLAY", 7: "JET",
8: "ZIP", 9: "WAH", 10: "HOLD", 11: "TIME",
12: "MSTR TEMPO", 14: "JOG A", 15: "LED CUE", 16: "LED PLAY",
17: "LED INTL", 18: "LED CD", 19: "JOG B", 20: "BROWSE A",
21: "BROWSE B", 22: "LOAD",
}
SA_BUTTON_PINS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 22]
SA_JOG_PINS = [14, 19]
SA_BROWSE_PINS = [20, 21]
SA_LED_PINS = [15, 16, 17, 18]
SA_ALL_PINS = sorted(set(SA_BUTTON_PINS + SA_JOG_PINS + SA_BROWSE_PINS + SA_LED_PINS))
_BOUNCE_WINDOW_US = 50_000 # 50 ms: edges within this window on same pin = bounce
_WAVEFORM_LEN = 40
# ─── Shared state ─────────────────────────────────────────────────────────────
class SAState:
"""Signal-analyzer state updated by a reader thread and read by display."""
def __init__(self) -> None:
self.lock = threading.Lock()
self.t_ref_us: int = 0
self.t_latest_us: int = 0
self.running: bool = False
self.pin_state: dict[int, int] = {p: 1 for p in SA_ALL_PINS}
self.pin_edges: dict[int, int] = {p: 0 for p in SA_ALL_PINS}
self.pin_waveform: dict[int, Any] = {
p: collections.deque([1] * _WAVEFORM_LEN, maxlen=_WAVEFORM_LEN)
for p in SA_ALL_PINS
}
self.bounce_count: dict[int, int] = {p: 0 for p in SA_BUTTON_PINS}
self.bounce_max_us: dict[int, int] = {p: 0 for p in SA_BUTTON_PINS}
self._press_t0: dict[int, int] = {}
self._press_n: dict[int, int] = {}
self._press_last: dict[int, int] = {}
self.jog_a_state = 1
self.jog_b_state = 1
self.jog_net = 0
self.adc_current = 512
self.adc_min = 1023
self.adc_max = 0
self.adc_sum = 0.0
self.adc_count = 0
self.adc_history: Any = collections.deque(maxlen=60)
self.events: Any = collections.deque(maxlen=20)
def update_gpio(self, t_us: int, pin: int, val: int) -> None:
with self.lock:
if self.t_ref_us == 0:
self.t_ref_us = t_us
self.t_latest_us = t_us
self.running = True
if pin not in SA_ALL_PINS:
return
if val == self.pin_state.get(pin, 1):
return
self.pin_state[pin] = val
self.pin_edges[pin] += 1
self.pin_waveform[pin].append(val)
rel = t_us - self.t_ref_us
edge = "▔→▁" if val == 0 else "▁→▔"
self.events.append((rel, pin, edge))
if pin in SA_BUTTON_PINS:
self._track_bounce(pin, t_us, val)
if pin == 14:
self.jog_a_state = val
self.jog_net += 1 if val != self.jog_b_state else -1
elif pin == 19:
self.jog_b_state = val
def _track_bounce(self, pin: int, t_us: int, val: int) -> None:
if val == 0 and pin not in self._press_t0:
self._press_t0[pin] = self._press_last[pin] = t_us
self._press_n[pin] = 1
return
if pin not in self._press_t0:
return
if t_us - self._press_last[pin] > _BOUNCE_WINDOW_US:
del self._press_t0[pin], self._press_n[pin], self._press_last[pin]
if val == 0:
self._press_t0[pin] = self._press_last[pin] = t_us
self._press_n[pin] = 1
return
self._press_n[pin] += 1
self._press_last[pin] = t_us
if self._press_n[pin] > 2:
dur = t_us - self._press_t0[pin]
self.bounce_count[pin] += 1
self.bounce_max_us[pin] = max(self.bounce_max_us.get(pin, 0), dur)
def update_adc(self, t_us: int, val: int) -> None:
with self.lock:
if self.t_ref_us == 0:
self.t_ref_us = t_us
self.running = True
self.adc_current = val
self.adc_min = min(self.adc_min, val)
self.adc_max = max(self.adc_max, val)
self.adc_sum += val
self.adc_count += 1
self.adc_history.append(val)
def reset_counters(self) -> None:
with self.lock:
for p in SA_ALL_PINS:
self.pin_edges[p] = 0
cur = self.pin_state.get(p, 1)
for _ in range(_WAVEFORM_LEN):
self.pin_waveform[p].append(cur)
for p in SA_BUTTON_PINS:
self.bounce_count[p] = 0
self.bounce_max_us[p] = 0
self._press_t0.clear(); self._press_n.clear(); self._press_last.clear()
self.jog_net = 0
self.adc_min = 1023; self.adc_max = 0
self.adc_sum = 0.0; self.adc_count = 0
self.adc_history.clear()
self.events.clear()
# ─── Protocol parsing ─────────────────────────────────────────────────────────
def parse_line(line: str) -> tuple | None:
"""Parse one SA: protocol line from Pico Core 1."""
if not line.startswith("SA:"):
return None
if line == "SA:START":
return ("start",)
if "ADC=" in line:
m = re.search(r'ADC=(\d+).*T=(\d+)', line)
if m:
return ("adc", int(m.group(1)), int(m.group(2)))
return None
m = re.search(r'T=(\d+),P=(\d+),V=(\d+)', line)
if m:
return ("gpio", int(m.group(1)), int(m.group(2)), int(m.group(3)))
return None
def open_channel(pi_client: Any) -> Any:
"""Open an SSH channel that streams /dev/ttyACM0 from the Pi."""
transport = pi_client._ssh.get_transport()
assert transport
ch = transport.open_session()
ch.set_combine_stderr(True)
ch.exec_command("stty -F /dev/ttyACM0 115200 raw -echo 2>/dev/null; cat /dev/ttyACM0")
return ch
# ─── Web server (SSE) ─────────────────────────────────────────────────────────
# Embedded HTML — kept in one place so the web UI and the Python server stay in sync.
_WEB_HTML = r"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<title>XDJ Signal Analyzer</title>
<style>
:root{--bg:#0d1117;--bg2:#161b22;--bg3:#21262d;--brd:#30363d;--txt:#c9d1d9;--dim:#6e7681;--blue:#58a6ff;--grn:#3fb950;--yel:#d29922;--red:#f85149;--cyan:#39c5cf;}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--bg);color:var(--txt);font-family:'SF Mono','Fira Code','Cascadia Code',monospace;font-size:13px;overflow:hidden;height:100vh;display:flex;flex-direction:column;}
header{background:var(--bg2);border-bottom:1px solid var(--brd);padding:7px 16px;display:flex;align-items:center;gap:14px;flex-shrink:0;}
header h1{color:var(--blue);font-size:13px;font-weight:bold;}
#status{font-size:12px;}#elapsed,#evcount{color:var(--dim);font-size:12px;}
.hbtn{background:var(--bg3);border:1px solid var(--brd);color:var(--txt);padding:2px 10px;cursor:pointer;border-radius:3px;font-family:inherit;font-size:11px;margin-left:4px;}
.hbtn:hover{border-color:var(--blue);}
.fbar{background:var(--bg2);border-bottom:1px solid var(--brd);padding:5px 12px;display:flex;gap:6px;align-items:center;flex-shrink:0;}
.fbtn{background:var(--bg3);border:1px solid var(--brd);color:var(--dim);padding:2px 12px;cursor:pointer;border-radius:3px;font-family:inherit;font-size:12px;}
.fbtn.on{background:#1f6feb;border-color:#388bfd;color:#e6edf3;}
.hint{color:var(--dim);font-size:11px;margin-left:8px;}
.main{display:grid;grid-template-columns:1fr 270px;grid-template-rows:1fr 160px;flex:1;min-height:0;}
.tbl-wrap{overflow-y:auto;border-right:1px solid var(--brd);}
table{width:100%;border-collapse:collapse;}
thead th{position:sticky;top:0;background:var(--bg2);color:var(--dim);text-align:left;padding:4px 8px;border-bottom:1px solid var(--brd);font-weight:bold;font-size:11px;text-transform:uppercase;white-space:nowrap;}
tbody tr{border-bottom:1px solid var(--brd)22;}
tbody tr:hover{background:#ffffff09;}
tbody tr.sel{background:#1f6feb18;}
tbody td{padding:3px 8px;white-space:nowrap;}
.c-pin{color:var(--cyan);font-weight:bold;}.c-name{color:var(--dim);}
.c-hi{color:var(--grn);}.c-lo{color:var(--yel);}
.c-ed{color:var(--txt);}.c-bc{color:var(--red);}.c-dim{color:var(--dim);}
.wf{letter-spacing:-1px;}
.rpanel{display:flex;flex-direction:column;border-left:1px solid var(--brd);}
.rsec{padding:10px 12px;border-bottom:1px solid var(--brd);flex-shrink:0;}
.rsec.grow{flex:1;overflow:hidden;}
.rtitle{color:var(--cyan);font-size:10px;font-weight:bold;margin-bottom:6px;letter-spacing:.5px;}
.rcontent{font-size:12px;line-height:1.7;}
#adc-bar-bg{background:var(--bg3);border-radius:2px;height:8px;margin:5px 0;}
#adc-bar{height:8px;border-radius:2px;transition:width .06s;}
#adc-nums{color:var(--dim);font-size:11px;}
#adc-spark{color:var(--cyan);letter-spacing:-1px;font-size:11px;}
.log-wrap{grid-column:1/-1;border-top:1px solid var(--brd);overflow-y:auto;font-size:11px;}
.log-wrap::-webkit-scrollbar{width:4px;}.log-wrap::-webkit-scrollbar-thumb{background:var(--brd);}
.ev{display:flex;gap:10px;padding:1px 12px;}.ev:hover{background:#ffffff06;}
.ev-t{color:var(--dim);min-width:65px;}.ev-p{color:var(--cyan);min-width:36px;}
.ev-n{color:var(--dim);min-width:96px;}.ev-hi{color:var(--grn);}.ev-lo{color:var(--yel);}
.ev-bc{color:var(--red);font-size:10px;}.ev-err{color:var(--red);}
</style></head><body>
<header>
<h1>⬡ XDJ Signal Analyzer</h1>
<span id="status" style="color:#d29922">● connecting…</span>
<span id="elapsed">00:00:00</span>│
<span id="evcount">0 events</span>
<span style="margin-left:auto">
<button class="hbtn" onclick="resetAll()">Reset</button>
<button class="hbtn" onclick="clearLog()">Clear Log</button>
</span>
</header>
<div class="fbar">
<button class="fbtn on" id="fb-all" onclick="setFilter('all',this)">ALL</button>
<button class="fbtn" id="fb-buttons" onclick="setFilter('buttons',this)">BUTTONS</button>
<button class="fbtn" id="fb-jog" onclick="setFilter('jog',this)">JOG</button>
<button class="fbtn" id="fb-leds" onclick="setFilter('leds',this)">LEDs</button>
<span class="hint">click row to inspect · SSE live stream</span>
</div>
<div class="main">
<div class="tbl-wrap"><table>
<thead><tr><th>PIN</th><th>NAME</th><th>STATE</th><th>EDGES</th><th>BOUNCE</th><th>MAX µs</th><th>WAVEFORM</th></tr></thead>
<tbody id="tbody"></tbody>
</table></div>
<div class="rpanel">
<div class="rsec" id="det-sec">
<div class="rtitle">━ SELECTED PIN ━━━━━━━━━━━━━━━━━━━━━━</div>
<div class="rcontent" id="det" style="color:var(--dim)">↑ click a row</div>
</div>
<div class="rsec">
<div class="rtitle">━ JOG QUADRATURE ━━━━━━━━━━━━━━━━━━━</div>
<div class="rcontent" id="jog"></div>
</div>
<div class="rsec grow">
<div class="rtitle">━ PITCH ADC GP26 ━━━━━━━━━━━━━━━━━━━</div>
<div id="adc-bar-bg"><div id="adc-bar" style="width:50%;background:var(--blue)"></div></div>
<div id="adc-nums"></div>
<div id="adc-spark"></div>
</div>
</div>
<div class="log-wrap"><div id="evlog"></div></div>
</div>
<script>
const PIN_NAMES = %%PIN_NAMES%%;
const BTN_PINS = %%BTN_PINS%%;
const JOG_PINS = %%JOG_PINS%%;
const LED_PINS = %%LED_PINS%%;
const ALL_PINS = %%ALL_PINS%%;
const HOST = "%%HOST%%";
const FILTERS = {all:ALL_PINS, buttons:BTN_PINS, jog:JOG_PINS, leds:LED_PINS};
let filter = ALL_PINS, selPin = null, jogNet = 0, evCount = 0, logCount = 0, tRef = null;
const t0 = Date.now();
const pins = {};
ALL_PINS.forEach(p => pins[p] = {val:1,edges:0,bounce:0,bm:0,wf:Array(24).fill(1)});
function buildTable() {
const tb = document.getElementById('tbody');
tb.innerHTML = '';
filter.forEach(pin => {
const tr = document.createElement('tr');
tr.id = 'r'+pin; tr.onclick = ()=>selRow(pin);
tr.innerHTML = '<td class="c-pin">GP'+String(pin).padStart(2,'0')+'</td>'
+'<td class="c-name">'+(PIN_NAMES[pin]||'?')+'</td>'
+'<td id="st'+pin+'" class="c-hi">▔ HI</td>'
+'<td id="ed'+pin+'" class="c-ed">0</td>'
+'<td id="bc'+pin+'" class="c-dim">—</td>'
+'<td id="bm'+pin+'" class="c-dim">—</td>'
+'<td id="wf'+pin+'" class="wf c-hi">'+''.repeat(24)+'</td>';
tb.appendChild(tr);
});
}
buildTable();
function updRow(pin) {
const p = pins[pin];
const st = document.getElementById('st'+pin); if(!st)return;
st.textContent = p.val?'▔ HI':'▁ LO'; st.className = p.val?'c-hi':'c-lo';
document.getElementById('ed'+pin).textContent = p.edges;
const bc=document.getElementById('bc'+pin);
bc.textContent=p.bounce||''; bc.className=p.bounce?'c-bc':'c-dim';
const bm=document.getElementById('bm'+pin);
bm.textContent=p.bm||''; bm.className=p.bm?'c-bc':'c-dim';
const wf=document.getElementById('wf'+pin);
wf.textContent=p.wf.map(v=>v?'':'').join(''); wf.className='wf '+(p.val?'c-hi':'c-lo');
}
function selRow(pin) {
selPin=pin;
document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('sel'));
const r=document.getElementById('r'+pin); if(r)r.classList.add('sel');
updDet();
}
function updDet() {
if(selPin===null)return;
const p=pins[selPin], name=PIN_NAMES[selPin]||('GP'+selPin);
const sc=p.val?'var(--grn)':'var(--yel)';
const bc=p.bounce?`<span style="color:var(--red)">⚡ ×${p.bounce} max ${p.bm}µs</span>`:'<span style="color:var(--dim)">no bounce</span>';
const wf=p.wf.map(v=>v?'':'').join('');
document.getElementById('det').innerHTML=
`<div style="color:var(--cyan);font-weight:bold">GP${String(selPin).padStart(2,'0')} ${name}</div>`
+`<div><span style="color:${sc}">${p.val?'▔ HIGH':'▁ LOW'}</span> &nbsp;edges:<b>${p.edges}</b>&nbsp;${bc}</div>`
+`<div style="color:${sc};letter-spacing:-1px;margin-top:4px;font-size:12px">${wf}</div>`;
}
function updJog() {
const a=pins[14]||{val:1,wf:[],edges:0}, b=pins[19]||{val:1,wf:[],edges:0};
const nc=jogNet>0?'var(--grn)':jogNet<0?'var(--yel)':'var(--dim)';
document.getElementById('jog').innerHTML=
`<div><span style="color:var(--grn)">A GP14</span> <span style="color:var(--grn);letter-spacing:-1px">${a.wf.map(v=>v?'':'').join('')}</span> <span style="color:var(--dim)">e:</span>${a.edges}</div>`
+`<div><span style="color:var(--yel)">B GP19</span> <span style="color:var(--yel);letter-spacing:-1px">${b.wf.map(v=>v?'':'').join('')}</span> <span style="color:var(--dim)">e:</span>${b.edges}</div>`
+`<div style="margin-top:4px">Net: <span style="color:${nc}">${jogNet>=0?'CW':'CCW'} ${Math.abs(jogNet)}</span></div>`;
}
function updADC(d) {
const center=Math.abs(d.val-512)<=8;
const pct=d.val/1023*100;
document.getElementById('adc-bar').style.width=pct+'%';
document.getElementById('adc-bar').style.background=center?'var(--grn)':'var(--yel)';
const cs=center?'<span style="color:var(--grn)">● CENTER</span>'
:`<span style="color:var(--yel)">${d.val-512>0?'+':''}${d.val-512} off</span>`;
document.getElementById('adc-nums').innerHTML=
`<span style="color:var(--dim)">val:</span>${d.val} ${cs} `
+`<span style="color:var(--dim)">min:${d.min} max:${d.max} mean:${d.mean.toFixed(1)}</span>`;
if(d.hist&&d.hist.length>1){
const mn=Math.min(...d.hist),mx=Math.max(...d.hist),rng=mx-mn||1;
const ch=' ▁▂▃▄▅▆▇█';
document.getElementById('adc-spark').textContent=d.hist.slice(-44).map(v=>ch[Math.round((v-mn)*8/rng)]).join('');
}
}
function setFilter(name,btn) {
filter=FILTERS[name]||ALL_PINS;
document.querySelectorAll('.fbtn').forEach(b=>b.classList.remove('on'));
btn.classList.add('on');
buildTable(); filter.forEach(updRow);
}
function clearLog(){document.getElementById('evlog').innerHTML='';logCount=0;tRef=null;}
function resetAll(){
ALL_PINS.forEach(p=>{pins[p]={val:pins[p]?.val??1,edges:0,bounce:0,bm:0,wf:Array(24).fill(pins[p]?.val??1)};updRow(p);});
evCount=0;jogNet=0;document.getElementById('evcount').textContent='0 events';
clearLog();updDet();updJog();
}
function addLog(ev) {
if(!filter.includes(ev.pin))return;
if(!tRef&&ev.t>0)tRef=ev.t;
const rel=tRef?((ev.t-tRef)/1e6).toFixed(3):'0.000';
const edge=ev.val?'<span class="ev-hi">▁→▔</span>':'<span class="ev-lo">▔→▁</span>';
const bc=ev.bounce>0?`<span class="ev-bc"> ⚡×${ev.bounce}</span>`:'';
const div=document.createElement('div'); div.className='ev';
div.innerHTML=`<span class="ev-t">${rel}s</span><span class="ev-p">GP${String(ev.pin).padStart(2,'0')}</span>`
+`<span class="ev-n">${PIN_NAMES[ev.pin]||''}</span>${edge}${bc}`;
const log=document.getElementById('evlog'); log.appendChild(div);
logCount++; if(logCount>500)log.removeChild(log.firstChild);
log.parentElement.scrollTop=log.parentElement.scrollHeight;
}
setInterval(()=>{
const el=Math.floor((Date.now()-t0)/1000);
document.getElementById('elapsed').textContent=
String(Math.floor(el/3600)).padStart(2,'0')+':'+String(Math.floor((el%3600)/60)).padStart(2,'0')+':'+String(el%60).padStart(2,'0');
},1000);
function connect() {
const es=new EventSource('/stream');
es.onopen=()=>{document.getElementById('status').textContent=''+HOST;document.getElementById('status').style.color='var(--grn)';};
es.onmessage=e=>{
const ev=JSON.parse(e.data);
if(ev.type==='gpio'){
const prev=pins[ev.pin]||{};
pins[ev.pin]={val:ev.val,edges:ev.edges,bounce:ev.bounce,bm:ev.bounce_max_us,wf:ev.wf};
if(ev.pin===19&&prev.val!==undefined&&ev.val===0&&prev.val===1)
jogNet+=(pins[14]?.val===0)?1:-1;
updRow(ev.pin);
if(ev.pin===selPin)updDet();
if(ev.pin===14||ev.pin===19)updJog();
if(ev.t>0){evCount++;document.getElementById('evcount').textContent=evCount+' events';addLog(ev);}
} else if(ev.type==='adc'){
updADC(ev);
} else if(ev.type==='error'){
const div=document.createElement('div');div.className='ev ev-err';
div.textContent=''+ev.msg;document.getElementById('evlog').appendChild(div);
document.getElementById('status').textContent='⚠ disconnected';
document.getElementById('status').style.color='var(--red)';
es.close();setTimeout(connect,3000);
}
};
es.onerror=()=>{
document.getElementById('status').textContent='● reconnecting…';
document.getElementById('status').style.color='var(--yel)';
es.close();setTimeout(connect,3000);
};
}
updJog();connect();
</script></body></html>"""
def run_signal_analyzer_web(pi_client: Any, stop_event: threading.Event, port: int) -> None:
"""HTTP + SSE server that streams GPIO/ADC events to a browser page.
Blocks until stop_event is set — run in a daemon thread.
"""
import json as _json
import http.server
import socketserver
state = SAState()
clients: list = []
clock = threading.Lock()
def _broadcast(msg: dict) -> None:
data = ("data: " + _json.dumps(msg) + "\n\n").encode()
with clock:
dead = []
for w in list(clients):
try:
w.write(data); w.flush()
except Exception:
dead.append(w)
for w in dead:
clients.remove(w)
def _reader() -> None:
try:
ch = open_channel(pi_client)
buf = b""
while not stop_event.is_set():
if ch.recv_ready():
buf += ch.recv(4096)
while b"\n" in buf:
raw, buf = buf.split(b"\n", 1)
parsed = parse_line(raw.decode(errors="replace").strip())
if not parsed:
continue
if parsed[0] == "gpio":
t_us, pin, val = parsed[1], parsed[2], parsed[3]
state.update_gpio(t_us, pin, val)
with state.lock:
_broadcast({
"type": "gpio", "pin": pin, "val": val, "t": t_us,
"name": SA_PIN_NAMES.get(pin, f"GP{pin}"),
"edges": state.pin_edges.get(pin, 0),
"bounce": state.bounce_count.get(pin, 0),
"bounce_max_us": state.bounce_max_us.get(pin, 0),
"wf": list(state.pin_waveform.get(pin, []))[-24:],
})
elif parsed[0] == "adc":
state.update_adc(parsed[2], parsed[1])
with state.lock:
n = state.adc_count or 1
_broadcast({
"type": "adc",
"val": state.adc_current, "min": state.adc_min,
"max": state.adc_max, "mean": state.adc_sum / n,
"hist": list(state.adc_history)[-44:],
})
elif ch.exit_status_ready():
_broadcast({"type": "error", "msg": "Serial stream closed — Pico disconnected?"})
break
else:
time.sleep(0.01)
ch.close()
except Exception as exc:
_broadcast({"type": "error", "msg": str(exc)})
threading.Thread(target=_reader, daemon=True).start()
html = (_WEB_HTML
.replace("%%PIN_NAMES%%", _json.dumps({str(k): v for k, v in SA_PIN_NAMES.items()}))
.replace("%%BTN_PINS%%", _json.dumps(SA_BUTTON_PINS))
.replace("%%JOG_PINS%%", _json.dumps(SA_JOG_PINS + SA_BROWSE_PINS))
.replace("%%LED_PINS%%", _json.dumps(SA_LED_PINS))
.replace("%%ALL_PINS%%", _json.dumps(SA_ALL_PINS))
.replace("%%HOST%%", pi_client.host))
html_b = html.encode()
class _Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_): pass
def do_GET(self) -> None:
if self.path in ("/", "/index.html"):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(html_b)))
self.end_headers()
self.wfile.write(html_b)
elif self.path == "/stream":
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
with state.lock:
for pin in SA_ALL_PINS:
snap = {
"type": "gpio", "pin": pin,
"val": state.pin_state.get(pin, 1), "t": 0,
"name": SA_PIN_NAMES.get(pin, f"GP{pin}"),
"edges": state.pin_edges.get(pin, 0),
"bounce": state.bounce_count.get(pin, 0),
"bounce_max_us": state.bounce_max_us.get(pin, 0),
"wf": list(state.pin_waveform.get(pin, []))[-24:],
}
self.wfile.write(("data: " + _json.dumps(snap) + "\n\n").encode())
self.wfile.flush()
with clock:
clients.append(self.wfile)
try:
while not stop_event.is_set():
time.sleep(1)
self.wfile.write(b": ka\n\n")
self.wfile.flush()
except Exception:
pass
finally:
with clock:
try: clients.remove(self.wfile)
except ValueError: pass
else:
self.send_error(404)
class _Server(socketserver.TCPServer):
allow_reuse_address = True
with _Server(("", port), _Handler) as srv:
srv.timeout = 0.5
while not stop_event.is_set():
srv.handle_request()
# ─── CLI dashboard (Textual) ──────────────────────────────────────────────────
def run_signal_analyzer_cli(pi_client: Any) -> None:
"""Launch the full Textual signal analyzer dashboard (--analyze flag)."""
try:
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.widgets import Button, DataTable, Footer, RichLog, Static
from textual import work as _tw
from rich.text import Text
except ImportError:
print("textual not installed — run: pip install textual", file=sys.stderr)
return
chk = pi_client.exec("ls /dev/ttyACM0 2>/dev/null || echo MISSING")
if "MISSING" in chk["stdout"]:
print("/dev/ttyACM0 not found — Pico not connected or firmware not flashed", file=sys.stderr)
return
state = SAState()
stop = threading.Event()
FILTER_GROUPS: dict[str, list[int]] = {
"g-all": SA_ALL_PINS,
"g-buttons": SA_BUTTON_PINS,
"g-jog": SA_JOG_PINS + SA_BROWSE_PINS,
"g-leds": SA_LED_PINS,
}
class SignalAnalyzerApp(App): # type: ignore[misc]
TITLE = "XDJ Signal Analyzer"
CSS = """
Screen { background: #0d1117; color: #c9d1d9; }
#hdr { height: 1; padding: 0 2; background: #161b22; color: #58a6ff; content-align: left middle; }
#fbar { height: 3; padding: 0 1; background: #161b22; border-bottom: solid #30363d; align: left middle; }
.gbtn { height: 1; min-width: 11; border: solid #30363d; margin-right: 1; background: #21262d; color: #8b949e; }
.gbtn.-on { background: #1f6feb; color: #e6edf3; border: solid #388bfd; }
#fhint { color: #6e7681; margin-left: 2; }
#body { height: 1fr; }
#left { width: 58; border-right: solid #21262d; }
DataTable { height: 1fr; }
DataTable > .datatable--header { background: #161b22; color: #8b949e; text-style: bold; }
DataTable > .datatable--cursor { background: #1f6feb33; }
DataTable > .datatable--zebra-stripe { background: #161b2244; }
#right { width: 1fr; padding: 0; }
#detail { height: 7; border-bottom: solid #21262d; padding: 1 2; background: #161b22; }
#jog { height: 8; border-bottom: solid #21262d; padding: 1 2; background: #0d1117; }
#adc { height: 1fr; padding: 1 2; background: #161b22; }
#evlog { height: 8; border-top: solid #21262d; }
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("r", "reset_all", "Reset"),
Binding("l", "clear_log", "Clear log"),
Binding("space", "toggle_pin", "Toggle"),
Binding("i", "isolate_pin", "Isolate"),
Binding("a", "show_all", "All"),
Binding("1", "grp1", "Buttons"),
Binding("2", "grp2", "Jog"),
Binding("3", "grp3", "LEDs"),
]
def __init__(self) -> None:
super().__init__()
self._t0 = time.time()
self._visible: set[int] = set(SA_ALL_PINS)
self._last_chk: dict[int, str] = {}
def compose(self) -> ComposeResult:
yield Static("", id="hdr")
with Horizontal(id="fbar"):
yield Button("ALL", id="g-all", classes="gbtn -on")
yield Button("BUTTONS", id="g-buttons", classes="gbtn")
yield Button("JOG", id="g-jog", classes="gbtn")
yield Button("LEDs", id="g-leds", classes="gbtn")
yield Static(
" ↑↓ select [bold]Space[/]:toggle [bold]I[/]:isolate "
"[bold]A[/]:all [bold]1-3[/]:group [bold]R[/]:reset [bold]L[/]:clear log",
id="fhint",
)
with Horizontal(id="body"):
with Vertical(id="left"):
yield DataTable(id="tbl", cursor_type="row", zebra_stripes=True)
with Vertical(id="right"):
yield Static("", id="detail")
yield Static("", id="jog")
yield Static("", id="adc")
yield RichLog(id="evlog", highlight=True, markup=True, max_lines=500)
yield Footer()
def on_mount(self) -> None:
self._rebuild_table()
self.set_interval(0.15, self._tick)
self._start_reader()
def on_worker_state_changed(self, event: object) -> None:
pass # suppress app exit on unhandled worker exception
@_tw(thread=True)
def _start_reader(self) -> None:
try:
evlog = self.query_one("#evlog", RichLog)
channel = open_channel(pi_client)
buf = b""
while not stop.is_set():
if channel.recv_ready():
buf += channel.recv(4096)
while b"\n" in buf:
raw, buf = buf.split(b"\n", 1)
parsed = parse_line(raw.decode(errors="replace").strip())
if parsed is None:
continue
if parsed[0] == "gpio":
t_us, pin, val = parsed[1], parsed[2], parsed[3]
state.update_gpio(t_us, pin, val)
if pin in self._visible:
self.call_from_thread(self._log_gpio, t_us, pin, val)
elif parsed[0] == "adc":
state.update_adc(parsed[2], parsed[1])
elif channel.exit_status_ready():
self.call_from_thread(
evlog.write, "[red]⚠ Serial stream closed — Pico disconnected?[/]"
)
break
else:
time.sleep(0.01)
channel.close()
except Exception as exc:
try:
self.call_from_thread(
self.query_one("#evlog", RichLog).write,
f"[red bold]Reader error:[/] [red]{exc}[/]",
)
except Exception:
pass
def _log_gpio(self, t_us: int, pin: int, val: int) -> None:
name = SA_PIN_NAMES.get(pin, f"GP{pin}")
edge = "[green]▁→▔[/]" if val else "[yellow]▔→▁[/]"
rel_s = (t_us - state.t_ref_us) / 1_000_000 if state.t_ref_us else 0.0
with state.lock:
bc = state.bounce_count.get(pin, 0)
bc_s = f" [red bold]⚡ bounce ×{bc}[/]" if bc else ""
self.query_one("#evlog", RichLog).write(
f"[dim]{rel_s:9.3f}s[/] [cyan]GP{pin:02d}[/] {name:<14} {edge}{bc_s}"
)
def _rebuild_table(self) -> None:
tbl = self.query_one("#tbl", DataTable)
tbl.clear(columns=True)
tbl.add_column("PIN", key="pin", width=6)
tbl.add_column("NAME", key="name", width=14)
tbl.add_column("STATE", key="st", width=6)
tbl.add_column("EDGES", key="ed", width=7)
tbl.add_column("BOUNCE", key="bc", width=8)
tbl.add_column("MAX µs", key="bm", width=9)
tbl.add_column("WAVEFORM (last 24)", key="wf", width=26)
self._last_chk.clear()
for pin in sorted(p for p in SA_ALL_PINS if p in self._visible):
tbl.add_row(
f"GP{pin:02d}", SA_PIN_NAMES.get(pin, "?"),
"▔ HI", "0", "", "", "" * 24,
key=str(pin),
)
def _tick(self) -> None:
el = time.time() - self._t0
hh = int(el)//3600; mm = (int(el)%3600)//60; ss = int(el)%60
with state.lock:
tot = sum(state.pin_edges.values())
self.query_one("#hdr", Static).update(
f"[bold cyan]XDJ Signal Analyzer[/] │ [green]{pi_client.host}[/] │ "
f"[dim]{hh:02d}:{mm:02d}:{ss:02d}[/] │ total events: [bold]{tot}[/]"
)
self._update_table()
self._update_right()
def _update_table(self) -> None:
tbl = self.query_one("#tbl", DataTable)
pins = sorted(p for p in SA_ALL_PINS if p in self._visible)
with state.lock:
snap = {
p: (
state.pin_state.get(p, 1),
state.pin_edges.get(p, 0),
state.bounce_count.get(p, 0),
state.bounce_max_us.get(p, 0),
list(state.pin_waveform.get(p, []))[-24:],
)
for p in pins
}
for pin in pins:
s_, ed, bc, bm, wf = snap[pin]
chk = f"{s_},{ed},{bc}"
if self._last_chk.get(pin) == chk:
continue
self._last_chk[pin] = chk
rk = str(pin)
st_c = "green" if s_ else "yellow"
ed_c = "bold white" if ed > 0 else "dim"
bc_c = "red bold" if bc > 0 else "dim"
wf_s = "".join("" if x else "" for x in wf)
try:
tbl.update_cell(rk, "st", Text("▔ HI" if s_ else "▁ LO", style=st_c), update_width=False)
tbl.update_cell(rk, "ed", Text(str(ed), style=ed_c), update_width=False)
tbl.update_cell(rk, "bc", Text(str(bc) if bc else "", style=bc_c), update_width=False)
tbl.update_cell(rk, "bm", Text(str(bm) if bm else "", style="red" if bm else "dim"), update_width=False)
tbl.update_cell(rk, "wf", Text(wf_s, style=st_c), update_width=False)
except Exception:
pass
def _update_right(self) -> None:
tbl = self.query_one("#tbl", DataTable)
sel_pin: int | None = None
try:
rk = tbl.cursor_row_key
if rk is not None:
sel_pin = int(str(rk))
except Exception:
pass
if sel_pin is not None and sel_pin in self._visible:
with state.lock:
s_ = state.pin_state.get(sel_pin, 1)
ed = state.pin_edges.get(sel_pin, 0)
bc = state.bounce_count.get(sel_pin, 0)
bm = state.bounce_max_us.get(sel_pin, 0)
wf = list(state.pin_waveform.get(sel_pin, []))
name = SA_PIN_NAMES.get(sel_pin, f"GP{sel_pin}")
st_c = "green" if s_ else "yellow"
wf_s = "".join("" if x else "" for x in wf)
bc_s = (f"[red]⚡ bounce ×{bc} longest: {bm} µs[/]"
if bc else "[dim]no bounce detected[/]")
self.query_one("#detail", Static).update(
f"[bold cyan]━━ GP{sel_pin:02d} {name} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/]\n"
f" [{st_c}]{'▔ HIGH' if s_ else '▁ LOW '}[/] edges: [bold]{ed}[/] {bc_s}\n\n"
f" [{st_c}]{wf_s}[/]"
)
else:
self.query_one("#detail", Static).update(
"[dim] ↑↓ navigate the table to inspect a pin[/]"
)
with state.lock:
wf_a = list(state.pin_waveform.get(14, []))
wf_b = list(state.pin_waveform.get(19, []))
a_ed = state.pin_edges.get(14, 0)
b_ed = state.pin_edges.get(19, 0)
net = state.jog_net
wf_a_s = "".join("" if x else "" for x in wf_a)
wf_b_s = "".join("" if x else "" for x in wf_b)
nc = "green" if net > 0 else ("yellow" if net < 0 else "dim")
ds = "CW" if net >= 0 else "CCW"
self.query_one("#jog", Static).update(
f"[bold cyan]━━ JOG QUADRATURE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/]\n"
f" [green]A GP14[/] [green]{wf_a_s}[/] [dim]edges:[/] [bold]{a_ed}[/]\n"
f" [yellow]B GP19[/] [yellow]{wf_b_s}[/] [dim]edges:[/] [bold]{b_ed}[/]\n\n"
f" Net: [{nc}]{ds} {abs(net):5d}[/] ticks "
f"[dim]A:{a_ed} B:{b_ed} diff:{abs(a_ed - b_ed)}[/]"
)
with state.lock:
av = state.adc_current
amin = state.adc_min if state.adc_count > 0 else 512
amax = state.adc_max if state.adc_count > 0 else 512
amean = (state.adc_sum / state.adc_count) if state.adc_count > 0 else 512.0
hist = list(state.adc_history)
cnt = state.adc_count
center = abs(av - 512) <= 8
adc_c = "green" if center else "yellow"
blen = 34
filled = max(0, min(blen, av * blen // 1024))
bar = "" * filled + "" * (blen - filled)
spc = " ▁▂▃▄▅▆▇█"
if hist and cnt > 1:
mn, mx = min(hist), max(hist); rng = mx - mn or 1
spark = "".join(spc[int((v - mn) * 8 // rng)] for v in hist[-44:])
else:
spark = "[dim](waiting for samples…)[/]"
jitter = (amax - amin) // 2 if cnt > 0 else 0
ctr_s = "[green]● CENTER[/]" if center else f"[yellow]{av - 512:+d} off center[/]"
self.query_one("#adc", Static).update(
f"[bold cyan]━━ PITCH ADC GP26/ADC0 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/]\n\n"
f" [{adc_c}][{bar}][/] [{adc_c}]{av:4d}[/] {ctr_s}\n\n"
f" [dim]min:[/][cyan]{amin}[/] [dim]max:[/][cyan]{amax}[/] "
f"[dim]mean:[/][cyan]{amean:.1f}[/] "
f"[dim]jitter:[/][{'green' if jitter < 5 else 'yellow'}{jitter}[/]\n\n"
f" [cyan]{spark}[/]"
)
def on_button_pressed(self, event: Button.Pressed) -> None:
bid = event.button.id
if bid not in FILTER_GROUPS:
return
for fid in FILTER_GROUPS:
self.query_one(f"#{fid}", Button).remove_class("-on")
event.button.add_class("-on")
new = set(FILTER_GROUPS[bid])
if new != self._visible:
self._visible = new
self._rebuild_table()
def action_toggle_pin(self) -> None:
tbl = self.query_one("#tbl", DataTable)
try:
pin = int(str(tbl.cursor_row_key))
except Exception:
return
if pin in self._visible:
self._visible.discard(pin)
else:
self._visible.add(pin)
self._rebuild_table()
def action_isolate_pin(self) -> None:
tbl = self.query_one("#tbl", DataTable)
try:
pin = int(str(tbl.cursor_row_key))
except Exception:
return
self._visible = {pin}
for fid in FILTER_GROUPS:
self.query_one(f"#{fid}", Button).remove_class("-on")
self._rebuild_table()
def action_show_all(self) -> None:
self._visible = set(SA_ALL_PINS)
for fid in FILTER_GROUPS:
self.query_one(f"#{fid}", Button).remove_class("-on")
self.query_one("#g-all", Button).add_class("-on")
self._rebuild_table()
def action_grp1(self) -> None:
self.query_one("#g-buttons", Button).press()
def action_grp2(self) -> None:
self.query_one("#g-jog", Button).press()
def action_grp3(self) -> None:
self.query_one("#g-leds", Button).press()
def action_clear_log(self) -> None:
log = self.query_one("#evlog", RichLog)
log.clear()
log.write("[dim]── log cleared ──[/]")
def action_reset_all(self) -> None:
state.reset_counters()
self._t0 = time.time()
self._last_chk.clear()
log = self.query_one("#evlog", RichLog)
log.clear()
log.write("[dim]── counters reset ──[/]")
def on_unmount(self) -> None:
stop.set()
SignalAnalyzerApp().run()
stop.set()