This commit is contained in:
David Rice
2026-06-15 16:28:26 +02:00
parent c5a71f742c
commit 528c7df10e
2 changed files with 308 additions and 0 deletions

View File

@@ -47,6 +47,12 @@ C_PINK_HOT = "#FF33BB" # P2 — hot pink, active / pressed state
C_PINK_LIGHT = "#FFADE4" # P4 — light pink, accents
C_GREY = "#F9F5F4" # Off-White — secondary labels
C_WHITE = "#FFFFFF"
C_ACCEPT = "#00C878" # green — ticket accepted
C_REJECT = "#FF3344" # red — ticket rejected
# ── A1000 card reader ─────────────────────────────────────────────────────────
_NFC_DEMO = '/usr/bin/nfc_nl_demo'
_VALID_UIDS = {'f6 4b f6 cb'} # whitelisted cards → accept tone
# ── Custom font (set after QApplication is created) ──────────────────────────
_FONT_FAMILY = "Sans Serif" # overwritten at startup if OptimismSans loads
@@ -140,6 +146,55 @@ class AudioEngine:
if self._pa is not None:
self._pa.terminate()
def play_accept(self):
"""Ascending two-tone chime: 880 Hz (120 ms) then 1320 Hz (200 ms)."""
self._play_oneshot([(880, 120, 'sine'), (1320, 200, 'sine')])
def play_reject(self):
"""Low sawtooth buzz: 220 Hz (400 ms)."""
self._play_oneshot([(220, 400, 'sawtooth')])
def _play_oneshot(self, segments):
wav = self._build_wav(segments)
def _run():
try:
proc = subprocess.Popen(
['aplay', '-q', '-'],
stdin=subprocess.PIPE, stderr=subprocess.DEVNULL,
)
proc.communicate(input=wav, timeout=3.0)
except Exception:
pass
threading.Thread(target=_run, daemon=True).start()
def _build_wav(self, segments):
"""Return WAV bytes for a list of (freq, duration_ms, waveform) tuples."""
samples = []
for freq, duration_ms, waveform in segments:
n = int(SAMPLE_RATE * duration_ms / 1000)
if _HAS_NUMPY:
t = np.arange(n, dtype=np.float64) / SAMPLE_RATE
if waveform == 'sine':
s = AMPLITUDE * np.sin(2.0 * math.pi * freq * t)
else:
s = AMPLITUDE * (2.0 * ((freq * t) % 1.0) - 1.0)
samples.extend((s * 32767).astype(np.int16).tolist())
else:
for i in range(n):
tt = i / SAMPLE_RATE
if waveform == 'sine':
v = AMPLITUDE * math.sin(2.0 * math.pi * freq * tt)
else:
v = AMPLITUDE * (2.0 * ((freq * tt) % 1.0) - 1.0)
samples.append(int(v * 32767))
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(struct.pack(f'{len(samples)}h', *samples))
return buf.getvalue()
# ── Stream management ─────────────────────────────────────────────────────
def _open_stream(self):
@@ -289,6 +344,24 @@ def _wave_btn_style(active: bool = False) -> str:
}}
"""
def _ticket_btn_style(kind: str) -> str:
c = C_ACCEPT if kind == 'accept' else C_REJECT
return f"""
QPushButton {{
background-color: {C_PANEL};
color: {c};
border: 2px solid {c};
border-radius: 14px;
font-size: 20px;
font-weight: bold;
}}
QPushButton:pressed {{
background-color: {c};
color: {C_WHITE};
border: 2px solid {c};
}}
"""
def _vol_btn_style() -> str:
return f"""
QPushButton {{
@@ -435,6 +508,63 @@ class LedController:
time.sleep(STEP_MS)
# ─────────────────────────────────────────────────────────────────────────────
# A1000 card reader
# ─────────────────────────────────────────────────────────────────────────────
class CardReader(QObject):
"""Polls nfc_nl_demo in a loop. Emits card_detected(uid) on each successful read."""
card_detected = pyqtSignal(str)
def __init__(self):
super().__init__()
self._running = False
self._thread = None
self._proc = None
self.available = os.path.exists(_NFC_DEMO)
def start(self):
if not self.available:
return
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
def stop(self):
self._running = False
if self._proc:
try:
self._proc.terminate()
except Exception:
pass
def _poll_loop(self):
while self._running:
try:
self._proc = subprocess.Popen(
[_NFC_DEMO],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
out, _ = self._proc.communicate(timeout=15)
self._proc = None
if 'Target detected' in out:
uid = ''
for line in out.splitlines():
if 'UID' in line and ':' in line:
uid = line.split(':', 1)[1].strip()
self.card_detected.emit(uid)
except subprocess.TimeoutExpired:
if self._proc:
self._proc.kill()
self._proc = None
except Exception:
self._proc = None
if self._running:
time.sleep(0.1)
def _autocrop(img):
"""Crop a QImage (ARGB32) to the bounding box of non-transparent pixels."""
if not _HAS_NUMPY:
@@ -502,9 +632,12 @@ class MainWindow(QMainWindow):
self.audio = AudioEngine()
self.audio.signals.status.connect(self._on_audio_status)
self.leds = LedController()
self._card = CardReader()
self._card.card_detected.connect(self._on_card_detected)
self._build_ui()
self._set_bg(C_BG)
self.leds.start()
self._card.start()
# ── UI construction ───────────────────────────────────────────────────────
@@ -524,6 +657,8 @@ class MainWindow(QMainWindow):
root.addLayout(self._waveform_section())
root.addSpacing(10)
root.addLayout(self._volume_section())
root.addSpacing(8)
root.addLayout(self._ticket_section())
root.addStretch()
root.addWidget(self._divider())
root.addLayout(self._status_bar())
@@ -667,6 +802,42 @@ class MainWindow(QMainWindow):
col.addLayout(row)
return col
def _ticket_section(self) -> QVBoxLayout:
col = QVBoxLayout()
col.setSpacing(8)
hdr = QHBoxLayout()
lbl = QLabel("TICKET VALIDATOR")
lbl.setFont(QFont(_FONT_FAMILY, 10))
lbl.setStyleSheet(f"color: {C_GREY}; letter-spacing: 3px;")
self.card_status_lbl = QLabel()
self.card_status_lbl.setFont(QFont(_FONT_FAMILY, 10))
self._set_card_indicator(False)
hdr.addWidget(lbl)
hdr.addStretch()
hdr.addWidget(self.card_status_lbl)
col.addLayout(hdr)
row = QHBoxLayout()
row.setSpacing(14)
self.btn_accept = QPushButton("✓ ACCEPT")
self.btn_reject = QPushButton("✗ REJECT")
for btn, kind in ((self.btn_accept, 'accept'), (self.btn_reject, 'reject')):
btn.setMinimumHeight(72)
btn.setStyleSheet(_ticket_btn_style(kind))
btn.setFocusPolicy(Qt.NoFocus)
self.btn_accept.clicked.connect(self._play_accept)
self.btn_reject.clicked.connect(self._play_reject)
row.addWidget(self.btn_accept)
row.addWidget(self.btn_reject)
col.addLayout(row)
return col
def _status_bar(self) -> QHBoxLayout:
row = QHBoxLayout()
self.status_lbl = QLabel("Ready · SGTL5000 / ALSA")
@@ -739,6 +910,36 @@ class MainWindow(QMainWindow):
except TypeError:
pass
def _set_card_indicator(self, present: bool):
if present:
self.card_status_lbl.setText("● CARD")
self.card_status_lbl.setStyleSheet(f"color: {C_ACCEPT};")
else:
if self._card.available:
self.card_status_lbl.setText("○ NO CARD")
self.card_status_lbl.setStyleSheet(f"color: {C_GREY};")
else:
self.card_status_lbl.setText("○ NO READER")
self.card_status_lbl.setStyleSheet(f"color: {C_PANEL};")
def _on_card_detected(self, uid: str):
self._set_card_indicator(True)
if uid in _VALID_UIDS:
self.audio.play_accept()
self.status_lbl.setText(f"ACCEPTED ✓ UID: {uid}")
else:
self.audio.play_reject()
self.status_lbl.setText(f"REJECTED ✗ UID: {uid}")
QTimer.singleShot(3000, lambda: self._set_card_indicator(False))
def _play_accept(self):
self.audio.play_accept()
self.status_lbl.setText("Ticket ACCEPTED ✓")
def _play_reject(self):
self.audio.play_reject()
self.status_lbl.setText("Ticket REJECTED ✗")
def _on_audio_status(self, msg: str):
self.status_lbl.setText(msg)
@@ -746,6 +947,7 @@ class MainWindow(QMainWindow):
self._fb_snapshot = data
def closeEvent(self, event):
self._card.stop()
self.leds.stop()
self.audio.shutdown()
super().closeEvent(event)

View File

@@ -36,6 +36,8 @@ C_PINK_HOT = "#FF33BB" # P2 — hot pink, active / pressed state
C_PINK_LIGHT = "#FFADE4" # P4 — light pink, accents
C_GREY = "#F9F5F4" # Off-White — secondary labels
C_WHITE = "#FFFFFF"
C_ACCEPT = "#00C878" # green — ticket accepted
C_REJECT = "#FF3344" # red — ticket rejected
# ── Custom font (set after QApplication is created) ──────────────────────────
_FONT_FAMILY = "Sans Serif" # overwritten at startup if OptimismSans loads
@@ -139,6 +141,55 @@ class AudioEngine:
if self._pa is not None:
self._pa.terminate()
def play_accept(self):
"""Ascending two-tone chime: 880 Hz (120 ms) then 1320 Hz (200 ms)."""
self._play_oneshot([(880, 120, 'sine'), (1320, 200, 'sine')])
def play_reject(self):
"""Low sawtooth buzz: 220 Hz (400 ms)."""
self._play_oneshot([(220, 400, 'sawtooth')])
def _play_oneshot(self, segments):
wav = self._build_wav(segments)
def _run():
try:
proc = subprocess.Popen(
['aplay', '-q', '-'],
stdin=subprocess.PIPE, stderr=subprocess.DEVNULL,
)
proc.communicate(input=wav, timeout=3.0)
except Exception:
pass
threading.Thread(target=_run, daemon=True).start()
def _build_wav(self, segments):
"""Return WAV bytes for a list of (freq, duration_ms, waveform) tuples."""
samples = []
for freq, duration_ms, waveform in segments:
n = int(SAMPLE_RATE * duration_ms / 1000)
if _HAS_NUMPY:
t = np.arange(n, dtype=np.float64) / SAMPLE_RATE
if waveform == 'sine':
s = AMPLITUDE * np.sin(2.0 * math.pi * freq * t)
else:
s = AMPLITUDE * (2.0 * ((freq * t) % 1.0) - 1.0)
samples.extend((s * 32767).astype(np.int16).tolist())
else:
for i in range(n):
tt = i / SAMPLE_RATE
if waveform == 'sine':
v = AMPLITUDE * math.sin(2.0 * math.pi * freq * tt)
else:
v = AMPLITUDE * (2.0 * ((freq * tt) % 1.0) - 1.0)
samples.append(int(v * 32767))
buf = io.BytesIO()
with wave.open(buf, 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(struct.pack(f'{len(samples)}h', *samples))
return buf.getvalue()
# ── Stream management ─────────────────────────────────────────────────────
def _open_stream(self):
@@ -353,6 +404,24 @@ def _wave_btn_style(active: bool = False) -> str:
}}
"""
def _ticket_btn_style(kind: str) -> str:
c = C_ACCEPT if kind == 'accept' else C_REJECT
return f"""
QPushButton {{
background-color: {C_PANEL};
color: {c};
border: 2px solid {c};
border-radius: 18px;
font-size: 28px;
font-weight: bold;
}}
QPushButton:pressed {{
background-color: {c};
color: {C_WHITE};
border: 2px solid {c};
}}
"""
def _vol_btn_style() -> str:
return f"""
QPushButton {{
@@ -464,6 +533,8 @@ class MainWindow(QMainWindow):
root.addLayout(self._waveform_section())
root.addSpacing(16)
root.addLayout(self._volume_section())
root.addSpacing(12)
root.addLayout(self._ticket_section())
root.addStretch()
root.addWidget(self._divider())
root.addLayout(self._status_bar())
@@ -604,6 +675,33 @@ class MainWindow(QMainWindow):
col.addLayout(row)
return col
def _ticket_section(self) -> QVBoxLayout:
col = QVBoxLayout()
col.setSpacing(12)
lbl = QLabel("TICKET VALIDATOR")
lbl.setFont(QFont(_FONT_FAMILY, 14))
lbl.setStyleSheet(f"color: {C_GREY}; letter-spacing: 3px;")
col.addWidget(lbl)
row = QHBoxLayout()
row.setSpacing(20)
self.btn_accept = QPushButton("✓ ACCEPT")
self.btn_reject = QPushButton("✗ REJECT")
for btn, kind in ((self.btn_accept, 'accept'), (self.btn_reject, 'reject')):
btn.setMinimumHeight(110)
btn.setStyleSheet(_ticket_btn_style(kind))
btn.setFocusPolicy(Qt.NoFocus)
self.btn_accept.clicked.connect(self._play_accept)
self.btn_reject.clicked.connect(self._play_reject)
row.addWidget(self.btn_accept)
row.addWidget(self.btn_reject)
col.addLayout(row)
return col
def _status_bar(self) -> QHBoxLayout:
row = QHBoxLayout()
self.status_lbl = QLabel("Ready · SGTL5000 / ALSA")
@@ -676,6 +774,14 @@ class MainWindow(QMainWindow):
except TypeError:
pass
def _play_accept(self):
self.audio.play_accept()
self.status_lbl.setText("Ticket ACCEPTED ✓")
def _play_reject(self):
self.audio.play_reject()
self.status_lbl.setText("Ticket REJECTED ✗")
def _on_audio_status(self, msg: str):
self.status_lbl.setText(msg)