Skip to content

Commit 07b9eb3

Browse files
committed
feat(zpl): ^CV code-validation toggle
Adds the ZPL ^CV barcode validation flag — when Y the firmware prints `INVALID-x` in reverse video over a barcode whose data fails its symbology's character/checksum rules. ^CV is session-scoped per spec (resets on power-off, not persisted by ^JUS). The Setup-Script emitter registry now distinguishes `scope: 'persistent' | 'session'`; session entries emit AFTER configurationUpdate so ^JUS never tries to commit a non-persistent state. The "configurationUpdate last" tripwire becomes "last among persistent entries", with a new test pinning the wire order ^XA ^JUS ^CVY ^XZ. UI lives in the Print Quality tab next to ^JZ / ^JT / ~TA. Three new locale keys (codeValidation, codeValidationOn, codeValidationOff) seeded across all 32 locales via scripts/locale_tool.py add-key.
1 parent 75c2115 commit 07b9eb3

38 files changed

Lines changed: 194 additions & 25 deletions

‎docs/zpl-roadmap.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ The Printer Settings Modal (Media & Feed / Print Quality / Clock & Time / Encodi
6363
| `[x]` | `^FP` | vertical / reverse field layout (CJK / RTL) | |
6464
| `[ ]` | `^CO` | font cache size | `Coming soon` |
6565
| `[ ]` | `^CP` | change parser | `Coming soon` |
66-
| `[ ]` | `^CV` | code validation | `Coming soon` |
66+
| `[x]` | `^CV` | code validation | |
6767

6868
## Text & fonts
6969

‎src/components/PrinterSettings/PrintQualityTab.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,17 @@ export function PrintQualityTab() {
152152
onChange={(v) => patchPrinterProfile({ tearOffAdjust: v })}
153153
unit={t.printerSettings.dotsUnit}
154154
/>
155+
156+
<ZplEnumSelect
157+
label={loc.codeValidation}
158+
command="^CV"
159+
values={['Y', 'N'] as const}
160+
isValid={(v): v is 'Y' | 'N' => v === 'Y' || v === 'N'}
161+
value={profile.codeValidation}
162+
onChange={(codeValidation) => patchPrinterProfile({ codeValidation })}
163+
defaultLabel={t.printerSettings.defaultOption}
164+
optionLabel={(v) => v === 'Y' ? loc.codeValidationOn : loc.codeValidationOff}
165+
/>
155166
</div>
156167
);
157168
}

‎src/lib/zplParser/handlers/setupScript.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ export function createSetupScriptHandlers(s: ParserState): Record<string, Handle
132132
const v = rest.trim().toUpperCase();
133133
if (v === "Y" || v === "N") printerProfile.headColdWarning = v;
134134
},
135+
CV(_, rest) {
136+
const v = rest.trim().toUpperCase();
137+
if (v === "Y" || v === "N") printerProfile.codeValidation = v;
138+
},
135139
// ^JH: only f/g modelled; other slots are runtime reset flags.
136140
// g is a 0..16 index into HEAD_CLEANING_INTERVAL_METERS per spec.
137141
JH(p) {

‎src/lib/zplSetupScript.test.ts‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -421,19 +421,33 @@ describe("generateSetupScript — output shape", () => {
421421
"maintenanceMessage",
422422
"headColdWarning",
423423
"configurationUpdate",
424+
"codeValidation",
424425
]);
425426
});
426427

427-
it("places configurationUpdate as the last entry so ^JUS commits the block", () => {
428+
it("places configurationUpdate as the last persistent entry so ^JUS commits the block", () => {
428429
// Tripwire: ^JU emits whatever persistent writes precede it, so a
429-
// future field added after configurationUpdate would land inside
430-
// the same ^XA…^XZ block and only get committed on the next
431-
// print job, not at provisioning.
432-
expect(SETUP_SCRIPT_FIELDS[SETUP_SCRIPT_FIELDS.length - 1]).toBe(
430+
// future persistent field added after configurationUpdate would
431+
// land in the same ^XA…^XZ block and only get committed on the
432+
// next print job, not at provisioning. Session-scoped entries
433+
// (e.g. ^CV) are allowed after because they emit after ^JUS.
434+
const persistentFields = SETUP_SCRIPT_FIELDS.filter((f) => {
435+
const e = __SETUP_SCRIPT_EMITTERS_FOR_TESTS[f];
436+
return e.kind === 'emit' && e.channel === 'block' && e.scope === 'persistent';
437+
});
438+
expect(persistentFields[persistentFields.length - 1]).toBe(
433439
"configurationUpdate",
434440
);
435441
});
436442

443+
it("emits session-scoped commands after ^JUS so the commit can't try to persist them", () => {
444+
const script = generateSetupScript({
445+
configurationUpdate: 'S',
446+
codeValidation: 'Y',
447+
});
448+
expect(script).toBe("^XA\n^JUS\n^CVY\n^XZ");
449+
});
450+
437451
it("PrinterProfile carries only Setup-Script fields — no per-label leakage", () => {
438452
// The schema must not accept per-label keys (^MD, ~SD, ^PR, ^MN
439453
// and friends). This pins the "no double-source-of-truth"
@@ -478,6 +492,20 @@ describe("generateSetupScript — maintenance commands", () => {
478492
expect(generateSetupScript({ ...base, headColdWarning: 'N' })).toBe("^XA\n^MWN\n^XZ");
479493
});
480494

495+
it("emits ^CV for the code-validation toggle", () => {
496+
expect(generateSetupScript({ ...base, codeValidation: 'Y' })).toBe("^XA\n^CVY\n^XZ");
497+
expect(generateSetupScript({ ...base, codeValidation: 'N' })).toBe("^XA\n^CVN\n^XZ");
498+
});
499+
500+
it("parses ^CV back into the profile", () => {
501+
expect(parseZPL("^XA^CVY^XZ").printerProfile.codeValidation).toBe("Y");
502+
expect(parseZPL("^XA^CVN^XZ").printerProfile.codeValidation).toBe("N");
503+
});
504+
505+
it("drops ^CV with an invalid value", () => {
506+
expect(parseZPL("^XA^CVX^XZ").printerProfile.codeValidation).toBeUndefined();
507+
});
508+
481509
it("emits ^JH with f-slot filled and other slots empty", () => {
482510
const script = generateSetupScript({ ...base, earlyWarningMaintenance: 'E' });
483511
expect(script).toBe("^XA\n^JH,,,,,E,,,,\n^XZ");

‎src/lib/zplSetupScript.ts‎

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,11 @@ import { formatRealtimeClockForZpl, toLocalIsoString } from './realtimeClock';
1717
* earlier script left the printer in a non-default state. */
1818

1919
type SetupScriptEntry =
20-
| {
21-
kind: 'emit';
22-
channel: 'tilde' | 'block';
23-
emit: (profile: PrinterProfile) => string | null;
24-
}
25-
| {
26-
kind: 'foldedInto';
27-
target: keyof PrinterProfile;
28-
};
20+
| { kind: 'emit'; channel: 'tilde'; emit: (p: PrinterProfile) => string | null }
21+
// `scope` is required on block entries so every new command makes the
22+
// persistence call explicit; 'session' entries emit after ^JUS.
23+
| { kind: 'emit'; channel: 'block'; scope: 'persistent' | 'session'; emit: (p: PrinterProfile) => string | null }
24+
| { kind: 'foldedInto'; target: keyof PrinterProfile };
2925

3026
const SETUP_SCRIPT_EMITTERS = {
3127
tearOffAdjust: {
@@ -45,16 +41,19 @@ const SETUP_SCRIPT_EMITTERS = {
4541
reprintAfterError: {
4642
kind: 'emit',
4743
channel: 'block',
44+
scope: 'persistent',
4845
emit: (p) => p.reprintAfterError !== undefined ? `^JZ${p.reprintAfterError}` : null,
4946
},
5047
headTestInterval: {
5148
kind: 'emit',
5249
channel: 'block',
50+
scope: 'persistent',
5351
emit: (p) => p.headTestInterval !== undefined ? `^JT${p.headTestInterval}` : null,
5452
},
5553
setRealtimeClock: {
5654
kind: 'emit',
5755
channel: 'block',
56+
scope: 'persistent',
5857
// Live mode (useCurrentTimeForClock) captures now() at emit-time
5958
// and wins over the stored static value. Toggling the checkbox in
6059
// the UI is therefore unambiguous regardless of stored ISO state.
@@ -72,26 +71,31 @@ const SETUP_SCRIPT_EMITTERS = {
7271
clockFormat: {
7372
kind: 'emit',
7473
channel: 'block',
74+
scope: 'persistent',
7575
emit: (p) => p.clockFormat !== undefined ? `^KD${p.clockFormat}` : null,
7676
},
7777
printerLocale: {
7878
kind: 'emit',
7979
channel: 'block',
80+
scope: 'persistent',
8081
emit: (p) => p.printerLocale !== undefined ? `^KL${p.printerLocale}` : null,
8182
},
8283
encodingTable: {
8384
kind: 'emit',
8485
channel: 'block',
86+
scope: 'persistent',
8587
emit: (p) => p.encodingTable !== undefined ? `^SE${p.encodingTable}` : null,
8688
},
8789
zplMode: {
8890
kind: 'emit',
8991
channel: 'block',
92+
scope: 'persistent',
9093
emit: (p) => p.zplMode !== undefined ? `^SZ${p.zplMode}` : null,
9194
},
9295
printerName: {
9396
kind: 'emit',
9497
channel: 'block',
98+
scope: 'persistent',
9599
emit: (p) => {
96100
if (p.printerName === undefined) return null;
97101
// Trim both halves: parser trims on import; asymmetric whitespace
@@ -107,11 +111,13 @@ const SETUP_SCRIPT_EMITTERS = {
107111
setPassword: {
108112
kind: 'emit',
109113
channel: 'block',
114+
scope: 'persistent',
110115
emit: (p) => p.setPassword !== undefined ? `^KP${p.setPassword}` : null,
111116
},
112117
clockMode: {
113118
kind: 'emit',
114119
channel: 'block',
120+
scope: 'persistent',
115121
// ^SL `a` slot is tri-shape: 'S', 'T', or numeric tolerance (TOL
116122
// mode). Schema's cross-field refine guarantees TOL always has a
117123
// defined clockTolerance, so no fallback needed.
@@ -133,6 +139,7 @@ const SETUP_SCRIPT_EMITTERS = {
133139
earlyWarningMaintenance: {
134140
kind: 'emit',
135141
channel: 'block',
142+
scope: 'persistent',
136143
emit: (p) => {
137144
if (p.earlyWarningMaintenance === undefined && p.headCleaningIntervalMeters === undefined) {
138145
return null;
@@ -152,6 +159,7 @@ const SETUP_SCRIPT_EMITTERS = {
152159
maintenanceAlert: {
153160
kind: 'emit',
154161
channel: 'block',
162+
scope: 'persistent',
155163
emit: (p) => {
156164
const m = p.maintenanceAlert;
157165
if (!m) return null;
@@ -161,28 +169,31 @@ const SETUP_SCRIPT_EMITTERS = {
161169
maintenanceMessage: {
162170
kind: 'emit',
163171
channel: 'block',
172+
scope: 'persistent',
164173
emit: (p) => p.maintenanceMessage
165174
? `^MI${p.maintenanceMessage.type},${p.maintenanceMessage.text}`
166175
: null,
167176
},
168177
headColdWarning: {
169178
kind: 'emit',
170179
channel: 'block',
180+
scope: 'persistent',
171181
emit: (p) => p.headColdWarning !== undefined ? `^MW${p.headColdWarning}` : null,
172182
},
173-
// configurationUpdate is the registry's last entry on purpose: a
174-
// commit (`^JUS`) needs to follow every other persistent write so
175-
// the EEPROM lands the rest of the block before being asked to
176-
// save itself. The Zebra app note for ^JU also warns that more
177-
// than one ^JU per file is unsupported, so we never split this.
178-
// The "is-last" invariant is anchored by a tripwire in
179-
// zplSetupScript.test.ts so adding a later entry can't silently
180-
// break the commit semantics.
183+
// Last persistent entry: ^JUS must follow every persistent write so the
184+
// EEPROM commits the block. Tripwire in zplSetupScript.test.ts pins this.
181185
configurationUpdate: {
182186
kind: 'emit',
183187
channel: 'block',
188+
scope: 'persistent',
184189
emit: (p) => p.configurationUpdate !== undefined ? `^JU${p.configurationUpdate}` : null,
185190
},
191+
codeValidation: {
192+
kind: 'emit',
193+
channel: 'block',
194+
scope: 'session',
195+
emit: (p) => p.codeValidation !== undefined ? `^CV${p.codeValidation}` : null,
196+
},
186197
} as const satisfies Partial<Record<keyof PrinterProfile, SetupScriptEntry>>;
187198

188199
/** Public list of the PrinterProfile fields that flow through the
@@ -200,15 +211,32 @@ export const __SETUP_SCRIPT_EMITTERS_FOR_TESTS = SETUP_SCRIPT_EMITTERS;
200211

201212
export function generateSetupScript(profile: PrinterProfile): string {
202213
const tildeLines: string[] = [];
203-
const blockLines: string[] = [];
214+
const persistentBlock: string[] = [];
215+
const sessionBlock: string[] = [];
204216

205217
for (const field of SETUP_SCRIPT_FIELDS) {
206218
const e = SETUP_SCRIPT_EMITTERS[field];
207219
if (e.kind !== 'emit') continue;
208220
const line = e.emit(profile);
209221
if (line === null) continue;
210-
(e.channel === 'tilde' ? tildeLines : blockLines).push(line);
222+
if (e.channel === 'tilde') {
223+
tildeLines.push(line);
224+
continue;
225+
}
226+
// Exhaustive on scope: a future variant forces a case here at
227+
// compile time instead of silently falling through to persistent.
228+
switch (e.scope) {
229+
case 'persistent': persistentBlock.push(line); break;
230+
case 'session': sessionBlock.push(line); break;
231+
default: {
232+
const _exhaustive: never = e;
233+
throw new Error(`unhandled scope on emit entry: ${JSON.stringify(_exhaustive)}`);
234+
}
235+
}
211236
}
237+
// Order is load-bearing: session entries trail ^JUS so the commit
238+
// never tries to persist them.
239+
const blockLines = [...persistentBlock, ...sessionBlock];
212240

213241
if (tildeLines.length === 0 && blockLines.length === 0) return '';
214242

‎src/locales/ar.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ const ar = {
172172
reprintAfterError: 'إعادة الطباعة بعد الخطأ',
173173
headTestInterval: 'فاصل اختبار الرأس (ملصقات)',
174174
tearOffAdjust: 'ضبط القطع',
175+
codeValidation: 'التحقق من الرمز',
176+
codeValidationOn: 'تشغيل',
177+
codeValidationOff: 'إيقاف',
175178
},
176179
clockTime: {
177180
setRealtimeClock: 'ضبط ساعة الوقت الفعلي',

‎src/locales/bg.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ const bg = {
172172
reprintAfterError: 'Повторен печат след грешка',
173173
headTestInterval: 'Интервал на тест на главата (етикети)',
174174
tearOffAdjust: 'Настройка на откъсване',
175+
codeValidation: 'Валидация на кода',
176+
codeValidationOn: 'Вкл.',
177+
codeValidationOff: 'Изкл.',
175178
},
176179
clockTime: {
177180
setRealtimeClock: 'Настройка на часовник в реално време',

‎src/locales/cs.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ const cs = {
172172
reprintAfterError: 'Přetisk po chybě',
173173
headTestInterval: 'Interval testu hlavy (etikety)',
174174
tearOffAdjust: 'Úprava odtrhu',
175+
codeValidation: 'Validace kódu',
176+
codeValidationOn: 'Zapnuto',
177+
codeValidationOff: 'Vypnuto',
175178
},
176179
clockTime: {
177180
setRealtimeClock: 'Nastavit hodiny reálného času',

‎src/locales/da.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ const da = {
172172
reprintAfterError: 'Genudskriv efter fejl',
173173
headTestInterval: 'Hovedtest-interval (etiketter)',
174174
tearOffAdjust: 'Afrivningsjustering',
175+
codeValidation: 'Kodevalidering',
176+
codeValidationOn: 'Til',
177+
codeValidationOff: 'Fra',
175178
},
176179
clockTime: {
177180
setRealtimeClock: 'Indstil realtidsur',

‎src/locales/de.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ const de = {
172172
reprintAfterError: 'Nach Fehler erneut drucken',
173173
headTestInterval: 'Kopftest-Intervall (Etiketten)',
174174
tearOffAdjust: 'Abriss-Versatz',
175+
codeValidation: 'Code-Validierung',
176+
codeValidationOn: 'Ein',
177+
codeValidationOff: 'Aus',
175178
},
176179
clockTime: {
177180
setRealtimeClock: 'Echtzeituhr stellen',

0 commit comments

Comments
 (0)