Done
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user