Skip to content

Commit 3764acf

Browse files
committed
feat(zpl): ^KP set-password + ^JU configuration-update
Adds the two persistent-config Setup-Script commands users typically reach for at the end of a provisioning script: ^KP (LCD password) and ^JU (save/recall/factory action). - PrinterProfile schema: setPassword (4-digit regex) + configurationUpdate (S/R/N/F enum; F kept in schema for round-trip fidelity but the UI's only path to send it is via import) - Parser handlers in setupScript.ts; FP-style enum guard for ^JU; ^KP value validated against the same regex - unsupported.ts: JU noop removed (handler now owns it) - Generator: ^KP emits with literal 4-digit string; ^JU lives last in SETUP_SCRIPT_EMITTERS so ^JUS commits everything written before it in the same block, per Zebra's "one ^JU per file, last" guidance - IdentityTab: password input with a draft-state mirror so progressive typing isn't filtered out before reaching 4 digits, plus an enum dropdown for the configuration action - 8 locale keys × 32 locales via add_locale_key.local.py - Roadmap: ^JU and ^KP flipped to [x] - Tests: round-trip of ^KP/^JU values, schema rejection of malformed passwords, generator emit order anchoring ^JUS at the end
1 parent 032de2c commit 3764acf

39 files changed

Lines changed: 454 additions & 15 deletions

‎docs/zpl-roadmap.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ The Printer Settings Modal (Media & Feed / Print Quality / Clock & Time / Encodi
182182
| `[ ]` | `~PS` | print start | `Coming soon` |
183183
| `[ ]` | `~JS` | change backfeed sequence | `Coming soon` |
184184
| `[ ]` | `^JW` | set ribbon tension | `Coming soon` |
185-
| `[ ]` | `^JU` | configuration update | `Coming soon` |
185+
| `[x]` | `^JU` | configuration update | |
186186

187187
## Clock & time
188188

@@ -207,7 +207,7 @@ The Printer Settings Modal (Media & Feed / Print Quality / Clock & Time / Encodi
207207
|:-:|---|---|---|
208208
| `[x]` | `^KN` | printer name + description | |
209209
| `[x]` | `^SL` | clock mode (S / T / TOL) + language | |
210-
| `[ ]` | `^KP` | set password | `Coming soon` |
210+
| `[x]` | `^KP` | set password | |
211211

212212
## Configuration & persistence
213213

‎src/components/PrinterSettings/IdentityTab.tsx‎

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,59 @@
1-
import { useId } from "react";
1+
import { useId, useState } from "react";
22
import { useT } from "../../lib/useT";
33
import { useLabelStore } from "../../store/labelStore";
44
import { inputCls, labelCls } from "../ui/formStyles";
5-
import { PRINTER_NAME_MAX_LEN } from "../../types/PrinterProfile";
5+
import {
6+
CONFIG_UPDATE_VALUES,
7+
type ConfigUpdateAction,
8+
PRINTER_NAME_MAX_LEN,
9+
PRINTER_PASSWORD_REGEX,
10+
isConfigUpdateAction,
11+
} from "../../types/PrinterProfile";
612
import {
713
ZplCommandLabel,
14+
ZplEnumSelect,
815
ZplField,
916
ZplSubField,
1017
} from "./zplFieldPrimitives";
1118

12-
/** Tab 5 of the Printer Settings Modal — printer identity setup.
13-
* Emits ^KN (printer name + description) on the Setup-Script
14-
* channel; both fields fold into a single `^KNname,description`
15-
* command at emit time. The tab stays intentionally focused on
16-
* "what is this printer called" — ^SL (clock-formatting Mode +
17-
* Language) lives in Tab 3 Clock & Time per its semantic home.
18-
* See memory:project_ticket_sl_clock_formatting for the ^SL
19-
* follow-up. */
19+
type LocIdentity = ReturnType<typeof useT>["printerSettings"]["identity"];
20+
21+
/** Explicit value → locale-key map; `satisfies` forces every
22+
* ConfigUpdateAction to have an entry and rejects typo keys at
23+
* compile time. Matches the CLOCK_FORMAT_LABEL_KEYS pattern. */
24+
const CONFIG_UPDATE_LABEL_KEYS = {
25+
S: 'configurationUpdateOptionS',
26+
R: 'configurationUpdateOptionR',
27+
N: 'configurationUpdateOptionN',
28+
F: 'configurationUpdateOptionF',
29+
} as const satisfies Record<ConfigUpdateAction, keyof LocIdentity>;
30+
31+
/** Tab 5 of the Printer Settings Modal: printer identity + EEPROM
32+
* commit setup. Emits ^KN (name + description), ^KP (4-digit panel
33+
* password) and ^JU (configuration update action) on the Setup-
34+
* Script channel. ^SL lives in Tab 3 Clock & Time per its semantic
35+
* home. See memory:project_ticket_sl_clock_formatting. */
2036
export function IdentityTab() {
2137
const t = useT();
2238
const profile = useLabelStore((s) => s.printerProfile);
2339
const patchPrinterProfile = useLabelStore((s) => s.patchPrinterProfile);
2440
const loc = t.printerSettings.identity;
2541
const nameId = useId();
42+
const passwordId = useId();
43+
// Local mirror for the 4-digit input: progressive typing ("4", "47")
44+
// is shown immediately but only commits to the store once the regex
45+
// matches. Without this, partial values would always be filtered out
46+
// and the user could never finish entering a password. Re-sync on
47+
// store changes (import, reset) uses the adjust-state-during-render
48+
// pattern so the React compiler doesn't flag a useEffect → setState
49+
// cascade.
50+
const storedPassword = profile.setPassword ?? "";
51+
const [passwordDraft, setPasswordDraft] = useState(storedPassword);
52+
const [lastStoredPassword, setLastStoredPassword] = useState(storedPassword);
53+
if (lastStoredPassword !== storedPassword) {
54+
setLastStoredPassword(storedPassword);
55+
setPasswordDraft(storedPassword);
56+
}
2657

2758
return (
2859
<div className="flex flex-col gap-4">
@@ -61,6 +92,43 @@ export function IdentityTab() {
6192
)}
6293
</ZplSubField>
6394
</ZplField>
95+
96+
<ZplField>
97+
<ZplCommandLabel text={loc.setPassword} command="^KP" htmlFor={passwordId} />
98+
<input
99+
id={passwordId}
100+
type="text"
101+
inputMode="numeric"
102+
pattern="\d{4}"
103+
maxLength={4}
104+
className={inputCls}
105+
value={passwordDraft}
106+
onChange={(e) => {
107+
const raw = e.target.value;
108+
if (!/^\d{0,4}$/.test(raw)) return;
109+
setPasswordDraft(raw);
110+
if (raw === "") {
111+
patchPrinterProfile({ setPassword: undefined });
112+
} else if (PRINTER_PASSWORD_REGEX.test(raw)) {
113+
patchPrinterProfile({ setPassword: raw });
114+
}
115+
}}
116+
/>
117+
<span className={`${labelCls} normal-case tracking-normal text-muted/70`}>
118+
{loc.setPasswordHint}
119+
</span>
120+
</ZplField>
121+
122+
<ZplEnumSelect
123+
label={loc.configurationUpdate}
124+
command="^JU"
125+
values={CONFIG_UPDATE_VALUES}
126+
isValid={isConfigUpdateAction}
127+
value={profile.configurationUpdate}
128+
onChange={(configurationUpdate) => patchPrinterProfile({ configurationUpdate })}
129+
defaultLabel={loc.configurationUpdateUnset}
130+
optionLabel={(v) => loc[CONFIG_UPDATE_LABEL_KEYS[v]]}
131+
/>
64132
</div>
65133
);
66134
}

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import type { PrinterProfile } from "../../../types/PrinterProfile";
2-
import { CLOCK_TOLERANCE_RANGE, HEAD_TEST_INTERVAL_RANGE, PRINTER_NAME_MAX_LEN, TEAR_OFF_ADJUST_RANGE, isClockFormat, isClockLanguage, isPrinterLocale, isZplMode, setupScriptUnsafeCharRegex } from "../../../types/PrinterProfile";
2+
import { CLOCK_TOLERANCE_RANGE, HEAD_TEST_INTERVAL_RANGE, PRINTER_NAME_MAX_LEN, PRINTER_PASSWORD_REGEX, TEAR_OFF_ADJUST_RANGE, isClockFormat, isClockLanguage, isConfigUpdateAction, isPrinterLocale, isZplMode, setupScriptUnsafeCharRegex } from "../../../types/PrinterProfile";
33
import { parseIntOrUndef } from "../../inputParse";
44
import { parseRealtimeClock } from "../../realtimeClock";
55
import { inRange, strParam } from "../helpers";
66
import type { Handler } from "../types";
77

88
/** Setup-Script commands (^JZ, ^JT, ~TA, ^ST, ^KD, ^KL, ^SE, ^SZ,
9-
* ^KN, ^SL) — all write the shared `printerProfile` slice. */
9+
* ^KN, ^SL, ^KP, ^JU): all write the shared `printerProfile` slice. */
1010
export function createSetupScriptHandlers(
1111
printerProfile: Partial<PrinterProfile>,
1212
): Record<string, Handler> {
@@ -73,5 +73,13 @@ export function createSetupScriptHandlers(
7373
const b = strParam(p[1]);
7474
if (isClockLanguage(b)) printerProfile.clockLanguage = b;
7575
},
76+
KP(p) {
77+
const raw = (p[0] ?? "").trim();
78+
if (PRINTER_PASSWORD_REGEX.test(raw)) printerProfile.setPassword = raw;
79+
},
80+
JU(_, rest) {
81+
const v = rest.trim().toUpperCase();
82+
if (isConfigUpdateAction(v)) printerProfile.configurationUpdate = v;
83+
},
7684
};
7785
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ export function createUnsupportedHandlers(
2222
JI: noop,
2323
JR: noop,
2424
JS: noop,
25-
JU: noop,
2625
PP: noop,
2726
// Browser-limit factories — surface as "not loaded" findings.
2827
FL: mkBrowserLimit("FL"),

‎src/lib/zplSetupScript.test.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,66 @@ describe("generateSetupScript — output shape", () => {
238238
expect(() => printerProfileSchema.parse({ ...base, printerName: "abcdefghij0123456" })).toThrow();
239239
});
240240

241+
it("emits ^KP for a 4-digit password", () => {
242+
expect(generateSetupScript({ ...base, setPassword: "4711" }))
243+
.toBe("^XA\n^KP4711\n^XZ");
244+
});
245+
246+
it("preserves ^KP0000 (the disable-protection value)", () => {
247+
expect(generateSetupScript({ ...base, setPassword: "0000" }))
248+
.toBe("^XA\n^KP0000\n^XZ");
249+
});
250+
251+
it("schema rejects non-4-digit passwords", () => {
252+
expect(() => printerProfileSchema.parse({ ...base, setPassword: "123" })).toThrow();
253+
expect(() => printerProfileSchema.parse({ ...base, setPassword: "12345" })).toThrow();
254+
expect(() => printerProfileSchema.parse({ ...base, setPassword: "abcd" })).toThrow();
255+
});
256+
257+
it("round-trips ^KP via the parser", () => {
258+
const { printerProfile: parsed } = parseZPL("^XA^KP9876^XZ");
259+
expect(parsed.setPassword).toBe("9876");
260+
});
261+
262+
it("parser rejects ^KP values that don't match the 4-digit shape", () => {
263+
expect(parseZPL("^XA^KP12^XZ").printerProfile.setPassword).toBeUndefined();
264+
expect(parseZPL("^XA^KP12A4^XZ").printerProfile.setPassword).toBeUndefined();
265+
});
266+
267+
it("emits ^JU{action} for each spec value", () => {
268+
expect(generateSetupScript({ ...base, configurationUpdate: "S" }))
269+
.toBe("^XA\n^JUS\n^XZ");
270+
expect(generateSetupScript({ ...base, configurationUpdate: "R" }))
271+
.toBe("^XA\n^JUR\n^XZ");
272+
expect(generateSetupScript({ ...base, configurationUpdate: "N" }))
273+
.toBe("^XA\n^JUN\n^XZ");
274+
expect(generateSetupScript({ ...base, configurationUpdate: "F" }))
275+
.toBe("^XA\n^JUF\n^XZ");
276+
});
277+
278+
it("places ^JU last in the block so ^JUS commits everything before it", () => {
279+
const script = generateSetupScript({
280+
...base,
281+
setPassword: "4711",
282+
printerName: "lab-01",
283+
configurationUpdate: "S",
284+
});
285+
const lines = script.split("\n");
286+
expect(lines[lines.length - 2]).toBe("^JUS");
287+
expect(lines[lines.length - 1]).toBe("^XZ");
288+
});
289+
290+
it("round-trips ^JU via the parser", () => {
291+
for (const action of ["S", "R", "N", "F"] as const) {
292+
const { printerProfile: parsed } = parseZPL(`^XA^JU${action}^XZ`);
293+
expect(parsed.configurationUpdate).toBe(action);
294+
}
295+
});
296+
297+
it("parser rejects ^JU with an unknown action letter", () => {
298+
expect(parseZPL("^XA^JUX^XZ").printerProfile.configurationUpdate).toBeUndefined();
299+
});
300+
241301
it("emits ^SL with mode S only when language is unset", () => {
242302
expect(generateSetupScript({ ...base, clockMode: "S" })).toBe("^XA\n^SLS\n^XZ");
243303
});
@@ -346,12 +406,24 @@ describe("generateSetupScript — output shape", () => {
346406
"zplMode",
347407
"printerName",
348408
"printerDescription",
409+
"setPassword",
349410
"clockMode",
350411
"clockTolerance",
351412
"clockLanguage",
413+
"configurationUpdate",
352414
]);
353415
});
354416

417+
it("places configurationUpdate as the last entry so ^JUS commits the block", () => {
418+
// Tripwire: ^JU emits whatever persistent writes precede it, so a
419+
// future field added after configurationUpdate would land inside
420+
// the same ^XA…^XZ block and only get committed on the next
421+
// print job, not at provisioning.
422+
expect(SETUP_SCRIPT_FIELDS[SETUP_SCRIPT_FIELDS.length - 1]).toBe(
423+
"configurationUpdate",
424+
);
425+
});
426+
355427
it("PrinterProfile carries only Setup-Script fields — no per-label leakage", () => {
356428
// The schema must not accept per-label keys (^MD, ~SD, ^PR, ^MN
357429
// and friends). This pins the "no double-source-of-truth"

‎src/lib/zplSetupScript.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ const SETUP_SCRIPT_EMITTERS = {
9898
},
9999
},
100100
printerDescription: { kind: 'foldedInto', target: 'printerName' },
101+
setPassword: {
102+
kind: 'emit',
103+
channel: 'block',
104+
emit: (p) => p.setPassword !== undefined ? `^KP${p.setPassword}` : null,
105+
},
101106
clockMode: {
102107
kind: 'emit',
103108
channel: 'block',
@@ -115,6 +120,19 @@ const SETUP_SCRIPT_EMITTERS = {
115120
// Drop bare `^SL,<lang>` (mode unset): spec leaves the shape
116121
// implementation-defined.
117122
clockLanguage: { kind: 'foldedInto', target: 'clockMode' },
123+
// configurationUpdate is the registry's last entry on purpose: a
124+
// commit (`^JUS`) needs to follow every other persistent write so
125+
// the EEPROM lands the rest of the block before being asked to
126+
// save itself. The Zebra app note for ^JU also warns that more
127+
// than one ^JU per file is unsupported, so we never split this.
128+
// The "is-last" invariant is anchored by a tripwire in
129+
// zplSetupScript.test.ts so adding a later entry can't silently
130+
// break the commit semantics.
131+
configurationUpdate: {
132+
kind: 'emit',
133+
channel: 'block',
134+
emit: (p) => p.configurationUpdate !== undefined ? `^JU${p.configurationUpdate}` : null,
135+
},
118136
} as const satisfies Partial<Record<keyof PrinterProfile, SetupScriptEntry>>;
119137

120138
/** Public list of the PrinterProfile fields that flow through the

‎src/locales/ar.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,14 @@ const ar = {
235235
printerName: 'اسم الطابعة',
236236
printerNameHint: 'Max 16 characters. Used in front-panel labels and ZebraNet print server.',
237237
printerDescription: 'وصف الطابعة',
238+
setPassword: 'كلمة مرور اللوحة الأمامية',
239+
setPasswordHint: '4 أرقام. 0000 يعطل قفل وضع إعداد LCD. الإعداد الافتراضي: 1234.',
240+
configurationUpdate: 'تحديث الإعدادات',
241+
configurationUpdateUnset: '(بدون إجراء)',
242+
configurationUpdateOptionS: 'S — حفظ في EEPROM',
243+
configurationUpdateOptionR: 'R — استدعاء آخر إعداد محفوظ',
244+
configurationUpdateOptionN: 'N — إعادة تحميل إعدادات الشبكة المصنعية (مدمر)',
245+
configurationUpdateOptionF: 'F — إعادة الضبط للمصنع (مدمر)',
238246
},
239247
},
240248

‎src/locales/bg.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,14 @@ const bg = {
235235
printerName: 'Име на принтера',
236236
printerNameHint: 'Макс. 16 знака. Използва се в етикетите на предния панел и сървъра за печат ZebraNet.',
237237
printerDescription: 'Описание на принтера',
238+
setPassword: 'Парола на панела',
239+
setPasswordHint: '4 цифри. 0000 деактивира заключването на LCD. Фабрична настройка: 1234.',
240+
configurationUpdate: 'Актуализация на конфигурацията',
241+
configurationUpdateUnset: '(без действие)',
242+
configurationUpdateOptionS: 'S — Запис в EEPROM',
243+
configurationUpdateOptionR: 'R — Извикване на последно записаното',
244+
configurationUpdateOptionN: 'N — Зареди фабрични мрежови настройки (разрушително)',
245+
configurationUpdateOptionF: 'F — Фабрично нулиране (разрушително)',
238246
},
239247
},
240248

‎src/locales/cs.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,14 @@ const cs = {
235235
printerName: 'Název tiskárny',
236236
printerNameHint: 'Max. 16 znaků. Používá se v štítcích předního panelu a tiskovém serveru ZebraNet.',
237237
printerDescription: 'Popis tiskárny',
238+
setPassword: 'Heslo panelu',
239+
setPasswordHint: '4 číslice. 0000 vypne uzamčení LCD. Tovární výchozí: 1234.',
240+
configurationUpdate: 'Aktualizace konfigurace',
241+
configurationUpdateUnset: '(žádná akce)',
242+
configurationUpdateOptionS: 'S — Uložit do EEPROM',
243+
configurationUpdateOptionR: 'R — Obnovit poslední uloženou',
244+
configurationUpdateOptionN: 'N — Načíst tovární síťová nastavení (destruktivní)',
245+
configurationUpdateOptionF: 'F — Tovární reset (destruktivní)',
238246
},
239247
},
240248

‎src/locales/da.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,14 @@ const da = {
235235
printerName: 'Printernavn',
236236
printerNameHint: 'Maks. 16 tegn. Bruges i frontpanel-etiketter og ZebraNet-printserver.',
237237
printerDescription: 'Printerbeskrivelse',
238+
setPassword: 'Frontpanelens adgangskode',
239+
setPasswordHint: '4 cifre. 0000 deaktiverer LCD-låsen. Fabriksstandard: 1234.',
240+
configurationUpdate: 'Konfigurationsopdatering',
241+
configurationUpdateUnset: '(ingen handling)',
242+
configurationUpdateOptionS: 'S — Gem i EEPROM',
243+
configurationUpdateOptionR: 'R — Hent senest gemte',
244+
configurationUpdateOptionN: 'N — Indlæs fabrik-netværksindstillinger (destruktiv)',
245+
configurationUpdateOptionF: 'F — Fabriksnulstilling (destruktiv)',
238246
},
239247
},
240248

0 commit comments

Comments
 (0)