diff --git a/arrive_audio_ui.py b/arrive_audio_ui.py index 18c9729..def098a 100644 --- a/arrive_audio_ui.py +++ b/arrive_audio_ui.py @@ -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) diff --git a/arrive_audio_ui_imx8.py b/arrive_audio_ui_imx8.py index 1bdca1d..be0cca7 100644 --- a/arrive_audio_ui_imx8.py +++ b/arrive_audio_ui_imx8.py @@ -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)