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:
@@ -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