diff --git a/PLANS AND STRATEGIES/PRINTER_BEEP_STRATEGY.md b/PLANS AND STRATEGIES/PRINTER_BEEP_STRATEGY.md
new file mode 100644
index 0000000..39b36cf
--- /dev/null
+++ b/PLANS AND STRATEGIES/PRINTER_BEEP_STRATEGY.md
@@ -0,0 +1,85 @@
+# Printer Beep Strategy
+
+## How beeping works on the Jolimark TP850UE
+
+The printer has a built-in buzzer. Three commands are available:
+
+| Command | Bytes | Parameters | Notes |
+|---------|-------|------------|-------|
+| `BEL` | `0x07` | none | Single short beep, ~50ms. Simplest. |
+| `ESC BEL n1 n2 n3` | `0x1B 0x07 n1 n2 n3` | n1=on-time (×100ms), n2=off-time (×100ms), n3=count | Full control over length, gap, repetitions. |
+| `GS BEL n1 n2 n3` | `0x1D 0x07 n1 n2 n3` | n1=count, n2=on-time (×100ms), n3=off-time (×100ms) | Same as ESC BEL but parameter order differs. |
+
+**Confirmed working on our test:** `ESC BEL` with `n1=2, n2=2, n3=1` = one 200ms beep.
+Pattern beeps also work: `ESC BEL 1 1 3` = three short beeps in quick succession.
+
+The beep is triggered by sending these bytes **immediately before or after** the print job —
+it does not need to be part of a complete print page. You can send a beep-only job
+(connect, send beep bytes, close) without printing anything.
+
+---
+
+## Where to add beeps in the system
+
+### 1. New kitchen ticket arrives (MOST IMPORTANT)
+**Where:** `printer_service.py` → `_print_kitchen_ticket()`, just before or after the cut command.
+**Pattern:** 2 short beeps — signals a new order without being annoying.
+```python
+p._raw(bytes([0x1b, 0x07, 1, 1, 2])) # 2× 100ms beeps
+p.cut()
+```
+
+### 2. Re-print of an existing ticket
+**Where:** Same function, but only 1 beep to distinguish from a new order.
+```python
+p._raw(bytes([0x1b, 0x07, 1, 2, 1])) # 1× 100ms beep
+```
+
+### 3. Urgent / rush order (future feature)
+**Where:** If we add an "urgent" flag to orders, trigger a longer or triple beep.
+```python
+p._raw(bytes([0x1b, 0x07, 3, 1, 3])) # 3× 300ms beeps
+```
+
+### 4. Test print
+**Where:** `send_test_print()` — already sends a test page, add 1 beep so the cook knows
+to look at the printer.
+
+---
+
+## Implementation plan (when ready)
+
+1. **Add a per-printer setting:** `print.beep_on_ticket` = `true`/`false`
+ (some stations may not want beeping, e.g. a bar printer near customers)
+
+2. **Add a beep pattern setting:** `print.beep_pattern` = `single` / `double` / `triple`
+
+3. **In `_print_kitchen_ticket`:** After building the ticket, before `p.cut()`:
+ ```python
+ if beep_enabled:
+ p._raw(beep_bytes_for_pattern)
+ ```
+
+4. **No separate beep job needed** — bake it into the ticket job. The buzzer fires
+ as the paper is cutting, which is the natural attention signal.
+
+---
+
+## Settings keys to add (future)
+
+```
+print.beep_on_ticket "true" / "false" default: "true"
+print.beep_pattern "single" / "double" / "triple" default: "double"
+```
+
+These can go in the Printer management UI (per-printer toggle) and in the
+Print Settings tab (global default pattern).
+
+---
+
+## Notes
+
+- Beep bytes are sent over the same TCP socket as print data — no separate connection needed.
+- The buzzer is hardware-limited; very short intervals (< 50ms) may be ignored.
+- Beeping does NOT require paper to be loaded or printing to succeed — it fires independently.
+- If spoof-printing mode is ON, the beep should also be suppressed (no real connection is made).
diff --git a/local_backend/main.py b/local_backend/main.py
index b0f54ec..05e5692 100644
--- a/local_backend/main.py
+++ b/local_backend/main.py
@@ -177,6 +177,10 @@ def _run_migrations():
"ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0",
# Auto-expand flag for sub-categories on the PWA accordion
"ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0",
+ # Printer protocol field
+ "ALTER TABLE printers ADD COLUMN protocol VARCHAR NOT NULL DEFAULT 'escpos_tcp'",
+ # Compact (half-width) display flag for quick options
+ "ALTER TABLE product_quick_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
]
for sql in migrations:
try:
diff --git a/local_backend/models/printer.py b/local_backend/models/printer.py
index e609e51..8f8de7d 100644
--- a/local_backend/models/printer.py
+++ b/local_backend/models/printer.py
@@ -11,6 +11,7 @@ class Printer(Base):
ip_address = Column(String, nullable=False)
port = Column(Integer, default=9100, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
+ protocol = Column(String, default="escpos_tcp", nullable=False)
products = relationship("Product", back_populates="printer_zone")
print_logs = relationship("PrintLog", back_populates="printer")
diff --git a/local_backend/models/product.py b/local_backend/models/product.py
index ea2436b..29c5463 100644
--- a/local_backend/models/product.py
+++ b/local_backend/models/product.py
@@ -72,6 +72,7 @@ class ProductQuickOption(Base):
sort_order = Column(Integer, default=0, nullable=False)
is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False)
+ is_compact = Column(Boolean, default=False, nullable=False)
product = relationship("Product", back_populates="quick_options")
diff --git a/local_backend/print_test.py b/local_backend/print_test.py
index 325128d..a304eb4 100644
--- a/local_backend/print_test.py
+++ b/local_backend/print_test.py
@@ -1,305 +1,343 @@
"""
-Printer font & symbol test script.
-Usage (inside Docker): python print_test.py [IP] [PORT]
-Defaults to 10.98.20.25:9100
+Printer comprehensive test script — Jolimark TP850UE
+Usage: python print_test.py [IP] [PORT]
+Default: 10.98.20.25:9100
+
+Prints 6 pages:
+ Page 1 — ESC ! modes, Font A, English
+ Page 2 — ESC ! modes, Font B, English
+ Page 3 — ESC ! modes, Font A, Greek
+ Page 4 — ESC ! modes, Font B, Greek
+ Page 5 — GS ! character size multipliers (both fonts)
+ Page 6 — Beep tests + misc (underline, invert, symbols)
+
+ESC ! (0x1B 0x21 n) correct bit map for TP850UE:
+ Bit 0 (0x01) — Font B instead of Font A
+ Bit 3 (0x08) — Emphasize / Bold
+ Bit 4 (0x10) — Double-height
+ Bit 5 (0x20) — Double-width
+ Bit 7 (0x80) — Underline
+
+GS ! (0x1D 0x21 n) character size multiplier:
+ Low nibble (bits 0-3): height multiplier (0=1x, 1=2x, 2=3x … 7=8x)
+ High nibble (bits 4-7): width multiplier (0=1x, 1=2x, 2=3x … 7=8x)
+ e.g. n=0x00 → 1×1, n=0x11 → 2×2, n=0x22 → 3×3, n=0x77 → 8×8
"""
import sys
+import time
PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25"
PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
from escpos.printer import Network
+
+# ── Low-level helpers ──────────────────────────────────────────────────────────
+
def _gr(text: str) -> bytes:
return text.encode('cp737', errors='replace')
def _open():
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
- p._raw(b'\x1b\x40') # ESC @ reset
- p._raw(b'\x1b\x74\x1d') # CP737 Greek code page
+ p._raw(b'\x1b\x40') # ESC @ — full reset
+ p._raw(b'\x1b\x74\x1d') # ESC t 29 — CP737 Greek code page
return p
def _t(p, text: str):
p._raw(_gr(text))
+def _reset(p):
+ """Reset to: Font A, normal size, no bold, left-align."""
+ p._raw(b'\x1b\x4d\x00') # ESC M 0 — Font A
+ p._raw(b'\x1b\x21\x00') # ESC ! 0 — normal
+ p._raw(b'\x1d\x21\x00') # GS ! 0 — 1×1 size
+ p._raw(b'\x1b\x45\x00') # ESC E 0 — bold off
+ p._raw(b'\x1b\x61\x00') # ESC a 0 — left align
+
+def _center(p): p._raw(b'\x1b\x61\x01')
+def _left(p): p._raw(b'\x1b\x61\x00')
+
def _divider(p, char="-", width=48):
- p._raw(b'\x1b\x61\x00')
+ _left(p)
_t(p, char * width + "\n")
-def _center(p):
- p._raw(b'\x1b\x61\x01')
+def _page_header(p, title: str):
+ _center(p)
+ p._raw(b'\x1b\x21\x28') # double-width + bold (bits 3+5 = 0x28)
+ _t(p, title + "\n")
+ _reset(p)
+ _divider(p, "=")
-def _left(p):
- p._raw(b'\x1b\x61\x00')
-# ── ESC ! n byte reference ──────────────────────────────────────────────────
-# Bit 0 → underline (not tested, minor)
-# Bit 1 → double-strike (bold)
-# Bit 3 → double-height
-# Bit 4 → double-width
-# Bit 5 → delete-line
-# Bit 7 → bold (ESC E alias in some models)
-# Common combos used here:
-# 0x00 = normal
-# 0x08 = double-height only (48 chars wide)
-# 0x10 = double-height only (alt bit) (48 chars wide)
-# 0x18 = double-height + bold
-# 0x20 = double-width only (24 chars wide)
-# 0x30 = double-width + double-height (24 chars wide)
-# 0x38 = double-width + double-height + bold
-# 0x48 = double-height (bit 6 combo — some printers)
+# ── ESC ! mode table ───────────────────────────────────────────────────────────
+#
+# Each entry: (esc_bang_byte, esc_e_bold, label)
+# esc_bang_byte sets the mode via ESC ! n
+# esc_e_bold adds ESC E on top (independent bold layer)
+# We test every useful combination so you can see the exact visual result.
-MODES = [
- (0x00, "Normal (0x00)"),
- (0x08, "Double-height bit3 (0x08)"),
- (0x10, "Double-height bit4 (0x10)"),
- (0x18, "Double-height + Bold (0x18)"),
- (0x20, "Double-width (0x20)"),
- (0x30, "Double-width + Double-height (0x30)"),
- (0x38, "Double-width + Double-height + Bold (0x38)"),
+ESC_BANG_MODES = [
+ # (byte, extra_bold, label)
+ (0x00, False, "0x00 Normal"),
+ (0x00, True, "0x00 +ESC E Normal + Bold (ESC E)"),
+ (0x08, False, "0x08 Bold only (bit3)"),
+ (0x10, False, "0x10 Double-height (bit4)"),
+ (0x10, True, "0x10 +ESC E Double-height + Bold"),
+ (0x18, False, "0x18 Double-height + Bold (bits 3+4)"),
+ (0x20, False, "0x20 Double-width (bit5)"),
+ (0x20, True, "0x20 +ESC E Double-width + Bold"),
+ (0x28, False, "0x28 Double-width + Bold (bits 3+5)"),
+ (0x30, False, "0x30 Double-width + Double-height (bits 4+5)"),
+ (0x38, False, "0x38 Double-width + Double-height + Bold (bits 3+4+5)"),
]
-# ── Section 1 — Font sizes & styles, English ───────────────────────────────
-def section_english(p):
- _center(p)
- p._raw(b'\x1b\x21\x38')
- _t(p, "=== FONT SIZES (EN) ===\n")
- p._raw(b'\x1b\x21\x00')
- _divider(p, "=")
+def _esc_bang_section(p, english: bool):
+ lang = "EN" if english else "GR"
+ sample_normal = "TEST PRINT Hello 123" if english else "ΔΟΚΙΜΗ ΕΚΤΥΠΩΣΗΣ"
+ sample_lower = "test print hello 123" if english else "δοκιμη εκτυπωσης"
- for code, label in MODES:
+ for (byte_val, extra_bold, label) in ESC_BANG_MODES:
_left(p)
- _t(p, f"[{label}]\n")
- p._raw(bytes([0x1b, 0x21, code]))
- _t(p, "TEST PRINT Hello World Abc123\n")
+ # Print the label in small normal text first
p._raw(b'\x1b\x21\x00')
- # bold on/off via ESC E
- _t(p, " -> bold ON: ")
- p._raw(b'\x1b\x45\x01')
- p._raw(bytes([0x1b, 0x21, code]))
- _t(p, "Bold Sample\n")
p._raw(b'\x1b\x45\x00')
- p._raw(b'\x1b\x21\x00')
+ _t(p, f"[{label}]\n")
+
+ # Apply mode
+ p._raw(bytes([0x1b, 0x21, byte_val]))
+ if extra_bold:
+ p._raw(b'\x1b\x45\x01')
+
+ _t(p, sample_normal + "\n")
+ _t(p, sample_lower + "\n")
+
+ # Reset
+ _reset(p)
_t(p, "\n")
_divider(p)
- p._raw(b'\n')
-# ── Section 2 — Font sizes & styles, Greek ─────────────────────────────────
-def section_greek(p):
- _center(p)
- p._raw(b'\x1b\x21\x38')
- _t(p, "=== FONT SIZES (GR) ===\n")
- p._raw(b'\x1b\x21\x00')
- _divider(p, "=")
+# ── Pages 1–4: ESC ! modes ────────────────────────────────────────────────────
- for code, label in MODES:
+def page_esc_bang(font_b: bool, english: bool):
+ font_label = "Font B (8x16 small)" if font_b else "Font A (12x24 standard)"
+ lang_label = "GREEK" if not english else "ENGLISH"
+ p = _open()
+
+ # Select font
+ p._raw(b'\x1b\x4d\x01' if font_b else b'\x1b\x4d\x00')
+
+ _page_header(p, f"ESC! MODES — {lang_label} — {font_label[:6]}")
+ _t(p, f"Font: {font_label}\n")
+ _divider(p)
+
+ _esc_bang_section(p, english)
+
+ p._raw(b'\n\n\n')
+ p.cut()
+ p.close()
+
+
+# ── Page 5: GS ! size multipliers ─────────────────────────────────────────────
+
+# Combinations worth seeing: square multipliers + some asymmetric
+GS_SIZES = [
+ (0x00, "1x1 normal"),
+ (0x01, "1w x 2h"),
+ (0x10, "2w x 1h"),
+ (0x11, "2x2"),
+ (0x22, "3x3"),
+ (0x33, "4x4"),
+ (0x44, "5x5"),
+ (0x55, "6x6"),
+ (0x02, "1w x 3h"),
+ (0x20, "3w x 1h"),
+ (0x21, "3w x 2h"),
+ (0x12, "2w x 3h"),
+]
+
+def page_gs_sizes():
+ p = _open()
+ _page_header(p, "GS! SIZE MULTIPLIERS")
+ _t(p, "GS ! n (0x1D 0x21 n)\n")
+ _t(p, "Low nibble=height, High nibble=width\n")
+ _divider(p)
+
+ for (byte_val, label) in GS_SIZES:
_left(p)
- _t(p, f"[{label}]\n")
- p._raw(bytes([0x1b, 0x21, code]))
- _t(p, "ΔΟΚΙΜΑΣΤΙΚΗ ΕΚΤΥΠΩΣΗ\n")
- _t(p, "δοκιμαστικη εκτυπωση\n") # lowercase
- p._raw(b'\x1b\x21\x00')
- p._raw(b'\x1b\x45\x01')
- p._raw(bytes([0x1b, 0x21, code]))
- _t(p, "Bold: Καλημερα Κοσμε\n")
- p._raw(b'\x1b\x45\x00')
+ # Label in tiny normal text
p._raw(b'\x1b\x21\x00')
+ p._raw(b'\x1d\x21\x00')
+ _t(p, f"[n=0x{byte_val:02X} {label}]\n")
+
+ # Font A sample
+ p._raw(b'\x1b\x4d\x00')
+ p._raw(bytes([0x1d, 0x21, byte_val]))
+ _t(p, "Aa SAMPLE\n")
+ p._raw(b'\x1d\x21\x00')
+
+ # Font B sample on same size
+ p._raw(b'\x1b\x4d\x01')
+ p._raw(bytes([0x1d, 0x21, byte_val]))
+ _t(p, "Bb SMALL\n")
+ p._raw(b'\x1d\x21\x00')
+ p._raw(b'\x1b\x4d\x00') # back to Font A
+
_t(p, "\n")
_divider(p)
- p._raw(b'\n')
-
-# ── Section 3 — All printable ASCII symbols ────────────────────────────────
-
-def section_ascii_symbols(p):
- _center(p)
- p._raw(b'\x1b\x21\x18') # double-height bold for header
- _t(p, "=== ASCII SYMBOLS ===\n")
- p._raw(b'\x1b\x21\x00')
- _divider(p, "=")
- _left(p)
-
- # Printable ASCII 0x20 – 0x7E, 16 per line
- chars = [chr(c) for c in range(0x20, 0x7F)]
- line = ""
- for i, ch in enumerate(chars):
- line += ch + " "
- if (i + 1) % 24 == 0:
- _t(p, line + "\n")
- line = ""
- if line:
- _t(p, line + "\n")
+ # Also show GS ! combined with ESC ! bold
_t(p, "\n")
- _t(p, "Notable:\n")
- _t(p, " Bullets : * + - # @ ! ? > < | / \\ ^ ~ _\n")
- _t(p, " Framing : [ ] { } ( ) = : ; , . \"\n")
- _t(p, " Currency : $ %\n")
- _divider(p)
- p._raw(b'\n')
-
-# ── Section 4 — CP737 extended chars (0x80–0xFF) ───────────────────────────
-
-def section_extended(p):
- _center(p)
- p._raw(b'\x1b\x21\x18')
- _t(p, "=== CP737 EXTENDED (0x80-FF) ===\n")
- p._raw(b'\x1b\x21\x00')
_divider(p, "=")
- _left(p)
- _t(p, "Hex offset rows x16 columns\n\n")
-
- for row in range(8): # 0x80, 0x90 ... 0xF0
- base = 0x80 + row * 16
- row_bytes = bytes(range(base, base + 16))
- label = f"0x{base:02X}: "
- p._raw(_gr(label))
- p._raw(row_bytes)
- p._raw(b'\n')
-
- _t(p, "\n")
- _t(p, "Key CP737 specials:\n")
- specials = [
- (0xB3, "─ thin horiz line"),
- (0xC4, "─ double line"),
- (0xBA, "│ vert line"),
- (0xBB, "┐ corner"),
- (0xBC, "┘ corner"),
- (0xC9, "╔ corner"),
- (0xCA, "╩ junction"),
- (0xCB, "╦ junction"),
- (0xCC, "╠ junction"),
- (0xCD, "═ double horiz"),
- (0xCE, "╬ cross"),
- (0xC8, "╚ corner"),
- (0xBB, "╗ corner"),
- (0xBC, "╝ corner"),
- (0xDB, "█ full block"),
- (0xDC, "▄ lower block"),
- (0xDF, "▀ upper block"),
- (0xB0, "░ light shade"),
- (0xB1, "▒ medium shade"),
- (0xB2, "▓ dark shade"),
- (0xF8, "° degree"),
- (0xF9, "· middle dot"),
- (0xFA, "· bullet dot"),
- (0xFB, "√ check / tick"),
- (0xFE, "■ filled square"),
- ]
- for code, desc in specials:
- row_bytes = bytes([code, 0x20]) # char + space
- p._raw(row_bytes)
- _t(p, f" {desc}\n")
-
- _divider(p)
- p._raw(b'\n')
-
-# ── Section 5 — Underline ──────────────────────────────────────────────────
-
-def section_underline(p):
- _center(p)
- p._raw(b'\x1b\x21\x18')
- _t(p, "=== UNDERLINE TEST ===\n")
p._raw(b'\x1b\x21\x00')
+ _t(p, "GS! + ESC E bold combined:\n")
_divider(p, "=")
- _left(p)
+ for (byte_val, label) in [(0x11,"2x2"), (0x22,"3x3"), (0x33,"4x4")]:
+ p._raw(b'\x1b\x21\x00')
+ p._raw(b'\x1d\x21\x00')
+ _t(p, f"[{label} + bold]\n")
+ p._raw(b'\x1b\x45\x01')
+ p._raw(bytes([0x1d, 0x21, byte_val]))
+ _t(p, "BOLD LARGE\n")
+ p._raw(b'\x1b\x45\x00')
+ p._raw(b'\x1d\x21\x00')
+ _t(p, "\n")
- # ESC - n : underline 0=off, 1=thin, 2=thick
+ p._raw(b'\n\n\n')
+ p.cut()
+ p.close()
+
+
+# ── Page 6: Beep + misc ────────────────────────────────────────────────────────
+
+def page_beep_misc():
+ p = _open()
+ _page_header(p, "BEEP + MISC TESTS")
+
+ # ── Beep section ──
+ _t(p, "BEEP TESTS\n")
+ _divider(p, "-")
+ _t(p, "Sending beeps now...\n\n")
+
+ # BEL — single beep (0x07)
+ _t(p, "[1] BEL single beep (0x07)\n")
+ p._raw(b'\x07')
+ time.sleep(0.5)
+
+ # ESC BEL n1 n2 n3 — beep for appointment
+ # n1=beep length (100ms units), n2=intermission (100ms), n3=count
+ _t(p, "[2] ESC BEL: 1 beep, 200ms long\n")
+ p._raw(bytes([0x1b, 0x07, 2, 2, 1])) # 200ms on, 200ms off, 1 beep
+ time.sleep(0.8)
+
+ _t(p, "[3] ESC BEL: 3 short beeps\n")
+ p._raw(bytes([0x1b, 0x07, 1, 1, 3])) # 100ms on, 100ms off, 3 beeps
+ time.sleep(1.5)
+
+ _t(p, "[4] ESC BEL: 1 long beep (500ms)\n")
+ p._raw(bytes([0x1b, 0x07, 5, 2, 1])) # 500ms on, 200ms off, 1 beep
+ time.sleep(1.2)
+
+ _t(p, "[5] GS BEL: 2 beeps\n")
+ p._raw(bytes([0x1d, 0x07, 2, 3, 2])) # 2 beeps, 300ms long, 200ms off
+ time.sleep(1.5)
+
+ _t(p, "Beep tests done.\n")
+ _divider(p)
+
+ # ── Underline ──
+ _t(p, "\nUNDERLINE\n")
+ _divider(p, "-")
for ul in [1, 2]:
p._raw(bytes([0x1b, 0x2d, ul]))
- _t(p, f"Underline mode {ul}: TEST PRINT Abc123\n")
- p._raw(b'\x1b\x2d\x00') # off
+ _t(p, f"Underline mode {ul}: Hello World 123\n")
+ p._raw(b'\x1b\x2d\x00')
_t(p, "\n")
_divider(p)
- p._raw(b'\n')
-# ── Section 6 — Inverted / white-on-black ─────────────────────────────────
-
-def section_invert(p):
- _center(p)
- p._raw(b'\x1b\x21\x18')
- _t(p, "=== INVERT (white-on-black) ===\n")
- p._raw(b'\x1b\x21\x00')
- _divider(p, "=")
- _left(p)
-
- # GS B n — invert
+ # ── White-on-black invert ──
+ _t(p, "\nWHITE-ON-BLACK (GS B)\n")
+ _divider(p, "-")
p._raw(b'\x1d\x42\x01')
- _t(p, " INVERTED TEXT SAMPLE \n")
+ _t(p, " INVERTED NORMAL \n")
+ p._raw(b'\x1d\x21\x11') # 2x2 inverted
+ _t(p, " INVERTED 2x2 \n")
+ p._raw(b'\x1d\x21\x00')
p._raw(b'\x1d\x42\x00')
- _t(p, "Normal text after invert\n")
- _t(p, "\n")
+ _t(p, "Normal after invert\n")
_divider(p)
- p._raw(b'\n')
-# ── Section 7 — QR Code sample ────────────────────────────────────────────
-
-def section_qr(p):
- _center(p)
- p._raw(b'\x1b\x21\x18')
- _t(p, "=== QR CODE SAMPLE ===\n")
- p._raw(b'\x1b\x21\x00')
- _divider(p, "=")
-
- data = b"https://pos.test"
- # GS ( k — QR store data
- store_len = len(data) + 3
- p._raw(b'\x1d\x28\x6b' + bytes([store_len & 0xFF, (store_len >> 8) & 0xFF, 0x31, 0x50, 0x30]) + data)
- # GS ( k — set size (module=6)
- p._raw(b'\x1d\x28\x6b\x03\x00\x31\x43\x06')
- # GS ( k — error correction level M
- p._raw(b'\x1d\x28\x6b\x03\x00\x31\x45\x31')
- # GS ( k — print
- p._raw(b'\x1d\x28\x6b\x03\x00\x31\x51\x30')
-
- _t(p, "\nhttps://pos.test\n\n")
+ # ── 90-degree rotation ──
+ _t(p, "\n90-DEGREE ROTATION (ESC V)\n")
+ _divider(p, "-")
+ p._raw(b'\x1b\x56\x01')
+ _t(p, "ROTATED TEXT\n")
+ p._raw(b'\x1b\x56\x00')
+ _t(p, "Normal again\n")
_divider(p)
- p._raw(b'\n')
-# ── Main ───────────────────────────────────────────────────────────────────
+ # ── CP737 useful symbols at normal size ──
+ _t(p, "\nUSEFUL CP737 SYMBOLS\n")
+ _divider(p, "-")
+ symbols = [
+ (0xFB, "tick / checkmark"),
+ (0xFE, "filled square"),
+ (0xF9, "middle dot"),
+ (0xFA, "small bullet"),
+ (0xF8, "degree"),
+ (0xDB, "full block"),
+ (0xDC, "lower half block"),
+ (0xDF, "upper half block"),
+ (0xB0, "light shade"),
+ (0xB1, "medium shade"),
+ (0xB2, "dark shade"),
+ (0xC4, "thin horiz line"),
+ (0xCD, "double horiz line"),
+ (0xBA, "vertical bar"),
+ (0xC9, "top-left corner dbl"),
+ (0xBB, "top-right corner dbl"),
+ (0xC8, "bot-left corner dbl"),
+ (0xBC, "bot-right corner dbl"),
+ ]
+ for code, desc in symbols:
+ p._raw(bytes([code, 0x20, code, 0x20, code, 0x20]))
+ _t(p, f" {desc}\n")
+
+ _divider(p)
+
+ p._raw(b'\n\n\n')
+ p.cut()
+ p.close()
+
+
+# ── Main ───────────────────────────────────────────────────────────────────────
def main():
- print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT} ...")
+ print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}")
+ print("Printing 6 pages...\n")
- # ---- PAGE 1: English fonts ----
- p = _open()
- section_english(p)
- p._raw(b'\n\n\n')
- p.cut()
- p.close()
- print("Page 1 sent (English fonts)")
+ page_esc_bang(font_b=False, english=True)
+ print("Page 1 done — ESC! modes, Font A, English")
- # ---- PAGE 2: Greek fonts ----
- p = _open()
- section_greek(p)
- p._raw(b'\n\n\n')
- p.cut()
- p.close()
- print("Page 2 sent (Greek fonts)")
+ page_esc_bang(font_b=True, english=True)
+ print("Page 2 done — ESC! modes, Font B, English")
- # ---- PAGE 3: Symbols & special chars ----
- p = _open()
- section_ascii_symbols(p)
- section_extended(p)
- p._raw(b'\n\n\n')
- p.cut()
- p.close()
- print("Page 3 sent (ASCII + CP737 extended)")
+ page_esc_bang(font_b=False, english=False)
+ print("Page 3 done — ESC! modes, Font A, Greek")
- # ---- PAGE 4: Underline + Invert + QR ----
- p = _open()
- section_underline(p)
- section_invert(p)
- section_qr(p)
- p._raw(b'\n\n\n')
- p.cut()
- p.close()
- print("Page 4 sent (underline / invert / QR)")
+ page_esc_bang(font_b=True, english=False)
+ print("Page 4 done — ESC! modes, Font B, Greek")
- print("Done — 4 pages printed.")
+ page_gs_sizes()
+ print("Page 5 done — GS! size multipliers")
+
+ page_beep_misc()
+ print("Page 6 done — Beep tests + misc")
+
+ print("\nAll done.")
if __name__ == "__main__":
main()
diff --git a/local_backend/routers/products.py b/local_backend/routers/products.py
index 2915055..b7c542f 100644
--- a/local_backend/routers/products.py
+++ b/local_backend/routers/products.py
@@ -35,6 +35,7 @@ def _replace_quick_options(db, product, quick_options):
sort_order=qo.sort_order if qo.sort_order else i,
is_favorite=qo.is_favorite,
favorite_sort_order=qo.favorite_sort_order,
+ is_compact=qo.is_compact,
))
@@ -206,6 +207,7 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
sort_order=qo.sort_order if qo.sort_order else i,
is_favorite=qo.is_favorite,
favorite_sort_order=qo.favorite_sort_order,
+ is_compact=qo.is_compact,
))
for opt in body.options:
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
diff --git a/local_backend/routers/settings.py b/local_backend/routers/settings.py
index e15218f..933738a 100644
--- a/local_backend/routers/settings.py
+++ b/local_backend/routers/settings.py
@@ -17,6 +17,13 @@ VALID_SETTINGS = {
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
"dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.",
+ # Print font settings — values are "SIZE:BOLD" where SIZE is ESC ! base byte (0/16/32/48) and BOLD is 0 or 1
+ "print.font_item_name": "Font for item name lines: SIZE:BOLD (e.g. '16:0')",
+ "print.font_options": "Font for option/modifier lines: SIZE:BOLD",
+ "print.font_table": "Font for table/waiter header lines: SIZE:BOLD",
+ "print.font_order_number": "Font for order number header: SIZE:BOLD",
+ "print.font_header": "Font for top header block: SIZE:BOLD",
+ "print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
}
DEFAULTS = {
@@ -26,6 +33,12 @@ DEFAULTS = {
"system.timezone": "Europe/Athens",
"ui.table_colours": "",
"dev.spoof_printing": "false",
+ "print.font_item_name": "16:0", # double-height, no bold
+ "print.font_options": "0:0", # normal
+ "print.font_table": "16:0", # double-height
+ "print.font_order_number": "48:1", # double-height + double-width + bold
+ "print.font_header": "48:1", # double-height + double-width + bold
+ "print.divider_style": "dash",
}
diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py
index f263303..939fad4 100644
--- a/local_backend/routers/system.py
+++ b/local_backend/routers/system.py
@@ -5,7 +5,7 @@ from typing import List
from database import get_db
from models.printer import Printer
-from schemas.printer import PrinterUpdate, PrinterOut
+from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
from routers.deps import get_current_user, require_manager, require_sysadmin
from models.user import User
from services import printer_service
@@ -40,7 +40,16 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
@router.get("/printers", response_model=List[PrinterOut])
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
- return db.query(Printer).filter(Printer.is_active == True).all()
+ return db.query(Printer).all()
+
+
+@router.post("/printers", response_model=PrinterOut)
+def create_printer(body: PrinterCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
+ printer = Printer(**body.model_dump())
+ db.add(printer)
+ db.commit()
+ db.refresh(printer)
+ return printer
@router.post("/printers/test")
@@ -53,7 +62,7 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De
@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_sysadmin)):
+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()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
@@ -64,6 +73,16 @@ def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(g
return printer
+@router.delete("/printers/{printer_id}")
+def delete_printer(printer_id: int, 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")
+ db.delete(printer)
+ db.commit()
+ return {"ok": True}
+
+
@router.post("/lock")
def lock_system(token: str, user: User = Depends(require_sysadmin)):
license_state["locked"] = True
diff --git a/local_backend/schemas/printer.py b/local_backend/schemas/printer.py
index 6958b3e..9faf8f1 100644
--- a/local_backend/schemas/printer.py
+++ b/local_backend/schemas/printer.py
@@ -2,11 +2,19 @@ from pydantic import BaseModel
from typing import Optional
+PROTOCOLS = ["escpos_tcp"] # extend later as needed
+
+
class PrinterBase(BaseModel):
name: str
ip_address: str
port: int = 9100
is_active: bool = True
+ protocol: str = "escpos_tcp"
+
+
+class PrinterCreate(PrinterBase):
+ pass
class PrinterUpdate(BaseModel):
@@ -14,6 +22,7 @@ class PrinterUpdate(BaseModel):
ip_address: Optional[str] = None
port: Optional[int] = None
is_active: Optional[bool] = None
+ protocol: Optional[str] = None
class PrinterOut(PrinterBase):
diff --git a/local_backend/schemas/product.py b/local_backend/schemas/product.py
index 7dbdd99..50bb9eb 100644
--- a/local_backend/schemas/product.py
+++ b/local_backend/schemas/product.py
@@ -57,6 +57,7 @@ class ProductQuickOptionCreate(BaseModel):
sort_order: int = 0
is_favorite: bool = False
favorite_sort_order: int = 0
+ is_compact: bool = False
class ProductQuickOptionOut(BaseModel):
@@ -68,6 +69,7 @@ class ProductQuickOptionOut(BaseModel):
sort_order: int = 0
is_favorite: bool = False
favorite_sort_order: int = 0
+ is_compact: bool = False
model_config = {"from_attributes": True}
diff --git a/local_backend/services/printer_service.py b/local_backend/services/printer_service.py
index 9dd1b09..c146c76 100644
--- a/local_backend/services/printer_service.py
+++ b/local_backend/services/printer_service.py
@@ -47,9 +47,57 @@ def _raw_text(p: Network, text: str):
p._raw(_gr(text))
-def _divider(p: Network):
+_DIVIDER_CHARS = {
+ "dash": "-",
+ "equals": "=",
+ "star": "*",
+ "empty": "",
+}
+
+_PRINT_FONT_DEFAULTS = {
+ "print.font_item_name": "16:0",
+ "print.font_options": "0:0",
+ "print.font_table": "16:0",
+ "print.font_order_number": "48:1",
+ "print.font_header": "48:1",
+ "print.divider_style": "dash",
+}
+
+# SIZE byte values (ESC ! base, no bold bit):
+# 0 = normal
+# 16 = double-height (bit4)
+# 32 = double-width (bit5)
+# 48 = double-height + double-width (bits 4+5)
+# Bold is applied separately via ESC E.
+
+def _decode_font(value: str) -> tuple[int, bool]:
+ """Parse 'SIZE:BOLD' string → (esc_bang_byte, bold_flag)."""
+ try:
+ parts = str(value).split(":")
+ size = int(parts[0])
+ bold = len(parts) > 1 and parts[1] == "1"
+ return size, bold
+ except (ValueError, AttributeError):
+ return 0, False
+
+
+def _load_print_fonts(db: Session) -> dict:
+ rows = db.query(PosSettings).filter(
+ PosSettings.key.in_(_PRINT_FONT_DEFAULTS.keys())
+ ).all()
+ fonts = dict(_PRINT_FONT_DEFAULTS)
+ for row in rows:
+ fonts[row.key] = row.value
+ return fonts
+
+
+def _divider(p: Network, style: str = "dash"):
+ char = _DIVIDER_CHARS.get(style, "-")
p._raw(b'\x1b\x61\x00')
- p._raw(_gr("-" * LINE_WIDTH + "\n"))
+ if char:
+ p._raw(_gr(char * LINE_WIDTH + "\n"))
+ else:
+ p._raw(b'\n')
def _item_line(name: str, qty: int) -> str:
@@ -106,34 +154,50 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
# ── Receipt formatting ───────────────────────────────────────────────────────
+def _font(p: Network, byte_val: int, bold: bool = False):
+ p._raw(bytes([0x1b, 0x21, byte_val]))
+ p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
+
+
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
- # Header
+ fonts = _load_print_fonts(db)
+ div = fonts["print.divider_style"]
+
+ sz_order, bold_order = _decode_font(fonts["print.font_order_number"])
+ sz_table, bold_table = _decode_font(fonts["print.font_table"])
+ sz_item, bold_item = _decode_font(fonts["print.font_item_name"])
+ sz_opt, bold_opt = _decode_font(fonts["print.font_options"])
+
+ # Header — order number
p._raw(b'\x1b\x61\x01')
- p._raw(b'\x1b\x21\x38') # bold + double height + double width
+ _font(p, sz_order, bold_order)
_raw_text(p, f"Παραγγελια #{order.id}\n")
p._raw(b'\x1b\x21\x00')
- _divider(p)
+ p._raw(b'\x1b\x45\x00')
+ _divider(p, div)
- # Meta
+ # Meta — table / waiter / time
p._raw(b'\x1b\x61\x00')
- p._raw(b'\x1b\x21\x10') # double height only — keeps 48-char width
+ _font(p, sz_table, bold_table)
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
_raw_text(p, f"Date: {now}\n")
_raw_text(p, f"Table: {order.table_id}\n")
_raw_text(p, f"Waiter: {order.opened_by}\n")
p._raw(b'\x1b\x21\x00')
- _divider(p)
+ p._raw(b'\x1b\x45\x00')
+ _divider(p, div)
# Items
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
name = product.name if product else f"Product #{item.product_id}"
- p._raw(b'\x1b\x21\x10')
- p._raw(b'\x1b\x45\x01') # bold on
+ _font(p, sz_item, bold_item)
_raw_text(p, _item_line(name, item.quantity) + "\n")
- p._raw(b'\x1b\x45\x00') # bold off
+ p._raw(b'\x1b\x21\x00')
+ p._raw(b'\x1b\x45\x00')
+ _font(p, sz_opt, bold_opt)
if item.removed_ingredients:
try:
removed_ids = json.loads(item.removed_ingredients)
@@ -154,8 +218,9 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
_raw_text(p, f" (i) {item.notes}\n")
p._raw(b'\x1b\x21\x00')
+ p._raw(b'\x1b\x45\x00')
- _divider(p)
+ _divider(p, div)
if order.notes:
p._raw(b'\x1b\x21\x30')
diff --git a/manager_dashboard/src/pages/ProductsTab.jsx b/manager_dashboard/src/pages/ProductsTab.jsx
index fc430a1..53b106b 100644
--- a/manager_dashboard/src/pages/ProductsTab.jsx
+++ b/manager_dashboard/src/pages/ProductsTab.jsx
@@ -766,6 +766,7 @@ function buildFormFromProduct(product) {
sort_order: q.sort_order ?? 0,
is_favorite: q.is_favorite ?? false,
favorite_sort_order: q.favorite_sort_order ?? 0,
+ is_compact: q.is_compact ?? false,
})) ?? [],
options: product.options?.map(o => ({
name: o.name,
@@ -906,7 +907,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
}
// ── Quick Options ──
- function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0 }] })) }
+ function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0, is_compact: false }] })) }
function removeQuickOption(i) { setForm(f => ({ ...f, quick_options: f.quick_options.filter((_, idx) => idx !== i) })) }
function setQuickOption(i, k, v) { setForm(f => ({ ...f, quick_options: f.quick_options.map((q, idx) => idx === i ? { ...q, [k]: v } : q) })) }
function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) }
@@ -1083,6 +1084,7 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
sort_order: i,
is_favorite: q.is_favorite ?? false,
favorite_sort_order: q.favorite_sort_order ?? 0,
+ is_compact: q.is_compact ?? false,
})),
options: form.options.map(o => ({
name: o.name,
@@ -1346,6 +1348,12 @@ function ProductFormModal({ product, categories, printers, onSave, onCopy, onClo
className="accent-primary-700 w-4 h-4" />
Πολλαπλά
+
))}
diff --git a/manager_dashboard/src/pages/Settings/SettingsPage.jsx b/manager_dashboard/src/pages/Settings/SettingsPage.jsx
index 9445ac7..6729c48 100644
--- a/manager_dashboard/src/pages/Settings/SettingsPage.jsx
+++ b/manager_dashboard/src/pages/Settings/SettingsPage.jsx
@@ -2,10 +2,12 @@ import { useState } from 'react'
import AppInfoTab from './tabs/AppInfoTab'
import ColoursTab from './tabs/ColoursTab'
import DevelopmentTab from './tabs/DevelopmentTab'
+import PrintFontsTab from './tabs/PrintFontsTab'
const TABS = [
{ key: 'app-info', label: 'App Info' },
{ key: 'colours', label: 'UI Personalization' },
+ { key: 'print-fonts', label: 'Εκτύπωση' },
{ key: 'development', label: 'Development' },
]
@@ -48,6 +50,7 @@ export default function SettingsPage() {
{/* Tab content */}
{activeTab === 'app-info' &&
Διαχείριση εκτυπωτών του συστήματος
+Φόρτωση…
} + {!isLoading && printers.length === 0 && !showNew && ( ++ Δεν υπάρχουν εκτυπωτές ακόμα. +
+ )} + + {printers.map(printer => ( + editingId === printer.id ? ( ++ Οι αλλαγές εφαρμόζονται αμέσως στην επόμενη εκτύπωση. +
+Δεν υπάρχουν γρήγορες επιλογές.
) return ( -{label}
+ + + + {singleStacked && !multiSelect && ( + + )} + + +