Skip to content

Commit b922acf

Browse files
NWERC 2025 balloon printer code
1 parent ed10663 commit b922acf

File tree

2 files changed

+295
-1
lines changed

2 files changed

+295
-1
lines changed

webapp/templates/jury/balloons.html.twig

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
<i class="fas fa-filter"></i> Filter
3030
</label>
3131
</div>
32+
<button class="btn btn-secondary" id="connect-printer">
33+
Connect printer
34+
</button>
3235
<div class="card mt-3{% if not hasFilters %} d-none{% endif %}" id="filter-card">
3336
<div class="card-body">
3437
<div class="row">
@@ -83,7 +86,298 @@
8386
{% block extrafooter %}
8487
{% if current_contest is not null %}
8588
<script>
89+
let port = null;
90+
let isConnected = false;
91+
92+
async function checkForPreviousPermissions() {
93+
try {
94+
// Get previously authorized ports
95+
const ports = await navigator.serial.getPorts();
96+
97+
if (ports.length > 0) {
98+
console.log('Found previously authorized port');
99+
return ports[0]; // Return the first available port
100+
}
101+
102+
return null;
103+
} catch (error) {
104+
console.error('Error checking permissions:', error);
105+
return null;
106+
}
107+
}
108+
109+
$(document).ready(async function() {
110+
if (!navigator.serial) {
111+
$('#connect-printer').hide();
112+
return;
113+
}
114+
115+
$('#connect-printer').text('Checking for previous printer connection...');
116+
117+
try {
118+
// Check for previously granted permissions
119+
const previousPort = await checkForPreviousPermissions();
120+
121+
if (previousPort) {
122+
// Attempt to reconnect automatically
123+
port = previousPort;
124+
125+
try {
126+
// Open the port with TM-88II settings
127+
await port.open({
128+
baudRate: 9600, // TM-88II default
129+
dataBits: 8,
130+
stopBits: 1,
131+
parity: 'none',
132+
flowControl: 'none'
133+
});
134+
135+
$('#connect-printer').text('Reconnected to printer');
136+
isConnected = true;
137+
} catch (error) {
138+
$('#status').text('Failed to reconnect');
139+
}
140+
} else {
141+
$('#status').text('Connect printer');
142+
}
143+
} catch (error) {
144+
console.error('Auto-reconnect failed:', error);
145+
$('#status').text('Connect printer');
146+
}
147+
});
148+
149+
async function connectPrinter() {
150+
try {
151+
// Request port access
152+
port = await navigator.serial.requestPort();
153+
154+
// Open the port with TM-88II settings
155+
await port.open({
156+
baudRate: 9600, // TM-88II default
157+
dataBits: 8,
158+
stopBits: 1,
159+
parity: 'none',
160+
flowControl: 'none'
161+
});
162+
163+
console.log('Printer connected!');
164+
$('#connect-printer').text('Connected to printer');
165+
return true;
166+
} catch (error) {
167+
console.error('Connection failed:', error);
168+
return false;
169+
}
170+
}
171+
172+
async function sendCommand(command) {
173+
if (!port) {
174+
console.error('Printer not connected');
175+
return;
176+
}
177+
178+
const writer = port.writable.getWriter();
179+
try {
180+
await writer.write(new Uint8Array(command));
181+
// Small delay to ensure command is processed
182+
await new Promise(resolve => setTimeout(resolve, 10));
183+
} catch (error) {
184+
console.error('Write failed:', error);
185+
} finally {
186+
writer.releaseLock();
187+
}
188+
}
189+
190+
// Map German umlauts to CP850 encoding
191+
function convertToCP850(text) {
192+
const charMap = {
193+
'<C3><A4>': String.fromCharCode(0x84), // <C3><A4> -> 132
194+
'<C3><B6>': String.fromCharCode(0x94), // <C3><B6> -> 148
195+
'<C3><BC>': String.fromCharCode(0x81), // <C3><BC> -> 129
196+
'<C3><84>': String.fromCharCode(0x8E), // <C3><84> -> 142
197+
'<C3><96>': String.fromCharCode(0x99), // <C3><96> -> 153
198+
'<C3><9C>': String.fromCharCode(0x9A), // <C3><9C> -> 154
199+
'<C3><9F>': String.fromCharCode(0xE1), // <C3><9F> -> 225
200+
'<E2><82><AC>': String.fromCharCode(0xD5), // Euro symbol (for CP858)
201+
202+
// French characters
203+
'<C3><A9>': String.fromCharCode(0x82), // <C3><A9> -> 130
204+
'<C3><A8>': String.fromCharCode(0x8A), // <C3><A8> -> 138
205+
'<C3><A0>': String.fromCharCode(0x85), // <C3><A0> -> 133
206+
'<C3><A7>': String.fromCharCode(0x87), // <C3><A7> -> 135
207+
208+
// Spanish characters
209+
'<C3><B1>': String.fromCharCode(0xA4), // <C3><B1> -> 164
210+
'<C3><A1>': String.fromCharCode(0xA0), // <C3><A1> -> 160
211+
'<C3><AD>': String.fromCharCode(0xA1), // <C3><AD> -> 161
212+
'<C3><B3>': String.fromCharCode(0xA2), // <C3><B3> -> 162
213+
'<C3><BA>': String.fromCharCode(0xA3) // <C3><BA> -> 163
214+
};
215+
216+
return text.replace(/[<C3><A4><C3><B6><C3><BC><C3><84><C3><96><C3><9C><C3><9F><C3><A9><C3><A8><C3><A0><C3><A7><C3><B1><C3><A1><C3><AD><C3><B3><C3><BA><E2><82<82><AC>]/g, match => charMap[match] || match);
217+
}
218+
219+
// Send text with automatic encoding
220+
async function sendText(text) {
221+
const convertedText = convertToCP850(text);
222+
const textBytes = new Uint8Array([...convertedText].map(char => char.charCodeAt(0)));
223+
await sendCommand([...textBytes]);
224+
}
225+
226+
// ESC/POS command constants
227+
const ESC = 0x1B;
228+
const GS = 0x1D;
229+
const FS = 0x1C;
230+
231+
// Working commands for TM-88II
232+
const commands = {
233+
// Code page commands for TM-88II
234+
codePage: {
235+
cp850: [ESC, 0x74, 0x02], // Western Europe (supports umlauts)
236+
cp858: [ESC, 0x74, 0x13], // Western Europe + Euro symbol
237+
cp437: [ESC, 0x74, 0x00], // US (default, no umlauts)
238+
iso8859_1: [ESC, 0x74, 0x06] // Latin-1
239+
},
240+
241+
// Initialization
242+
init: [ESC, 0x40],
243+
244+
// Text emphasis (these work better than bold)
245+
emphasis: {
246+
on: [ESC, 0x45, 0x01], // Bold on
247+
off: [ESC, 0x45, 0x00] // Bold off
248+
},
249+
250+
// Double size (more reliable than bold)
251+
doubleWidth: {
252+
on: [ESC, 0x21, 0x20], // Double width on
253+
off: [ESC, 0x21, 0x00] // Normal width
254+
},
255+
256+
doubleHeight: {
257+
on: [ESC, 0x21, 0x10], // Double height on
258+
off: [ESC, 0x21, 0x00] // Normal height
259+
},
260+
261+
// Both double width and height
262+
doubleSize: {
263+
on: [ESC, 0x21, 0x30], // Double both
264+
off: [ESC, 0x21, 0x00] // Normal
265+
},
266+
267+
// Alignment (these should work)
268+
align: {
269+
left: [ESC, 0x61, 0x30], // or 0x00
270+
center: [ESC, 0x61, 0x31], // or 0x01
271+
right: [ESC, 0x61, 0x32] // or 0x02
272+
},
273+
274+
// Alternative alignment commands
275+
justification: {
276+
left: [ESC, 0x61, 0x00],
277+
center: [ESC, 0x61, 0x01],
278+
right: [ESC, 0x61, 0x02]
279+
},
280+
281+
// Paper handling
282+
feedLines: (n) => [ESC, 0x64, n],
283+
cutPaper: [GS, 0x56, 0x00], // Full cut
284+
cutPaperPartial: [GS, 0x56, 0x01], // Partial cut
285+
286+
// Line feed and carriage return
287+
lf: [0x0A],
288+
cr: [0x0D],
289+
crlf: [0x0D, 0x0A]
290+
};
291+
292+
async function printReceipt(balloon, team, color) {
293+
try {
294+
// Initialize printer
295+
await sendCommand(commands.init);
296+
await sendCommand(commands.codePage.cp850);
297+
298+
// Center-aligned header with double size
299+
await sendCommand(commands.align.center);
300+
await sendCommand(commands.doubleSize.on);
301+
await sendText('NWERC 2025');
302+
await sendCommand(commands.crlf);
303+
await sendCommand(commands.doubleSize.off);
304+
305+
// Normal size subtitle
306+
await sendText(`Balloon #${balloon.balloonid}`);
307+
await sendCommand(commands.crlf);
308+
await sendCommand(commands.crlf);
309+
310+
// Left align for items
311+
await sendCommand(commands.align.left);
312+
await sendText('Date: ' + new Date().toLocaleDateString());
313+
await sendCommand(commands.crlf);
314+
await sendText('Time: ' + new Date().toLocaleTimeString());
315+
await sendCommand(commands.crlf);
316+
await sendCommand(commands.crlf);
317+
318+
// Items with emphasis
319+
await sendCommand(commands.emphasis.on);
320+
await sendText(` Location: Problem: `);
321+
await sendCommand(commands.emphasis.off);
322+
await sendCommand(commands.crlf);
323+
await sendCommand(commands.doubleSize.on);
324+
await sendText(` ${balloon.location || 'Z7'} ${balloon.problem} `);
325+
await sendCommand(commands.doubleSize.off);
326+
await sendCommand(commands.crlf);
327+
await sendCommand(commands.crlf);
328+
await sendCommand(commands.emphasis.on);
329+
await sendText('Team Details:');
330+
await sendCommand(commands.emphasis.off);
331+
await sendCommand(commands.crlf);
332+
await sendText(`Name: ${team}`);
333+
await sendCommand(commands.crlf);
334+
await sendText(`Location: ${balloon.location || 'Z7'}`);
335+
await sendCommand(commands.crlf);
336+
await sendText(`Affiliation: ${balloon.affiliation}`);
337+
await sendCommand(commands.crlf);
338+
await sendText(`Category: ${balloon.category}`);
339+
await sendCommand(commands.crlf);
340+
await sendText(`#Balloons: ${Object.keys(balloon.total).length}`);
341+
await sendCommand(commands.crlf);
342+
await sendCommand(commands.crlf);
343+
await sendCommand(commands.emphasis.on);
344+
await sendText('Balloon Details:');
345+
await sendCommand(commands.emphasis.off);
346+
await sendCommand(commands.crlf);
347+
await sendText(`Problem: ${balloon.problem}`);
348+
await sendCommand(commands.crlf);
349+
await sendText(`Color: ${color}`);
350+
await sendCommand(commands.crlf);
351+
352+
// Separator line
353+
await sendText('------------------------');
354+
await sendCommand(commands.crlf);
355+
356+
// Feed and cut
357+
await sendCommand(commands.feedLines(3));
358+
await sendCommand(commands.cutPaper);
359+
360+
console.log('Receipt printed successfully');
361+
} catch (error) {
362+
console.error('Print failed:', error);
363+
}
364+
}
365+
366+
async function printThenRedirect(balloon, team, color, url) {
367+
event.preventDefault();
368+
console.log(balloon, team, color);
369+
console.log(JSON.stringify(balloon));
370+
console.log(JSON.stringify(team));
371+
console.log(JSON.stringify(color));
372+
await printReceipt(balloon, team, color);
373+
window.location.href = url;
374+
}
375+
86376
$(function () {
377+
$('#connect-printer').on('click', async function () {
378+
await connectPrinter();
379+
});
380+
87381
$('#filter-toggle').on('change', function () {
88382
if ($(this).is(':checked')) {
89383
$('#filter-card').removeClass('d-none');

webapp/templates/jury/partials/balloon_list.html.twig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
<td>
5050
{%- if not balloon.data.done -%}
5151
{%- set link = path('jury_balloons_setdone', {balloonId: balloon.data.balloonid}) %}
52-
<a href="{{ link }}" title="mark balloon as done">
52+
<a href="{{ link }}" title="mark balloon as done" onclick="printThenRedirect({{ balloon.data | json_encode() }}, '{{ balloon.data.team.effectiveName }}', '{{ balloon.data.contestproblem.color }}', this.href)">
5353
<i class="fas fa-running"></i>
5454
</a>
5555
</td>

0 commit comments

Comments
 (0)