feat: printer beep support for kitchen tickets

Adds configurable buzzer control so the printer alerts kitchen staff
on each new ticket print.

Backend:
  - printer_service.py: ESC 0x07 beep command fired before paper cut,
    reads print.beep_on_ticket / print.beep_pattern settings; supports
    single/double/triple/long presets and a custom:n1:n2:n3 format
  - settings.py: registers the two new print.beep_* settings with
    defaults (beep_on_ticket=true, beep_pattern=double)
  - system.py: POST /api/system/printers/test-beep endpoint for
    live testing from the dashboard

Frontend (PrintFontsTab):
  - BeepSection component with preset grid, custom n1/n2/n3 inputs,
    and a live test-beep button targeting any configured printer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 23:53:58 +03:00
parent b9d6055c48
commit 607e78ea82
4 changed files with 254 additions and 2 deletions

View File

@@ -37,6 +37,9 @@ VALID_SETTINGS = {
"print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS", "print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS",
"print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS", "print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS",
"print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS", "print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS",
# Beep settings
"print.beep_on_ticket": "Play beep when a kitchen ticket prints: 'true' | 'false'",
"print.beep_pattern": "Beep pattern: 'single' | 'double' | 'triple' | 'long' | 'custom:n1:n2:n3'",
} }
DEFAULTS = { DEFAULTS = {
@@ -63,6 +66,8 @@ DEFAULTS = {
"print.font_ingredient": "0:0:0", "print.font_ingredient": "0:0:0",
"print.font_item_note": "0:0:0", "print.font_item_note": "0:0:0",
"print.font_order_note": "0:1:0", "print.font_order_note": "0:1:0",
"print.beep_on_ticket": "true",
"print.beep_pattern": "double",
} }

View File

@@ -133,6 +133,15 @@ def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User
return {"success": success, "error": error} return {"success": success, "error": error}
@router.post("/printers/test-beep")
def test_beep(printer_id: int, n1: int = 2, n2: int = 2, n3: int = 1, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_beep(printer.ip_address, printer.port, n1, n2, n3)
return {"success": success, "error": error}
@router.put("/printers/{printer_id}", response_model=PrinterOut) @router.put("/printers/{printer_id}", response_model=PrinterOut)
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)): def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first() printer = db.query(Printer).filter(Printer.id == printer_id).first()

View File

@@ -66,6 +66,8 @@ _PRINT_SETTING_KEYS = [
"print.font_ingredient", "print.font_ingredient",
"print.font_item_note", "print.font_item_note",
"print.font_order_note", "print.font_order_note",
"print.beep_on_ticket",
"print.beep_pattern",
] ]
_PRINT_SETTING_DEFAULTS = { _PRINT_SETTING_DEFAULTS = {
@@ -80,6 +82,17 @@ _PRINT_SETTING_DEFAULTS = {
"print.font_ingredient": "0:0:0", "print.font_ingredient": "0:0:0",
"print.font_item_note": "0:0:0", "print.font_item_note": "0:0:0",
"print.font_order_note": "0:1:0", "print.font_order_note": "0:1:0",
"print.beep_on_ticket": "true",
"print.beep_pattern": "double",
}
# Beep patterns: (n1=on×100ms, n2=off×100ms, n3=count)
# Using ESC BEL n1 n2 n3 (0x1B 0x07 n1 n2 n3)
_BEEP_PATTERNS = {
"single": (2, 2, 1), # one 200ms beep
"double": (1, 1, 2), # two short beeps
"triple": (1, 1, 3), # three short beeps
"long": (5, 2, 1), # one 500ms beep
} }
# SIZE byte values (ESC ! base, no bold bit): # SIZE byte values (ESC ! base, no bold bit):
@@ -179,6 +192,21 @@ def is_spoof_mode() -> bool:
db.close() db.close()
def send_test_beep(ip: str, port: int, n1: int, n2: int, n3: int) -> Tuple[bool, str]:
"""Send a standalone beep-only job. n1=on-time×100ms, n2=off-time×100ms, n3=count."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping test beep")
return True, ""
try:
p = _get_printer(ip, port)
p._raw(bytes([0x1b, 0x07, n1, n2, n3]))
p.close()
return True, ""
except Exception as e:
logger.error("Test beep failed for %s:%s%s", ip, port, e)
return False, str(e)
def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]: def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
if is_spoof_mode(): if is_spoof_mode():
logger.info("Spoof printing ON — dropping test print for %s", name) logger.info("Spoof printing ON — dropping test print for %s", name)
@@ -563,6 +591,22 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
p._raw(b'\x1b\x21\x00') p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n') p._raw(b'\n\n\n')
# Beep before cut so the buzzer fires as the paper advances
beep_enabled = cfg.get("print.beep_on_ticket", "true") == "true"
if beep_enabled:
pattern = cfg.get("print.beep_pattern", "double")
if pattern.startswith("custom:"):
# custom:n1:n2:n3
try:
_, n1s, n2s, n3s = pattern.split(":")
beep_bytes = (int(n1s), int(n2s), int(n3s))
except (ValueError, TypeError):
beep_bytes = _BEEP_PATTERNS["double"]
else:
beep_bytes = _BEEP_PATTERNS.get(pattern, _BEEP_PATTERNS["double"])
p._raw(bytes([0x1b, 0x07, beep_bytes[0], beep_bytes[1], beep_bytes[2]]))
p.cut() p.cut()

View File

@@ -42,6 +42,8 @@ const FONT_DEFAULTS = {
'print.font_order_note': '0:1:0', 'print.font_order_note': '0:1:0',
'print.divider_style': 'dash', 'print.divider_style': 'dash',
'print.ticket_mode': 'detailed', 'print.ticket_mode': 'detailed',
'print.beep_on_ticket': 'true',
'print.beep_pattern': 'double',
} }
// ── Preview ──────────────────────────────────────────────────────────────── // ── Preview ────────────────────────────────────────────────────────────────
@@ -347,6 +349,189 @@ function TicketModeSection({ value, onChange, isPending, printers }) {
) )
} }
// ── Beep section ───────────────────────────────────────────────────────────
const BEEP_PRESETS = [
{ id: 'off', label: 'Χωρίς Ήχο', desc: 'Χωρίς beep κατά την εκτύπωση' },
{ id: 'single', label: 'Μονό', desc: '1× παλμός 200ms' },
{ id: 'double', label: 'Διπλό', desc: '2× σύντομοι παλμοί (προτεινόμενο)' },
{ id: 'triple', label: 'Τριπλό', desc: '3× σύντομοι παλμοί — πιο εντονο' },
{ id: 'long', label: 'Μακρύ', desc: '1× παλμός 500ms' },
{ id: 'custom', label: 'Προσαρμοσμένο', desc: 'Ορίστε τη διάρκεια χειροκίνητα' },
]
// Parse beep settings → { preset, n1, n2, n3 }
function decodeBeep(patternVal, enabledVal) {
if (enabledVal === 'false') return { preset: 'off', n1: 2, n2: 2, n3: 1 }
if (!patternVal || patternVal === 'double') return { preset: 'double', n1: 1, n2: 1, n3: 2 }
if (patternVal.startsWith('custom:')) {
const [, n1, n2, n3] = patternVal.split(':')
return { preset: 'custom', n1: parseInt(n1) || 2, n2: parseInt(n2) || 2, n3: parseInt(n3) || 1 }
}
const presetMap = { single: [2,2,1], double: [1,1,2], triple: [1,1,3], long: [5,2,1] }
const [n1, n2, n3] = presetMap[patternVal] ?? [1,1,2]
return { preset: patternVal, n1, n2, n3 }
}
function BeepSection({ beepEnabled, beepPattern, onChange, isPending, printers }) {
const { preset: initPreset, n1: iN1, n2: iN2, n3: iN3 } = decodeBeep(beepPattern, beepEnabled)
const [preset, setPreset] = useState(initPreset)
const [n1, setN1] = useState(iN1)
const [n2, setN2] = useState(iN2)
const [n3, setN3] = useState(iN3)
const [testPrinter, setTestPrinter] = useState(null)
const [testing, setTesting] = useState(false)
useEffect(() => {
if (printers.length > 0 && !testPrinter) {
setTestPrinter((printers.find(p => p.is_active) ?? printers[0]).id)
}
}, [printers])
// Sync local state if parent settings change (e.g. after save)
useEffect(() => {
const decoded = decodeBeep(beepPattern, beepEnabled)
setPreset(decoded.preset)
setN1(decoded.n1); setN2(decoded.n2); setN3(decoded.n3)
}, [beepPattern, beepEnabled])
function handlePresetChange(id) {
setPreset(id)
if (id === 'off') {
onChange('print.beep_on_ticket', 'false')
} else if (id === 'custom') {
onChange('print.beep_on_ticket', 'true')
onChange('print.beep_pattern', `custom:${n1}:${n2}:${n3}`)
} else {
onChange('print.beep_on_ticket', 'true')
onChange('print.beep_pattern', id)
}
}
function handleCustomChange(field, raw) {
const v = Math.max(1, Math.min(30, parseInt(raw) || 1))
const next = { n1, n2, n3, [field]: v }
if (field === 'n1') setN1(v)
if (field === 'n2') setN2(v)
if (field === 'n3') setN3(v)
onChange('print.beep_pattern', `custom:${next.n1}:${next.n2}:${next.n3}`)
}
// Compute beep bytes from current state for test
function getBeepBytes() {
if (preset === 'custom') return { n1, n2, n3 }
const presetMap = { single: [2,2,1], double: [1,1,2], triple: [1,1,3], long: [5,2,1] }
const [bn1, bn2, bn3] = presetMap[preset] ?? [1,1,2]
return { n1: bn1, n2: bn2, n3: bn3 }
}
async function handleTestBeep() {
if (!testPrinter || preset === 'off') return
const { n1: b1, n2: b2, n3: b3 } = getBeepBytes()
setTesting(true)
try {
const res = await client.post(`/api/system/printers/test-beep?printer_id=${testPrinter}&n1=${b1}&n2=${b2}&n3=${b3}`)
if (res.data.success) toast.success('Beep στάλθηκε!')
else toast.error(`Σφάλμα: ${res.data.error}`)
} catch {
toast.error('Σφάλμα επικοινωνίας')
} finally {
setTesting(false)
}
}
return (
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Ήχος Εκτυπωτή (Beep)</h2>
<p className="text-xs text-gray-400 mt-0.5">
Ο εκτυπωτής χτυπά για να ειδοποιεί την κουζίνα σε κάθε νέα παραγγελία.
</p>
</div>
{/* Preset grid */}
<div style={{ display: 'flex', gap: 10, padding: '16px 20px', flexWrap: 'wrap' }}>
{BEEP_PRESETS.map(opt => {
const active = preset === opt.id
return (
<button
key={opt.id}
onClick={() => handlePresetChange(opt.id)}
disabled={isPending}
style={{
flex: '1 1 140px', textAlign: 'left', padding: '12px 14px',
borderRadius: 10, cursor: isPending ? 'default' : 'pointer',
border: `2px solid ${active ? '#3758c9' : '#e5e7eb'}`,
background: active ? '#eff3ff' : 'white',
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: active ? '#3758c9' : '#111315', marginBottom: 3 }}>
{opt.label}
</div>
<div style={{ fontSize: 11, color: '#6b7280', lineHeight: 1.4 }}>{opt.desc}</div>
</button>
)
})}
</div>
{/* Custom fields */}
{preset === 'custom' && (
<div style={{ padding: '14px 20px', display: 'flex', gap: 20, alignItems: 'flex-end', flexWrap: 'wrap', background: '#fafafa' }}>
{[
{ field: 'n1', label: 'Διάρκεια ON (×100ms)', val: n1, hint: 'π.χ. 3 = 300ms' },
{ field: 'n2', label: 'Διάρκεια OFF (×100ms)', val: n2, hint: 'Διάλλειμα μεταξύ παλμών' },
{ field: 'n3', label: 'Επαναλήψεις', val: n3, hint: 'Πόσες φορές' },
].map(({ field, label, val, hint }) => (
<div key={field} style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 140px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>{label}</label>
<input
type="number" min={1} max={30} value={val}
onChange={e => handleCustomChange(field, e.target.value)}
disabled={isPending}
style={{ ...inputStyle, width: '100%' }}
/>
<span style={{ fontSize: 10, color: '#9ca3af' }}>{hint}</span>
</div>
))}
</div>
)}
{/* Test beep */}
<div style={{ padding: '14px 20px', display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: '#374151', fontWeight: 500 }}>Δοκιμαστικό Beep:</span>
{printers.length > 0 ? (
<>
<select
value={testPrinter ?? ''}
onChange={e => setTestPrinter(Number(e.target.value))}
disabled={testing}
style={{ ...inputStyle, width: 200 }}
>
{printers.map(p => <option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>)}
</select>
<button
onClick={handleTestBeep}
disabled={testing || preset === 'off' || !testPrinter}
style={{
...btnSecondary,
opacity: (testing || preset === 'off') ? 0.5 : 1,
cursor: (testing || preset === 'off') ? 'default' : 'pointer',
}}
>
{testing ? 'Αποστολή…' : '🔔 Τεστ Beep'}
</button>
{preset === 'off' && (
<span style={{ fontSize: 12, color: '#9ca3af' }}>Ο ήχος είναι απενεργοποιημένος</span>
)}
</>
) : (
<span style={{ fontSize: 12, color: '#ef4444' }}>Δεν υπάρχουν εκτυπωτές</span>
)}
</div>
</div>
)
}
// ── Printers section ─────────────────────────────────────────────────────── // ── Printers section ───────────────────────────────────────────────────────
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }] const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
@@ -638,7 +823,16 @@ export default function PrintFontsTab() {
printers={printers} printers={printers}
/> />
{/* 3. Font sizes — grouped */} {/* 3. Beep settings */}
<BeepSection
beepEnabled={val('print.beep_on_ticket')}
beepPattern={val('print.beep_pattern')}
onChange={handleChange}
isPending={updateMut.isPending}
printers={printers}
/>
{/* 4. Font sizes */}
<div className="card divide-y divide-gray-100"> <div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}> <div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2> <h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
@@ -664,7 +858,7 @@ export default function PrintFontsTab() {
))} ))}
</div> </div>
{/* 4. Divider style */} {/* 5. Divider style */}
<div className="card divide-y divide-gray-100"> <div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}> <div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2> <h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>