diff --git a/README.md b/README.md index 467e7042..f8f3abe2 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ On desktop, **Connect data** also reads an Excel worksheet. **File → Settings ### Printer settings -**File → Settings… → Per Label** configures media, print quality, output and RFID. The **Setup Script** covers clock, encoding, fonts, printer identity and maintenance. +**File → Settings… → Per Label** configures media, print quality, output and RFID. The **Setup Script** covers clock, encoding, fonts, printer identity, maintenance, sensors and interfaces. - The **Objects** group selects the fonts and graphics the **Setup Script** uploads. - It lists cached images and deletes the unused ones. @@ -188,7 +188,7 @@ On desktop, **File → Settings… → App → MCP** starts a local MCP server, ## Coverage -125 of the 225 ZPL II commands are modelled in the browser; desktop covers 9 more with a connected printer. 2 more are planned for both builds. 67 need a connected printer and are planned for desktop. See per-command coverage: [docs/zpl-coverage.md](docs/zpl-coverage.md). +132 of the 225 ZPL II commands are modelled in the browser; desktop covers 9 more with a connected printer. 2 more are planned for both builds. 60 need a connected printer and are planned for desktop. See per-command coverage: [docs/zpl-coverage.md](docs/zpl-coverage.md). | Area | Modelled | |---|---| @@ -205,7 +205,7 @@ On desktop, **File → Settings… → App → MCP** starts a local MCP server, | Text & fonts | 14 / 15 | | Print quality | 10 / 18 | | Configuration & persistence | 4 / 5 | -| Hardware / Host comm / RFID / Network | 4 / 89 | +| Hardware / Host comm / RFID / Network | 11 / 89 | ## Limitations diff --git a/docs/zpl-coverage.md b/docs/zpl-coverage.md index 723ffdd8..7a259ea8 100644 --- a/docs/zpl-coverage.md +++ b/docs/zpl-coverage.md @@ -240,8 +240,8 @@ Printer-side control, calibration and device actions; most need a connection or | `~JF` | set battery condition, PA400 and PT400 only | `[ ]` | `[ ]` | `[ ]` | | `~JG` | graphing sensor calibration | `[ ]` | `[~]` | `[~]` | | `^JI` / `~JI` | start ZBI | `[ ]` | `[~]` | `[~]` | -| `^JJ` | set auxiliary port | `[ ]` | `[~]` | `[~]` | -| `^JS` | sensor select | `[ ]` | `[~]` | `[~]` | +| `^JJ` | set auxiliary port | `[x]` | `[x]` | `[x]` | +| `^JS` | sensor select | `[x]` | `[x]` | `[x]` | | `~JL` | set label length | `[ ]` | `[~]` | `[~]` | | `^JM` | dots per millimeter | `[x]` | `[x]` | `[x]` | | `~JN` | printhead test fatal | `[ ]` | `[~]` | `[~]` | @@ -251,19 +251,19 @@ Printer-side control, calibration and device actions; most need a connection or | `~JR` | power on reset | `[ ]` | `[~]` | `[~]` | | `~JX` | cancel partial format | `[ ]` | `[~]` | `[~]` | | `~RO` | reset advanced counter | `[ ]` | `[~]` | `[~]` | -| `^SC` | set serial communications | `[ ]` | `[~]` | `[~]` | -| `^SI` | set sensor intensity | `[ ]` | `[~]` | `[~]` | +| `^SC` | set serial communications | `[x]` | `[x]` | `[x]` | +| `^SI` | set sensor intensity | `[x]` | `[x]` | `[x]` | | `^SP` | start print at dot row, throughput hint only | `[ ]` | `[ ]` | `[ ]` | | `^SQ` | halt ZebraNet alert | `[ ]` | `[~]` | `[~]` | -| `^SR` | set printhead resistance | `[ ]` | `[~]` | `[~]` | -| `^SS` | set media sensors | `[ ]` | `[~]` | `[~]` | +| `^SR` | set printhead resistance | `[x]` | `[x]` | `[x]` | +| `^SS` | set media sensors | `[x]` | `[x]` | `[x]` | | `^SX` | set ZebraNet alert | `[ ]` | `[~]` | `[~]` | | `^TO` | transfer object | `[ ]` | `[~]` | `[~]` | | `~WC` | print configuration label | `[ ]` | `[~]` | `[~]` | | `^WD` | print directory label | `[ ]` | `[~]` | `[~]` | | `~WQ` | write query | `[ ]` | `[~]` | `[~]` | | `^KV` | cutter and presenter settings, KR403 only | `[ ]` | `[ ]` | `[ ]` | -| `^XS` | set dynamic media calibration | `[ ]` | `[~]` | `[~]` | +| `^XS` | set dynamic media calibration | `[x]` | `[x]` | `[x]` | | `~PM` | decommissioning mode | `[ ]` | `[~]` | `[~]` | | `^ZZ` | printer sleep, PA400 and PT400 only | `[ ]` | `[ ]` | `[ ]` | | `^CP` | eject or retract a presented page, KR403 only | `[ ]` | `[ ]` | `[ ]` | diff --git a/packages/core/src/catalog/commands.json b/packages/core/src/catalog/commands.json index a722b796..cfe9c294 100644 --- a/packages/core/src/catalog/commands.json +++ b/packages/core/src/catalog/commands.json @@ -5286,9 +5286,8 @@ "example": "^JJ1,0,p,f,d,e" }, "support": { - "web": "no", - "desktop": "planned", - "docker": "planned" + "web": "yes", + "desktop": "yes" } }, { @@ -5312,9 +5311,8 @@ "example": "^JSA" }, "support": { - "web": "no", - "desktop": "planned", - "docker": "planned" + "web": "yes", + "desktop": "yes" } }, { @@ -5552,9 +5550,8 @@ "example": "^SC9600,8,N,1,X,N" }, "support": { - "web": "no", - "desktop": "planned", - "docker": "planned" + "web": "yes", + "desktop": "yes" } }, { @@ -5584,9 +5581,8 @@ "example": "^SI1,120" }, "support": { - "web": "no", - "desktop": "planned", - "docker": "planned" + "web": "yes", + "desktop": "yes" } }, { @@ -5671,9 +5667,8 @@ "example": "^SR0675" }, "support": { - "web": "no", - "desktop": "planned", - "docker": "planned" + "web": "yes", + "desktop": "yes" } }, { @@ -5745,9 +5740,8 @@ "example": "^SS075,045,070,0812,090,085,060,055,075" }, "support": { - "web": "no", - "desktop": "planned", - "docker": "planned" + "web": "yes", + "desktop": "yes" } }, { @@ -6000,9 +5994,9 @@ ], "section": "Hardware control & calibration", "title": "set dynamic media calibration", - "summary": "Controls dynamic recalibration of label length and sensor threshold after power-up or media-compartment open.", + "summary": "Controls dynamic recalibration of label length and sensor threshold after power-up or media-compartment open. The gain slot is announced for a later firmware.", "reference": { - "syntax": "^XSa,b", + "syntax": "^XSa,b,c", "params": [ { "name": "a", @@ -6015,14 +6009,19 @@ "meaning": "enable dynamic threshold calibration", "values": "Y enable, N disable", "default": "Y" + }, + { + "name": "c", + "meaning": "enable dynamic gain calibration", + "values": "Y enable, N disable", + "default": "Y" } ], - "example": "^XSY,Y" + "example": "^XSY,Y,Y" }, "support": { - "web": "no", - "desktop": "planned", - "docker": "planned" + "web": "yes", + "desktop": "yes" } }, { diff --git a/packages/core/src/lib/importReport.ts b/packages/core/src/lib/importReport.ts index 4f0be4d1..84239056 100644 --- a/packages/core/src/lib/importReport.ts +++ b/packages/core/src/lib/importReport.ts @@ -50,7 +50,9 @@ export function resolveRoutedReport( } if (f.pageIndex === DOCUMENT_FINDING) return [f]; const newIndex = newIndexOf.get(f.pageIndex); - return newIndex === undefined ? [] : [{ ...f, pageIndex: newIndex }]; + // A setup-only page leaves with its block, but what the profile could not take is still lost. + if (newIndex === undefined) return f.kind === 'partial' ? [{ ...f, pageIndex: DOCUMENT_FINDING }] : []; + return [{ ...f, pageIndex: newIndex }]; }); return reportOf(findings); } diff --git a/packages/core/src/lib/zplParser.ts b/packages/core/src/lib/zplParser.ts index 9bd2e603..cc1cfb3e 100644 --- a/packages/core/src/lib/zplParser.ts +++ b/packages/core/src/lib/zplParser.ts @@ -161,7 +161,7 @@ export const BY_CONSUMING_BARCODE_TYPES = new Set([ * support "no" plus a handler also holds for ^FM, ^HT and ^LF. */ export const DEVICE_ACTION_IDS: ReadonlySet = new Set([ "~JA", "~JC", "~JD", "~JE", "^JI", "~JI", "~JR", "^ID", "^IS", "~EG", - "~PH", "~PP", "~PM", "~PR", "^JS", + "~PH", "~PP", "~PM", "~PR", ]); /** Commands defining persistent state a later field may consume implicitly; @@ -575,7 +575,7 @@ export function parseZPL( const canonical = `${prefix}${cmd}`; const takesPrefix = commandTakesPrefix(cmd, prefix); // The device action quotes its payload: the report must name the bytes that will run. - if (takesPrefix && replayRiskCodes.has(cmd)) replayRisk.push({ command: s.result.tokenCommand, span: s.result.tokenSpan }); + if (takesPrefix && (replayRiskCodes.has(cmd) || replayRiskCodes.has(canonical))) replayRisk.push({ command: s.result.tokenCommand, span: s.result.tokenSpan }); else if (takesPrefix && DEVICE_ACTION_IDS.has(canonical)) deviceAction.push({ command: sourceToken, span: s.result.tokenSpan }); // Balance bookkeeping needs the token offset and the state the handler is // about to flip, so it sits here rather than in the two handlers. diff --git a/packages/core/src/lib/zplParser/handlers/setupScript.ts b/packages/core/src/lib/zplParser/handlers/setupScript.ts index 6e22dc3d..9588711f 100644 --- a/packages/core/src/lib/zplParser/handlers/setupScript.ts +++ b/packages/core/src/lib/zplParser/handlers/setupScript.ts @@ -1,8 +1,8 @@ -import { CLOCK_TOLERANCE_RANGE, FONT_CACHE_KB_RANGE, FONT_LINKS_PATH_MAX_LEN, HEAD_CLEANING_INTERVAL_METERS, HEAD_TEST_INTERVAL_RANGE, JH_SLOT_F, JH_SLOT_G, MAINTENANCE_ALERT_DEFAULTS, MAINTENANCE_DISTANCE_MAX_BY_TYPE, MAINTENANCE_MESSAGE_MAX_LEN, PRINTER_NAME_MAX_LEN, PRINTER_PASSWORD_REGEX, TEAR_OFF_ADJUST_RANGE, isClockFormat, isClockLanguage, isConfigUpdateAction, isFontCacheType, isMaintenanceAlertPrint, isMaintenanceAlertType, isMaintenanceAlertUnit, isModeProtection, isPrinterLocale, isZplMode, setupScriptUnsafeCharRegex } from "../../../types/PrinterProfile"; +import { AUX_PORT_SLOTS, CLOCK_TOLERANCE_RANGE, FONT_CACHE_KB_RANGE, FONT_LINKS_PATH_MAX_LEN, HEAD_CLEANING_INTERVAL_METERS, HEAD_TEST_INTERVAL_RANGE, JH_SLOT_F, JH_SLOT_G, MAINTENANCE_ALERT_DEFAULTS, MAINTENANCE_DISTANCE_MAX_BY_TYPE, MAINTENANCE_MESSAGE_MAX_LEN, PRINTER_NAME_MAX_LEN, PRINTER_PASSWORD_REGEX, PRINTHEAD_RESISTANCE_RANGE, SENSOR_INTENSITY_RANGE, SENSOR_SLOTS, SI_FIELD_BY_SLOT, TEAR_OFF_ADJUST_RANGE, XS_SLOTS, isClockFormat, isClockLanguage, isConfigUpdateAction, isFontCacheType, isMaintenanceAlertPrint, isMaintenanceAlertType, isMaintenanceAlertUnit, isMediaSensorSelect, isModeProtection, isPrinterLocale, isSerialBaud, isSerialDataBits, isSerialHandshake, isSerialParity, isSerialProtocol, isSerialStopBits, isZplMode, setupScriptUnsafeCharRegex, type AuxPortField, type SensorSlotField } from "../../../types/PrinterProfile"; import { parseIntOrUndef } from "../../inputParse"; import { isYesNo } from "../../../types/typeHelpers"; import { parseRealtimeClock } from "../../realtimeClock"; -import type { ParserState } from "../context"; +import { notePartial, type ParserState } from "../context"; import { inRange, strParam } from "../helpers"; import type { Handler } from "../types"; @@ -207,6 +207,84 @@ export function createSetupScriptHandlers(s: ParserState): Record 0) printerProfile.fontLinks = list; else delete printerProfile.fontLinks; }, + // Keyed with its prefix: ~JS is the backfeed sequence. + "^JS"(p) { + const v = strParam(p[0]); + if (isMediaSensorSelect(v)) printerProfile.mediaSensorSelect = v; + else notePartial(s.result, "^JS"); + }, + // The spec says nothing about a bad ^SS slot, so it follows ^SI and ^SC: the command changes nothing and the loss is reported. + SS(p) { + const read = new Map(); + for (const [i, [field, range]] of SENSOR_SLOTS.entries()) { + const text = strParam(p[i]); + // An empty slot leaves the calibrated value in force, so it clears the field. + const v = text === "" ? undefined : inRange(parseIntOrUndef(text), range); + if (text !== "" && v === undefined) { + notePartial(s.result, "^SS"); + return; + } + read.set(field, v); + } + for (const [field, v] of read) { + if (v === undefined) Reflect.deleteProperty(printerProfile, field); + else printerProfile[field] = v; + } + }, + // ^SI names one value and sets it, so an empty slot is a loss and not a clear, spec p.338. + SI(p) { + const field = SI_FIELD_BY_SLOT[strParam(p[0])]; + const v = inRange(parseIntOrUndef(p[1]), SENSOR_INTENSITY_RANGE); + if (field === undefined || v === undefined) notePartial(s.result, "^SI"); + else printerProfile[field] = v; + }, + XS(p) { + // Every slot defaults to Y, spec p.374, so a bare ^XS must not vanish on re-emit. + const bare = p.every((v) => v.trim() === ""); + const values = XS_SLOTS.map((_field, i) => (bare ? "Y" : strParam(p[i]))); + if (values.some((v) => v !== "" && !isYesNo(v)) || p.slice(XS_SLOTS.length).some((v) => v.trim() !== "")) { + notePartial(s.result, "^XS"); + return; + } + XS_SLOTS.forEach((field, i) => { + const v = values[i] ?? ""; + if (isYesNo(v)) printerProfile[field] = v; + else Reflect.deleteProperty(printerProfile, field); + }); + }, + SR(p) { + const text = strParam(p[0]); + if (text === "") delete printerProfile.printheadResistance; + else { + const v = inRange(parseIntOrUndef(text), PRINTHEAD_RESISTANCE_RANGE); + if (v === undefined) notePartial(s.result, "^SR"); + else printerProfile.printheadResistance = v; + } + }, + SC(p) { + const baud = strParam(p[0]); + const dataBits = strParam(p[1]); + const parity = strParam(p[2]); + const stopBits = strParam(p[3]); + const handshake = strParam(p[4]); + const protocol = strParam(p[5]); + if (!isSerialBaud(baud) || !isSerialDataBits(dataBits) || !isSerialParity(parity) || !isSerialStopBits(stopBits) || !isSerialHandshake(handshake) || !isSerialProtocol(protocol)) { + notePartial(s.result, "^SC"); + return; + } + printerProfile.serialPort = { baud, dataBits, parity, stopBits, handshake, protocol }; + }, + JJ(p) { + // Every slot has a default, so a bare ^JJ must not vanish on re-emit. + const bare = p.every((v) => v.trim() === ""); + AUX_PORT_SLOTS.forEach(([field, values, fallback], i) => { + const v = bare ? fallback : (p[i] ?? "").trim().toLowerCase(); + Reflect.deleteProperty(printerProfile, field); + if (v === "") return; + if (values.some((allowed) => allowed === v)) (printerProfile as Record)[field] = v; + else notePartial(s.result, "^JJ"); + }); + }, // ^JH: only f/g modelled; other slots are runtime reset flags. // g is a 0..16 index into HEAD_CLEANING_INTERVAL_METERS per spec. JH(p) { diff --git a/packages/core/src/lib/zplParser/handlers/unsupported.ts b/packages/core/src/lib/zplParser/handlers/unsupported.ts index c9697df2..bc697d11 100644 --- a/packages/core/src/lib/zplParser/handlers/unsupported.ts +++ b/packages/core/src/lib/zplParser/handlers/unsupported.ts @@ -18,7 +18,6 @@ export function createUnsupportedHandlers(s: ParserState): Record deleteStoredObjects(s, p[0] ?? ""), IS: noop, diff --git a/packages/core/src/types/PrinterProfile.test.ts b/packages/core/src/types/PrinterProfile.test.ts index 6b1ffaee..b4ae8eb5 100644 --- a/packages/core/src/types/PrinterProfile.test.ts +++ b/packages/core/src/types/PrinterProfile.test.ts @@ -33,6 +33,11 @@ describe("setupFonts download", () => { }); describe("repairPrinterProfile", () => { + it("drops the serial port as one key, while an applicator slot costs only itself", () => { + expect(repairPrinterProfile({ serialPort: { baud: "9600", dataBits: "9" }, auxPortMode: "1", auxPortEndPrint: "9" })).toEqual({ auxPortMode: "1" }); + expect(repairPrinterProfile({ serialPort: {} })).toEqual({}); + }); + it("drops the key a cross-field rule names, even when the rule reports the absent side", () => { expect(repairPrinterProfile({ clockMode: "TOL" })).toEqual({}); expect(repairPrinterProfile({ clockMode: "S", clockTolerance: 30, printerName: "P1" })).toEqual({ clockMode: "S", printerName: "P1" }); diff --git a/packages/core/src/types/PrinterProfile.ts b/packages/core/src/types/PrinterProfile.ts index 8bdab190..3fec97bc 100644 --- a/packages/core/src/types/PrinterProfile.ts +++ b/packages/core/src/types/PrinterProfile.ts @@ -81,6 +81,72 @@ export function toggleModeProtection( } export const TEAR_OFF_ADJUST_RANGE = { min: -120, max: 120 } as const; +/** ^JS: A auto, R reflective, T transmissive. */ +export const MEDIA_SENSOR_SELECT_VALUES = ['A', 'R', 'T'] as const; +export type MediaSensorSelect = (typeof MEDIA_SENSOR_SELECT_VALUES)[number]; +export const isMediaSensorSelect = makeEnumGuard(MEDIA_SENSOR_SELECT_VALUES); +/** ^SS w, m, r, a, b, spec p.349. */ +const SENSOR_THRESHOLD_RANGE = { min: 0, max: 100 } as const; +/** ^SS n, o, c. */ +const SENSOR_LED_RANGE = { min: 0, max: 255 } as const; +/** ^SS l. */ +const SENSOR_LABEL_LENGTH_RANGE = { min: 1, max: 32000 } as const; +/** ^SI b, spec p.338. */ +export const SENSOR_INTENSITY_RANGE = { min: 0, max: 196 } as const; +/** ^XS a, b, c in wire order, spec p.374. */ +export const XS_SLOTS = ['dynamicCalibrationLength', 'dynamicCalibrationThreshold', 'dynamicCalibrationGain'] as const; +/** ^SR, spec p.348. */ +export const PRINTHEAD_RESISTANCE_RANGE = { min: 488, max: 1175 } as const; + +/** ^SC slots, spec p.332. */ +export const SERIAL_BAUD_VALUES = ['110', '300', '600', '1200', '2400', '4800', '9600', '14400', '19200', '28800', '38400', '57600', '115200'] as const; +export const isSerialBaud = makeEnumGuard(SERIAL_BAUD_VALUES); +export const SERIAL_DATA_BITS_VALUES = ['7', '8'] as const; +export const isSerialDataBits = makeEnumGuard(SERIAL_DATA_BITS_VALUES); +export const SERIAL_PARITY_VALUES = ['N', 'E', 'O'] as const; +export const isSerialParity = makeEnumGuard(SERIAL_PARITY_VALUES); +export const SERIAL_STOP_BITS_VALUES = ['1', '2'] as const; +export const isSerialStopBits = makeEnumGuard(SERIAL_STOP_BITS_VALUES); +export const SERIAL_HANDSHAKE_VALUES = ['X', 'D', 'R', 'M'] as const; +export const isSerialHandshake = makeEnumGuard(SERIAL_HANDSHAKE_VALUES); +export const SERIAL_PROTOCOL_VALUES = ['A', 'N', 'Z'] as const; +export const isSerialProtocol = makeEnumGuard(SERIAL_PROTOCOL_VALUES); +export const SERIAL_PORT_FIELDS = ['baud', 'dataBits', 'parity', 'stopBits', 'handshake', 'protocol'] as const; + +export const AUX_PORT_MODE_VALUES = ['0', '1', '2'] as const; +export const AUX_PORT_END_PRINT_VALUES = ['0', '1', '2', '3', '4'] as const; +export const AUX_PORT_START_PRINT_VALUES = ['p', 'l'] as const; +export const AUX_PORT_ERROR_MODE_VALUES = ['e', 'f'] as const; +export const AUX_PORT_SWITCH_VALUES = ['e', 'd'] as const; +/** ^JJ slots in wire order with their values and the printer default, spec p.266. + * The guide prints 0 as the start print default, the SGD device.applicator.start_print_mode says pulse, p.682. */ +export const AUX_PORT_SLOTS = [ + ['auxPortMode', AUX_PORT_MODE_VALUES, '0'], + ['auxPortEndPrint', AUX_PORT_END_PRINT_VALUES, '0'], + ['auxPortStartPrint', AUX_PORT_START_PRINT_VALUES, 'p'], + ['auxPortErrorMode', AUX_PORT_ERROR_MODE_VALUES, 'f'], + ['auxPortReprint', AUX_PORT_SWITCH_VALUES, 'd'], + ['auxPortRibbonLow', AUX_PORT_SWITCH_VALUES, 'e'], +] as const satisfies readonly (readonly [keyof PrinterProfile, readonly string[], string])[]; +export type AuxPortField = (typeof AUX_PORT_SLOTS)[number][0]; + +/** ^SS slots in wire order with their range and digit width, spec p.349. */ +export const SENSOR_SLOTS = [ + ['sensorWebThreshold', SENSOR_THRESHOLD_RANGE, 3], + ['sensorMediaThreshold', SENSOR_THRESHOLD_RANGE, 3], + ['sensorRibbonThreshold', SENSOR_THRESHOLD_RANGE, 3], + ['sensorLabelLengthDots', SENSOR_LABEL_LENGTH_RANGE, 4], + ['sensorMediaLedIntensity', SENSOR_LED_RANGE, 3], + ['sensorRibbonLedIntensity', SENSOR_LED_RANGE, 3], + ['sensorMarkThreshold', SENSOR_THRESHOLD_RANGE, 3], + ['sensorMarkMediaThreshold', SENSOR_THRESHOLD_RANGE, 3], + ['sensorMarkLedIntensity', SENSOR_LED_RANGE, 3], +] as const satisfies readonly (readonly [keyof PrinterProfile, { min: number; max: number }, number])[]; +export type SensorSlotField = (typeof SENSOR_SLOTS)[number][0]; +export const SENSOR_SLOT_RANGE = Object.fromEntries(SENSOR_SLOTS.map(([field, range]) => [field, range])) as Record; +/** ^SI a picks the value the b slot sets, spec p.338. */ +export const SI_FIELD_BY_SLOT: Readonly> = { '1': 'sensorBrightness', '2': 'sensorBaseline' }; + /** ^KP 4-digit; '0000' disables LCD-setup protection. */ export const PRINTER_PASSWORD_REGEX = /^\d{4}$/; @@ -220,6 +286,44 @@ export const printerProfileSchema = z.object({ path: z.string().min(1).max(FONT_LINKS_PATH_MAX_LEN).regex(setupScriptSafeStringRegex), gfa: z.string().min(1).max(SETUP_UPLOAD_MAX_CHARS), })).optional(), + /** ^JS media sensor. */ + mediaSensorSelect: z.enum(MEDIA_SENSOR_SELECT_VALUES).optional(), + /** ^SS slots, each on its own so one bad value drops one slot. */ + sensorWebThreshold: intInRange(SENSOR_THRESHOLD_RANGE).optional(), + sensorMediaThreshold: intInRange(SENSOR_THRESHOLD_RANGE).optional(), + sensorRibbonThreshold: intInRange(SENSOR_THRESHOLD_RANGE).optional(), + /** The calibrated length, not the design's ^LL. */ + sensorLabelLengthDots: intInRange(SENSOR_LABEL_LENGTH_RANGE).optional(), + sensorMediaLedIntensity: intInRange(SENSOR_LED_RANGE).optional(), + sensorRibbonLedIntensity: intInRange(SENSOR_LED_RANGE).optional(), + sensorMarkThreshold: intInRange(SENSOR_THRESHOLD_RANGE).optional(), + sensorMarkMediaThreshold: intInRange(SENSOR_THRESHOLD_RANGE).optional(), + sensorMarkLedIntensity: intInRange(SENSOR_LED_RANGE).optional(), + /** ^SI1 and ^SI2, the transmissive sensor's brightness and baseline. */ + sensorBrightness: intInRange(SENSOR_INTENSITY_RANGE).optional(), + sensorBaseline: intInRange(SENSOR_INTENSITY_RANGE).optional(), + /** ^XS length, threshold and gain slots. */ + dynamicCalibrationLength: z.enum(YES_NO_VALUES).optional(), + dynamicCalibrationThreshold: z.enum(YES_NO_VALUES).optional(), + dynamicCalibrationGain: z.enum(YES_NO_VALUES).optional(), + /** ^SR, a value above the head's rating damages it. */ + printheadResistance: intInRange(PRINTHEAD_RESISTANCE_RANGE).optional(), + /** ^SC. The spec gives no slot a default, so the wire carries all six or none, p.332. */ + serialPort: z.object({ + baud: z.enum(SERIAL_BAUD_VALUES).optional(), + dataBits: z.enum(SERIAL_DATA_BITS_VALUES).optional(), + parity: z.enum(SERIAL_PARITY_VALUES).optional(), + stopBits: z.enum(SERIAL_STOP_BITS_VALUES).optional(), + handshake: z.enum(SERIAL_HANDSHAKE_VALUES).optional(), + protocol: z.enum(SERIAL_PROTOCOL_VALUES).optional(), + }).refine((o) => SERIAL_PORT_FIELDS.some((f) => o[f] !== undefined), 'an empty serial port carries nothing').optional(), + /** ^JJ slots, each with its own printer default. */ + auxPortMode: z.enum(AUX_PORT_MODE_VALUES).optional(), + auxPortEndPrint: z.enum(AUX_PORT_END_PRINT_VALUES).optional(), + auxPortStartPrint: z.enum(AUX_PORT_START_PRINT_VALUES).optional(), + auxPortErrorMode: z.enum(AUX_PORT_ERROR_MODE_VALUES).optional(), + auxPortReprint: z.enum(AUX_PORT_SWITCH_VALUES).optional(), + auxPortRibbonLow: z.enum(AUX_PORT_SWITCH_VALUES).optional(), /** ^JH f master gate; without `E` ^MA sits dormant. */ earlyWarningMaintenance: z.enum(['E', 'D']).optional(), /** ^JH g; stored as meters for UX, wire is index into table. */ @@ -313,7 +417,7 @@ export const isSetupPath = (path: string): boolean => path.length <= FONT_LINKS_ /** Fields with their own Objects group: a source edit never strips them and Clear keeps them. */ export const SETUP_UPLOAD_FIELDS = ["setupFonts", "setupGraphics"] as const; -/** Fields whose value differs. By value: four fields hold objects. */ +/** Fields whose value differs, by value since some fields hold objects. */ export function changedProfileFields(before: PrinterProfile, after: PrinterProfile): PrinterProfileField[] { return PRINTER_PROFILE_FIELDS.filter((field) => JSON.stringify(after[field]) !== JSON.stringify(before[field])); } diff --git a/src/components/PrinterSettings/InterfacesTab.test.tsx b/src/components/PrinterSettings/InterfacesTab.test.tsx new file mode 100644 index 00000000..227c5827 --- /dev/null +++ b/src/components/PrinterSettings/InterfacesTab.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { render, cleanup, fireEvent, act } from "@testing-library/react"; +import { InterfacesTab } from "./InterfacesTab"; +import { useLabelStore } from "../../store/labelStore"; +import { fallbackTranslations as en } from "../../locales"; + +afterEach(cleanup); +beforeEach(() => { + act(() => { + useLabelStore.setState({ printerProfile: {} }); + }); +}); + +const loc = en.printerSettings.interfaces; +const profile = () => useLabelStore.getState().printerProfile; +const pick = (r: ReturnType, field: string, option: string | RegExp) => { + fireEvent.click(r.getByRole("button", { name: field })); + fireEvent.click(r.getByRole("option", { name: option })); +}; + +describe("InterfacesTab", () => { + it("builds the serial port slot by slot and clears the field with the last slot", () => { + const r = render(); + pick(r, loc.parity, new RegExp(loc.parityE)); + pick(r, loc.baud, "9600"); + expect(profile().serialPort).toEqual({ baud: "9600", parity: "E" }); + pick(r, loc.baud, loc.notSet); + pick(r, loc.parity, loc.notSet); + expect(profile()).toEqual({}); + }); + + it("fills the six serial slots from a preset and says so while the port is incomplete", () => { + const r = render(); + expect(r.getByRole("button", { name: en.label.preset }).textContent).toContain(loc.notSet); + pick(r, loc.baud, "9600"); + expect(r.getByText(loc.serialIncomplete)).toBeTruthy(); + expect(r.getByRole("button", { name: en.label.preset }).textContent).toContain(en.label.presetCustom); + pick(r, en.label.preset, /^115200 8N1/); + expect(profile().serialPort).toEqual({ baud: "115200", dataBits: "8", parity: "N", stopBits: "1", handshake: "X", protocol: "N" }); + expect(r.queryByText(loc.serialIncomplete)).toBeNull(); + fireEvent.click(r.getByRole("button", { name: en.label.preset })); + expect(r.queryByRole("option", { name: en.label.presetCustom })).toBeNull(); + expect(r.queryByRole("option", { name: loc.notSet })).toBeNull(); + }); + + it("sets one applicator slot on its own once the port is shown", () => { + const r = render(); + expect(r.queryByRole("button", { name: loc.startPrint })).toBeNull(); + fireEvent.click(r.getByRole("button", { name: loc.auxShow })); + pick(r, loc.startPrint, new RegExp(loc.startPrintL)); + expect(profile()).toEqual({ auxPortStartPrint: "l" }); + expect(r.queryByRole("button", { name: loc.auxHide })).toBeNull(); + pick(r, loc.startPrint, en.printerSettings.defaultOption); + expect(profile()).toEqual({}); + }); +}); diff --git a/src/components/PrinterSettings/InterfacesTab.tsx b/src/components/PrinterSettings/InterfacesTab.tsx new file mode 100644 index 00000000..c7de1812 --- /dev/null +++ b/src/components/PrinterSettings/InterfacesTab.tsx @@ -0,0 +1,226 @@ +import { useId, useState } from "react"; +import { useT } from "../../hooks/useT"; +import { useLabelStore } from "../../store/labelStore"; +import { + AUX_PORT_SLOTS, + AUX_PORT_END_PRINT_VALUES, + AUX_PORT_ERROR_MODE_VALUES, + AUX_PORT_MODE_VALUES, + AUX_PORT_START_PRINT_VALUES, + AUX_PORT_SWITCH_VALUES, + SERIAL_BAUD_VALUES, + SERIAL_DATA_BITS_VALUES, + SERIAL_HANDSHAKE_VALUES, + SERIAL_PARITY_VALUES, + SERIAL_PORT_FIELDS, + SERIAL_PROTOCOL_VALUES, + SERIAL_STOP_BITS_VALUES, + type PrinterProfile, +} from "@zplab/core/types/PrinterProfile"; +import { ZplCommandLabel, ZplEnumSubCustomSelect, ZplField, ZplFieldHint } from "./zplFieldPrimitives"; +import { fieldGridCols, fieldGridCell } from "../ui/formStyles"; + +type LocInterfaces = ReturnType["printerSettings"]["interfaces"]; +type SerialPort = NonNullable; + +const PARITY_KEYS = { N: "parityN", E: "parityE", O: "parityO" } as const satisfies Record<(typeof SERIAL_PARITY_VALUES)[number], keyof LocInterfaces>; +const HANDSHAKE_KEYS = { X: "handshakeX", D: "handshakeD", R: "handshakeR", M: "handshakeM" } as const satisfies Record<(typeof SERIAL_HANDSHAKE_VALUES)[number], keyof LocInterfaces>; +const PROTOCOL_KEYS = { A: "protocolA", N: "protocolN", Z: "protocolZ" } as const satisfies Record<(typeof SERIAL_PROTOCOL_VALUES)[number], keyof LocInterfaces>; +const AUX_MODE_KEYS = { "0": "auxMode0", "1": "auxMode1", "2": "auxMode2" } as const satisfies Record<(typeof AUX_PORT_MODE_VALUES)[number], keyof LocInterfaces>; +const END_PRINT_KEYS = { "0": "endPrint0", "1": "endPrint1", "2": "endPrint2", "3": "endPrint3", "4": "endPrint4" } as const satisfies Record<(typeof AUX_PORT_END_PRINT_VALUES)[number], keyof LocInterfaces>; +const START_PRINT_KEYS = { p: "startPrintP", l: "startPrintL" } as const satisfies Record<(typeof AUX_PORT_START_PRINT_VALUES)[number], keyof LocInterfaces>; +const ERROR_MODE_KEYS = { e: "errorModeE", f: "errorModeF" } as const satisfies Record<(typeof AUX_PORT_ERROR_MODE_VALUES)[number], keyof LocInterfaces>; +const SWITCH_KEYS = { e: "enabled", d: "disabled" } as const satisfies Record<(typeof AUX_PORT_SWITCH_VALUES)[number], keyof LocInterfaces>; +// The three line settings a host is set up with in practice, 8N1 with XON/XOFF. +const SERIAL_PRESET_BAUDS = ["9600", "19200", "115200"] as const; +const serialPreset = (baud: (typeof SERIAL_PRESET_BAUDS)[number]): SerialPort => ({ baud, dataBits: "8", parity: "N", stopBits: "1", handshake: "X", protocol: "N" }); +const noBadge = () => ""; + +/** Serial port and applicator port of the setup script (^SC, ^JJ). */ +export function InterfacesTab() { + const t = useT(); + const profile = useLabelStore((s) => s.printerProfile); + const patchPrinterProfile = useLabelStore((s) => s.patchPrinterProfile); + const loc = t.printerSettings.interfaces; + const serial: SerialPort = profile.serialPort ?? {}; + const serialSlotsSet = SERIAL_PORT_FIELDS.filter((f) => serial[f] !== undefined).length; + const activePreset = SERIAL_PRESET_BAUDS.find((baud) => SERIAL_PORT_FIELDS.every((f) => serial[f] === serialPreset(baud)[f])); + const anyAuxSet = AUX_PORT_SLOTS.some(([field]) => profile[field] !== undefined); + // A set slot ships with the script, so the port hides only while it is empty. + const [auxOpen, setAuxOpen] = useState(false); + const auxId = useId(); + const showAux = auxOpen || anyAuxSet; + // The slots are one command on the wire, so the last cleared slot clears the field. + const setSerial = (patch: Partial) => { + const merged = { ...serial, ...patch }; + // Wire order keeps the object equal by value to what the parser builds. + const next: SerialPort = Object.fromEntries(SERIAL_PORT_FIELDS.flatMap((f) => (merged[f] === undefined ? [] : [[f, merged[f]]]))); + patchPrinterProfile({ serialPort: SERIAL_PORT_FIELDS.some((f) => next[f] !== undefined) ? next : undefined }); + }; + + return ( +
+ + +
+ `${baud} 8N1 ${loc.handshakeX}`} + optionBadge={noBadge} + onChange={(baud) => baud !== undefined && patchPrinterProfile({ serialPort: serialPreset(baud) })} + className={fieldGridCell} + /> +
+
+ v} + optionBadge={noBadge} + onChange={(baud) => setSerial({ baud })} + className={fieldGridCell} + /> + v} + optionBadge={noBadge} + onChange={(dataBits) => setSerial({ dataBits })} + className={fieldGridCell} + /> + loc[PARITY_KEYS[v]]} + onChange={(parity) => setSerial({ parity })} + className={fieldGridCell} + /> + v} + optionBadge={noBadge} + onChange={(stopBits) => setSerial({ stopBits })} + className={fieldGridCell} + /> + loc[HANDSHAKE_KEYS[v]]} + onChange={(handshake) => setSerial({ handshake })} + className={fieldGridCell} + /> + loc[PROTOCOL_KEYS[v]]} + onChange={(protocol) => setSerial({ protocol })} + className={fieldGridCell} + /> +
+ + {loc.serialBaudHint} + + {serialSlotsSet > 0 && serialSlotsSet < SERIAL_PORT_FIELDS.length ? ( + + {loc.serialIncomplete} + + ) : null} +
+ + + + {anyAuxSet ? null : ( + + )} + {showAux ? ( +
+ loc[AUX_MODE_KEYS[v]]} + onChange={(auxPortMode) => patchPrinterProfile({ auxPortMode })} + className={fieldGridCell} + /> + loc[END_PRINT_KEYS[v]]} + onChange={(auxPortEndPrint) => patchPrinterProfile({ auxPortEndPrint })} + className={fieldGridCell} + /> + loc[START_PRINT_KEYS[v]]} + onChange={(auxPortStartPrint) => patchPrinterProfile({ auxPortStartPrint })} + className={fieldGridCell} + /> + loc[ERROR_MODE_KEYS[v]]} + onChange={(auxPortErrorMode) => patchPrinterProfile({ auxPortErrorMode })} + className={fieldGridCell} + /> + loc[SWITCH_KEYS[v]]} + onChange={(auxPortReprint) => patchPrinterProfile({ auxPortReprint })} + className={fieldGridCell} + /> + loc[SWITCH_KEYS[v]]} + onChange={(auxPortRibbonLow) => patchPrinterProfile({ auxPortRibbonLow })} + className={fieldGridCell} + /> +
+ ) : null} +
+
+ ); +} diff --git a/src/components/PrinterSettings/PrinterSettingsModal.tsx b/src/components/PrinterSettings/PrinterSettingsModal.tsx index 72385148..5012deff 100644 --- a/src/components/PrinterSettings/PrinterSettingsModal.tsx +++ b/src/components/PrinterSettings/PrinterSettingsModal.tsx @@ -24,6 +24,8 @@ import { StoredFontsTab } from "./StoredFontsTab"; import { StoredGraphicsTab } from "./StoredGraphicsTab"; import { IdentityTab } from "./IdentityTab"; import { MaintenanceTab } from "./MaintenanceTab"; +import { SensorsTab } from "./SensorsTab"; +import { InterfacesTab } from "./InterfacesTab"; import { McpServerTab } from "./McpServerTab"; import { MediaFeedTab } from "./MediaFeedTab"; import { RfidTab } from "./RfidTab"; @@ -54,6 +56,8 @@ const TOP_TAB_OF = { fonts: 'setupScript', identity: 'setupScript', maintenance: 'setupScript', + sensors: 'setupScript', + interfaces: 'setupScript', storedFonts: 'objects', storedGraphics: 'objects', } as const satisfies Record; @@ -98,6 +102,8 @@ const TAB_COMPONENTS: Partial> = { storedGraphics: StoredGraphicsTab, identity: IdentityTab, maintenance: MaintenanceTab, + sensors: SensorsTab, + interfaces: InterfacesTab, }; const MODAL_BOX_CLS = @@ -299,7 +305,7 @@ export function PrinterSettingsModal() {