Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte
## Coverage

<!-- coverage:start (generated from docs/zpl-roadmap.md by scripts/gen-coverage.mjs; run `pnpm coverage:gen`) -->
114 of the 225 ZPL II commands tracked in the [roadmap](docs/zpl-roadmap.md) are supported today. Categorical breakdown:
117 of the 225 ZPL II commands tracked in the [roadmap](docs/zpl-roadmap.md) are supported today. Categorical breakdown:

| Area | Supported |
|---|---|
Expand All @@ -170,7 +170,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte
| Text & fonts | 7 / 14 |
| Print quality | 10 / 18 |
| Configuration & persistence | 3 / 5 |
| Hardware / Host comm / RFID / Network | 1 / 87 |
| Hardware / Host comm / RFID / Network | 4 / 87 |
<!-- coverage:end -->

---
Expand Down
6 changes: 3 additions & 3 deletions docs/zpl-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,12 +308,12 @@ password-coupled ^RL. Read-back stays native. ^RM/^RR folded into ^RS,
| `[ ]` | `^RM` | enable motion (pre-Link-OS; folded into ^RS; passthrough) | `Out of scope` |
| `[ ]` | `^RN` | detect multiple tags (pre-Link-OS; absent from current guide; passthrough) | `Out of scope` |
| `[ ]` | `^RR` | RFID retries (pre-Link-OS; folded into ^RS; passthrough) | `Out of scope` |
| `[ ]` | `^RB` | define EPC data structure (partitions consumed by ^RF EPC writes) | `Coming soon` |
| `[ ]` | `^RS` | RFID setup (tag type, programming position, retries, error handling) | `Coming soon` |
| `[x]` | `^RB` | define EPC data structure (partitions consumed by ^RF EPC writes; spec-modelled, hardware-unverified) | |
| `[x]` | `^RS` | RFID setup (tag type, position, VOID handling, retries; legacy/a/c slots flagged partial; spec-modelled, hardware-unverified) | |
| `[ ]` | `^RT` | read tag (legacy; superseded by ^RF read) | `Native build` |
| `[ ]` | `^RU` | read unique chip serialization (TID-derived EPC serial) | `Native build` |
| `[ ]` | `~RV` | report encoding result (pre-Link-OS; absent from current guide; passthrough) | `Out of scope` |
| `[ ]` | `^RW` | set read & write power | `Coming soon` |
| `[x]` | `^RW` | set read & write power (antenna slot flagged partial; spec-modelled, hardware-unverified) | |
| `[ ]` | `^RL` | lock / permalock tag memory (password-coupled companion of ^RF) | `Coming soon` |
| `[ ]` | `^HR` | calibrate RFID tag position | `Native build` |
| `[ ]` | `^HL` / `~HL` | RFID data log (return to host) | `Native build` |
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/lib/designFile.rfid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { sanitizeRfidEpc } from "../types/LabelConfig";
import type { LabelConfig } from "../types/LabelConfig";

// Parser and UI keep the partitions summing to the bit count; a hand-edited
// design file must not slip an invalid ^RB past them.
describe("sanitizeRfidEpc", () => {
const label = (over: Partial<LabelConfig>): LabelConfig =>
({ widthMm: 50, heightMm: 30, dpmm: 8, ...over }) as LabelConfig;

it("keeps a pair whose partitions sum to the total", () => {
const l = label({ rfidEpcBits: 96, rfidEpcPartitions: [8, 24, 64] });
sanitizeRfidEpc(l);
expect(l.rfidEpcBits).toBe(96);
expect(l.rfidEpcPartitions).toEqual([8, 24, 64]);
});

it("drops the pair whole when the sum disagrees", () => {
const l = label({ rfidEpcBits: 96, rfidEpcPartitions: [1] });
sanitizeRfidEpc(l);
expect(l.rfidEpcBits).toBeUndefined();
expect(l.rfidEpcPartitions).toBeUndefined();
});

it("leaves an unpartitioned total alone", () => {
const l = label({ rfidEpcBits: 96 });
sanitizeRfidEpc(l);
expect(l.rfidEpcBits).toBe(96);
});
});
3 changes: 2 additions & 1 deletion packages/core/src/lib/designFile.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from "zod";
import { JM_DENSITY_VALUES, labelConfigSchema, type JmDensity, type LabelConfig } from "../types/LabelConfig";
import { JM_DENSITY_VALUES, labelConfigSchema, sanitizeRfidEpc, type JmDensity, type LabelConfig } from "../types/LabelConfig";
import { labelObjectBaseSchema } from "../types/LabelObject";
import {
variableSchema,
Expand Down Expand Up @@ -103,6 +103,7 @@ export function parseDesignFile(text: string): Result<DesignFile, DesignFileErro
if (parsed.success) {
const pages = parsed.data.pages as unknown as DesignFilePage[];
reconstructLegacyJmDensity(parsed.data.label, pages);
sanitizeRfidEpc(parsed.data.label);
const variables = parsed.data.variables ?? [];
// Enforce the marker-safe name invariant: an old/foreign name like `clock:Y`
// can't be a single-bind marker, so rename offenders + rewrite their markers.
Expand Down
97 changes: 97 additions & 0 deletions packages/core/src/lib/rfidEpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import {
epcAddField,
epcFieldRange,
epcRemoveField,
epcSetField,
epcSetTotal,
epcSetTrailing,
epcTotalRange,
epcTrailingField,
splitEpcBits,
} from "./rfidEpc";

describe("splitEpcBits", () => {
it("splits an existing total instead of dropping it", () => {
expect(splitEpcBits(96)).toEqual([48, 48]);
expect(splitEpcBits(97)).toEqual([49, 48]);
});

it("adds fields once 64-bit partitions cannot hold the total", () => {
expect(splitEpcBits(200)).toEqual([50, 50, 50, 50]);
expect(splitEpcBits(200)?.every((b) => b <= 64)).toBe(true);
});

it("starts from two default fields when there is no total yet", () => {
expect(splitEpcBits(undefined)).toEqual([8, 8]);
});

it("refuses to split a tag too narrow for two fields", () => {
expect(splitEpcBits(1)).toBeNull();
});

it("reports totals past 16 x 64 bits as unpartitionable", () => {
expect(splitEpcBits(1024)).not.toBeNull();
expect(splitEpcBits(1025)).toBeNull();
});
});

describe("fixed-total partition edits", () => {
const SGTIN = [8, 3, 3, 20, 24, 38];

it("derives the trailing field from the total", () => {
expect(epcTrailingField(96, SGTIN.slice(0, -1))).toBe(38);
});

it("keeps the total when a field is retyped", () => {
const next = epcSetField(96, SGTIN, 3, 26);
expect(next).toEqual([8, 3, 3, 26, 24, 32]);
expect(next.reduce((a, b) => a + b, 0)).toBe(96);
});

it("clamps a retype to what leaves the trailing field valid", () => {
// The other typed fields claim 34 bits, so this one may grow to 61 before
// the trailing field would fall under its 1-bit minimum.
expect(epcSetField(96, SGTIN, 4, 99)).toEqual([8, 3, 3, 20, 61, 1]);
expect(epcFieldRange(96, SGTIN, 4)).toEqual({ min: 1, max: 61 });
});

it("keeps the total when a field is added or removed", () => {
const added = epcAddField(96, SGTIN);
expect(added).toEqual([8, 3, 3, 20, 24, 19, 19]);
expect(added?.reduce((a, b) => a + b, 0)).toBe(96);
const removed = epcRemoveField(96, SGTIN, 0);
expect(removed).toEqual([3, 3, 20, 24, 46]);
expect(removed?.reduce((a, b) => a + b, 0)).toBe(96);
});

it("collapses to a lone field, which the caller drops", () => {
expect(epcRemoveField(96, [48, 48], 0)).toEqual([48]);
});

it("spreads the freed bits so no field passes 64", () => {
expect(epcRemoveField(96, [48, 12, 36], 0)).toEqual([32, 64]);
expect(epcRemoveField(96, [48, 12, 36], 0)?.every((b) => b <= 64)).toBe(true);
});

it("refuses a removal the remaining fields cannot absorb", () => {
expect(epcRemoveField(192, [64, 64, 64], 0)).toBeNull();
});

it("bounds the total by what the typed fields already claim", () => {
expect(epcTotalRange(SGTIN, 65535)).toEqual({ min: 59, max: 122 });
expect(epcTotalRange(undefined, 65535)).toEqual({ min: 1, max: 65535 });
});

it("grows the tag when the trailing field is retyped", () => {
expect(epcSetTrailing(SGTIN, 46)).toEqual({
partitions: [8, 3, 3, 20, 24, 46],
total: 104,
});
expect(epcSetTrailing(SGTIN, 99).partitions.at(-1)).toBe(64);
});

it("moves a total change into the trailing field", () => {
expect(epcSetTotal(SGTIN, 104)).toEqual([8, 3, 3, 20, 24, 46]);
});
});
119 changes: 119 additions & 0 deletions packages/core/src/lib/rfidEpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { RFID_EPC_MAX_PARTITIONS, RFID_EPC_PARTITION_RANGE } from "../types/LabelConfig";

const { min: FIELD_MIN, max: FIELD_MAX } = RFID_EPC_PARTITION_RANGE;

/** The one EPC layout the ZPL guide documents (p.425): header, filter,
* partition, company prefix, item reference, serial number. */
export const SGTIN_96_FIELDS = [8, 3, 3, 20, 24, 38] as const;

/** Seed a partition list: a structure needs at least two fields, and wide
* totals need more because a field holds 64 bits at most. Null when the
* total does not fit 16 fields, or is too narrow for two. */
export function splitEpcBits(total: number | undefined): number[] | null {
if (total === undefined) return [8, 8];
// A 1-bit tag cannot hold two fields; growing it silently would be worse.
if (total < 2) return null;
const parts = Math.max(2, Math.ceil(total / FIELD_MAX));
if (parts > RFID_EPC_MAX_PARTITIONS) return null;
const base = Math.floor(total / parts);
const remainder = total - base * parts;
return Array.from({ length: parts }, (_, i) => base + (i < remainder ? 1 : 0));
}

/** The tag width is the given and the fields divide it (spec p.424: the
* partitions add up to n), so the last field is derived, never typed.
* Every helper below preserves that. */
export function epcTrailingField(total: number, leading: readonly number[]): number {
return total - leading.reduce((a, b) => a + b, 0);
}

/** Bounds for a typed field: what keeps the derived trailing field within
* 1..64 while the total stays put. */
export function epcFieldRange(
total: number,
parts: readonly number[],
index: number,
): { min: number; max: number } {
const others = parts
.slice(0, -1)
.reduce((sum, bits, i) => (i === index ? sum : sum + bits), 0);
return {
min: Math.max(FIELD_MIN, total - others - FIELD_MAX),
max: Math.min(FIELD_MAX, total - others - FIELD_MIN),
};
}

/** Bounds for the total: the typed fields claim their bits, and the trailing
* field has to fit in what remains. Unpartitioned totals are free. */
export function epcTotalRange(
parts: readonly number[] | undefined,
maxBits: number,
): { min: number; max: number } {
if (!parts) return { min: FIELD_MIN, max: maxBits };
const leading = parts.slice(0, -1).reduce((a, b) => a + b, 0);
return { min: leading + FIELD_MIN, max: Math.min(maxBits, leading + FIELD_MAX) };
}

/** Add a field by halving the trailing one, so the tag width holds. Null when
* the structure is full or the trailing field cannot be split. */
export function epcAddField(total: number, parts: readonly number[]): number[] | null {
if (parts.length >= RFID_EPC_MAX_PARTITIONS) return null;
const trailing = epcTrailingField(total, parts.slice(0, -1));
const taken = Math.floor(trailing / 2);
if (taken < FIELD_MIN || trailing - taken < FIELD_MIN) return null;
return [...parts.slice(0, -1), taken, trailing - taken];
}

/** Remove a field; its bits flow back from the end, total unchanged. Null
* when they fit nowhere without breaking the 64-bit cap; a lone survivor is
* no structure, so the caller drops it. */
export function epcRemoveField(
total: number,
parts: readonly number[],
index: number,
): number[] | null {
const kept = parts.filter((_, i) => i !== index);
if (kept.length <= 1) return kept;
const out = [...kept];
let freed = total - out.reduce((a, b) => a + b, 0);
for (let i = out.length - 1; i >= 0 && freed > 0; i--) {
const room = FIELD_MAX - (out[i] ?? 0);
const take = Math.min(room, freed);
out[i] = (out[i] ?? 0) + take;
freed -= take;
}
return freed === 0 ? out : null;
}

/** Retype one field, then let the trailing field absorb the difference. */
export function epcSetField(
total: number,
parts: readonly number[],
index: number,
bits: number,
): number[] {
const range = epcFieldRange(total, parts, index);
const clamped = Math.min(range.max, Math.max(range.min, bits));
const leading = parts.slice(0, -1).map((b, i) => (i === index ? clamped : b));
return [...leading, epcTrailingField(total, leading)];
}

/** Retype the trailing field: the leading ones are already claimed, so this
* sets the tag width rather than redistributing within it. */
export function epcSetTrailing(
parts: readonly number[],
bits: number,
): { partitions: number[]; total: number } {
const clamped = Math.min(FIELD_MAX, Math.max(FIELD_MIN, bits));
const leading = parts.slice(0, -1);
return {
partitions: [...leading, clamped],
total: leading.reduce((a, b) => a + b, 0) + clamped,
};
}

/** Retype the tag width; the trailing field takes the difference. */
export function epcSetTotal(parts: readonly number[], total: number): number[] {
const leading = parts.slice(0, -1);
return [...leading, epcTrailingField(total, leading)];
}
87 changes: 87 additions & 0 deletions packages/core/src/lib/rfidPosition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import {
rfidAmountRange,
rfidPositionConvert,
rfidPositionFromParts,
rfidPositionOf,
rfidPositionParts,
rfidPositionValue,
} from "./rfidPosition";

describe("rfidPositionValue", () => {
it("reads the three wire forms as mm off the leading edge", () => {
expect(rfidPositionValue("520", 8)).toEqual({ mode: "abs", mm: 65 });
expect(rfidPositionValue("F90", 8)).toEqual({ mode: "F", mm: 90 });
expect(rfidPositionValue("B14", 8)).toEqual({ mode: "B", mm: -14 });
});

it("rejects an unset or malformed position", () => {
expect(rfidPositionValue(undefined, 8)).toBeNull();
expect(rfidPositionValue("X1", 8)).toBeNull();
});
});

describe("rfidPositionOf", () => {
it("keeps the notation the design already uses", () => {
expect(rfidPositionOf(65, "abs", 8, 100)).toBe("520");
expect(rfidPositionOf(65, "F", 8, 100)).toBe("F65");
});

it("switches to backfeed past the leading edge, capped at B30", () => {
expect(rfidPositionOf(-14, "F", 8, 100)).toBe("B14");
expect(rfidPositionOf(-99, "abs", 8, 100)).toBe("B30");
});

it("clamps forward travel to the label length and the F999 domain", () => {
expect(rfidPositionOf(500, "abs", 8, 100)).toBe("800");
expect(rfidPositionOf(5000, "F", 8, 2000)).toBe("F999");
});
});

describe("rfidPositionParts", () => {
it("splits each notation into its own unit", () => {
expect(rfidPositionParts("520")).toEqual({ mode: "abs", amount: 520 });
expect(rfidPositionParts("F90")).toEqual({ mode: "F", amount: 90 });
expect(rfidPositionParts("B14")).toEqual({ mode: "B", amount: 14 });
expect(rfidPositionParts("nonsense")).toBeNull();
});

it("round-trips through rfidPositionFromParts", () => {
for (const wire of ["520", "F90", "B14"]) {
const p = rfidPositionParts(wire)!;
expect(rfidPositionFromParts(p.mode, p.amount)).toBe(wire);
}
});

it("bounds the amount per notation", () => {
expect(rfidAmountRange("abs", 240)).toEqual({ min: 0, max: 240 });
expect(rfidAmountRange("F", 240)).toEqual({ min: 0, max: 999 });
expect(rfidAmountRange("B", 240)).toEqual({ min: 0, max: 30 });
});
});

describe("rfidPositionConvert", () => {
it("keeps the distance when the unit changes", () => {
expect(rfidPositionConvert("abs", "F", 520, 8)).toBe(65);
expect(rfidPositionConvert("F", "abs", 65, 8)).toBe(520);
});

it("leaves same-notation switches alone", () => {
expect(rfidPositionConvert("abs", "abs", 520, 8)).toBe(520);
});

it("drops to the leading edge when the direction flips", () => {
// B points before the edge; no forward notation can say that, so keeping
// the magnitude would move the position by twice the distance.
expect(rfidPositionConvert("B", "F", 14, 8)).toBe(0);
expect(rfidPositionConvert("B", "abs", 14, 8)).toBe(0);
expect(rfidPositionConvert("abs", "B", 520, 8)).toBe(0);
});
});

describe("absolute positions stay inside the wire domain", () => {
it("caps a tall label at five digits", () => {
expect(rfidPositionOf(5000, "abs", 24, 5000)).toBe("99999");
expect(rfidAmountRange("abs", 120000)).toEqual({ min: 0, max: 99999 });
});
});
Loading