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:
@@ -37,6 +37,9 @@ VALID_SETTINGS = {
|
||||
"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_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 = {
|
||||
@@ -63,6 +66,8 @@ DEFAULTS = {
|
||||
"print.font_ingredient": "0:0:0",
|
||||
"print.font_item_note": "0:0:0",
|
||||
"print.font_order_note": "0:1:0",
|
||||
"print.beep_on_ticket": "true",
|
||||
"print.beep_pattern": "double",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -133,6 +133,15 @@ def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User
|
||||
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)
|
||||
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()
|
||||
|
||||
@@ -66,6 +66,8 @@ _PRINT_SETTING_KEYS = [
|
||||
"print.font_ingredient",
|
||||
"print.font_item_note",
|
||||
"print.font_order_note",
|
||||
"print.beep_on_ticket",
|
||||
"print.beep_pattern",
|
||||
]
|
||||
|
||||
_PRINT_SETTING_DEFAULTS = {
|
||||
@@ -80,6 +82,17 @@ _PRINT_SETTING_DEFAULTS = {
|
||||
"print.font_ingredient": "0:0:0",
|
||||
"print.font_item_note": "0:0: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):
|
||||
@@ -179,6 +192,21 @@ def is_spoof_mode() -> bool:
|
||||
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]:
|
||||
if is_spoof_mode():
|
||||
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'\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()
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ const FONT_DEFAULTS = {
|
||||
'print.font_order_note': '0:1:0',
|
||||
'print.divider_style': 'dash',
|
||||
'print.ticket_mode': 'detailed',
|
||||
'print.beep_on_ticket': 'true',
|
||||
'print.beep_pattern': 'double',
|
||||
}
|
||||
|
||||
// ── 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 ───────────────────────────────────────────────────────
|
||||
|
||||
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
|
||||
@@ -638,7 +823,16 @@ export default function PrintFontsTab() {
|
||||
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 style={{ padding: '16px 20px' }}>
|
||||
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
|
||||
@@ -664,7 +858,7 @@ export default function PrintFontsTab() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 4. Divider style */}
|
||||
{/* 5. Divider style */}
|
||||
<div className="card divide-y divide-gray-100">
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>
|
||||
|
||||
Reference in New Issue
Block a user