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
18 changes: 18 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,24 @@ npx thermoworks doneness [meat] [--json]

- `--json` - Output machine-readable JSON.

### `thermoworks placement [meat] [--json]`

Show meat and pit probe placement guidance

**Usage**

```bash
npx thermoworks placement [meat] [--json]
```

**Arguments**

- `meat` - Meat name or alias.

**Options**

- `--json` - Output machine-readable JSON.

### `thermoworks safe <SERIAL> [--channel N] [--temp T] [--protein P] [--held N] [--json]`

Show food-safety pasteurization progress
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { mcpStart } from "./commands/mcp.js";
import { metrics } from "./commands/metrics.js";
import { notifications } from "./commands/notifications.js";
import { open } from "./commands/open.js";
import { placement } from "./commands/placement.js";
import { plan } from "./commands/plan.js";
import { replay } from "./commands/replay.js";
import { safe } from "./commands/safe.js";
Expand Down Expand Up @@ -718,6 +719,15 @@ export const commandDefinitions: readonly CommandDefinition[] = [
supportsJson: true,
handler: ({ args, options }: CommandContext) => doneness(args[1], options),
},
{
name: "placement",
summary: "Show meat and pit probe placement guidance",
usage: "placement [meat] [--json]",
usageLines: ["placement [meat] Show meat and pit probe placement guidance"],
arguments: [{ name: "meat", description: "Meat name or alias." }],
supportsJson: true,
handler: ({ args, options }: CommandContext) => placement(args.slice(1), options),
},
{
name: "safe",
summary: "Show food-safety pasteurization progress",
Expand Down
64 changes: 64 additions & 0 deletions packages/cli/src/commands/placement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { getProbePlacements, type ProbePlacement, resolveProbePlacement } from "thermoworks-sdk";

import { type OutputOptions, outputJson } from "../output.js";

/** Format the built-in probe placements as a compact table. */
export function formatPlacementTable(placements: ProbePlacement[]): string {
const header = { meat: "Meat", probe: "Meat probe" };
const rows = placements.map((placement) => ({
meat: placement.meat,
probe: placement.meatProbe,
}));
const width = Math.max(header.meat.length, ...rows.map((row) => row.meat.length));
const lines = ["Probe placement guide:\n", ` ${header.meat.padEnd(width)} ${header.probe}`];
for (const row of rows) lines.push(` ${row.meat.padEnd(width)} ${row.probe}`);
return `${lines.join("\n")}\n`;
}

/** Format one cut's probe placement details. */
export function formatPlacementDetail(placement: ProbePlacement): string {
const lines = [
placement.meat,
` Meat probe: ${placement.meatProbe}`,
` Pit probe: ${placement.pitProbe}`,
` Avoid: ${placement.avoid.join(", ")}`,
];
for (const note of placement.notes) lines.push(` Note: ${note}`);
return `${lines.join("\n")}\n`;
}

/**
* Show probe placement guidance for common cuts. Uses offline SDK reference data,
* so it needs no ThermoWorks Cloud login or network access.
*/
export async function placement(
args: string[],
options: OutputOptions = { json: false },
): Promise<void> {
const meat = args.join(" ").trim();

if (meat.length > 0) {
if (meat.startsWith("--")) {
console.error(`Unknown option: ${meat}`);
process.exit(1);
}
const result = resolveProbePlacement(meat);
if (!result) {
console.error(`Unknown meat: "${meat}". Run "thermoworks placement" to see built-in cuts.`);
process.exit(1);
}
if (options.json) {
outputJson(result);
return;
}
process.stdout.write(formatPlacementDetail(result));
return;
}

const placements = getProbePlacements();
if (options.json) {
outputJson(placements);
return;
}
process.stdout.write(formatPlacementTable(placements));
}
98 changes: 98 additions & 0 deletions packages/cli/tests/placement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
formatPlacementDetail,
formatPlacementTable,
placement,
} from "../src/commands/placement.js";

let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
});

afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});

const brisket = {
meat: "Brisket",
meatProbe: "Center of the flat",
pitProbe: "At grate level",
avoid: ["fat seam", "bone"],
notes: ["Use a second probe in the point."],
};

describe("formatPlacementTable", () => {
it("renders a placement row for each cut", () => {
const out = formatPlacementTable([brisket]);
expect(out).toContain("Probe placement guide");
expect(out).toContain("Brisket");
expect(out).toContain("Center of the flat");
});
});

describe("formatPlacementDetail", () => {
it("renders a labeled block for one cut", () => {
const out = formatPlacementDetail(brisket);
expect(out).toContain("Meat probe:");
expect(out).toContain("Pit probe:");
expect(out).toContain("Avoid:");
expect(out).toContain("Use a second probe");
});
});

describe("placement command", () => {
it("prints a table of every built-in cut", async () => {
await placement([]);
const written = stdoutWriteSpy.mock.calls[0]?.[0] as string;
expect(written).toContain("Probe placement guide");
expect(written).toContain("Brisket");
expect(written).toContain("Pork Butt");
});

it("prints details for a single resolved cut", async () => {
await placement(["brisket"]);
const written = stdoutWriteSpy.mock.calls[0]?.[0] as string;
expect(written).toContain("Brisket");
expect(written).toContain("Meat probe:");
});

it("resolves multi-word aliases", async () => {
await placement(["pulled", "pork"]);
const written = stdoutWriteSpy.mock.calls[0]?.[0] as string;
expect(written).toContain("Pork Butt");
});

it("exits with an error for an unknown meat", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("exit");
});
await expect(placement(["unobtainium"])).rejects.toThrow("exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown meat"));
exitSpy.mockRestore();
});

it("emits a JSON array for the full list", async () => {
await placement([], { json: true });
const written = logSpy.mock.calls.map((c) => c[0]).join("\n");
const parsed = JSON.parse(written);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed[0].meat).toBe("Brisket");
expect(parsed[0]).toHaveProperty("meatProbe");
});

it("emits a JSON object for a single cut", async () => {
await placement(["brisket"], { json: true });
const written = logSpy.mock.calls.map((c) => c[0]).join("\n");
const parsed = JSON.parse(written);
expect(parsed.meat).toBe("Brisket");
expect(parsed.meatProbe).toContain("flat");
});
});
1 change: 1 addition & 0 deletions packages/cli/tests/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const expectedCommands = [
"graph",
"guide",
"doneness",
"placement",
"safe",
"carryover",
"cooldown",
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export {
getPasteurizationTable,
requiredHoldMinutes,
} from "./pasteurization.js";
export type { ProbePlacement } from "./placement.js";
export { getProbePlacements, resolveProbePlacement } from "./placement.js";
export type {
CookPlan,
CookPlanItem,
Expand Down
105 changes: 105 additions & 0 deletions packages/sdk/src/placement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { getMeatProfiles, resolveMeatProfile } from "./plan.js";

/** Probe placement guidance for one built-in cut. */
export interface ProbePlacement {
/** Canonical meat profile name. */
readonly meat: string;
/** Where to place the food probe. */
readonly meatProbe: string;
/** Where to place the pit or ambient probe. */
readonly pitProbe: string;
/** Common placement mistakes to avoid. */
readonly avoid: readonly string[];
/** Extra cook-specific placement notes. */
readonly notes: readonly string[];
}

const PLACEMENTS: Record<string, Omit<ProbePlacement, "meat">> = {
Brisket: {
meatProbe: "Center of the flat, from the side, with the tip in the thickest lean section.",
pitProbe: "Clipped to the grate near the brisket, shielded from direct radiant heat.",
avoid: ["Fat seams", "the point", "touching the grate", "the firebox side of an offset smoker"],
notes: ["Use a second probe in the point if you want to track both muscles."],
},
"Pork Butt": {
meatProbe: "Deep in the money muscle or thickest center, angled away from the blade bone.",
pitProbe: "At grate level beside the butt, not above the dome thermometer.",
avoid: ["Blade bone", "large fat pockets", "the outer bark"],
notes: ["Probe tenderness in several spots near the end because shoulders finish unevenly."],
},
"Pork Ribs": {
meatProbe: "Between bones in the thickest center rack section if using a probe.",
pitProbe: "At grate level beside the rack, with the clip clear of sauce or foil.",
avoid: ["Touching bone", "thin end bones", "pinching the cable under a lid"],
notes: ["Ribs are still best judged by bend, pullback, and probe tenderness."],
},
"Baby Back Ribs": {
meatProbe: "Between bones in the thickest center of the rack, parallel to the bones.",
pitProbe: "At grate level beside the rack and away from direct flame.",
avoid: ["Bone contact", "the narrow tail end", "placing the pit probe above a water pan edge"],
notes: ["Use temperature as a trend. Tenderness matters more than a single number."],
},
"Whole Chicken": {
meatProbe: "Deep in the breast from the side, tip centered and not touching the cavity.",
pitProbe: "At grate level beside the bird, away from dripping flare-ups.",
avoid: ["Bone", "the cavity", "skin-only shallow placement"],
notes: ["Spot-check the thigh separately. It should finish hotter than the breast."],
},
"Chicken Wings": {
meatProbe: "Use the largest drumette or flat if monitoring, with the tip centered in meat.",
pitProbe: "At grate level near the wing pile or basket.",
avoid: ["Bone", "skin folds", "tiny pieces that over-read quickly"],
notes: ["Wings are small, so spot checks are often more reliable than a leave-in probe."],
},
Turkey: {
meatProbe: "Deep in the breast from the side, tip centered and clear of the rib cage.",
pitProbe: "At grate level beside the turkey, not high in the dome.",
avoid: ["Rib bones", "the cavity", "stuffing", "touching a roasting rack"],
notes: ["Track the breast and spot-check the thigh so dark meat has time to finish."],
},
"Tri-Tip": {
meatProbe: "Into the thickest center from the side, following the grain line.",
pitProbe: "At grate level beside the roast, away from the sear zone.",
avoid: ["The tapered tip", "surface fat", "direct radiant heat during a reverse sear"],
notes: ["Move or remove the probe before a hard sear if cable heat is a risk."],
},
Salmon: {
meatProbe: "Into the thickest fillet section from the side, shallow enough to stay centered.",
pitProbe: "At grate level beside the fillet or plank.",
avoid: ["Thin tail sections", "touching the plank", "pushing through the fillet"],
notes: ["Salmon changes quickly near the end. Pair the probe with visual flake checks."],
},
"Beef Short Ribs": {
meatProbe: "Centered in the thickest meat between bones, inserted from the side.",
pitProbe: "At grate level beside the ribs, clear of the bone side and water pan edge.",
avoid: ["Bone", "fat pockets", "the membrane side"],
notes: ["Probe each rib for tenderness because thickness varies across the plate."],
},
};

function copyPlacement(meat: string, placement: Omit<ProbePlacement, "meat">): ProbePlacement {
return {
meat,
meatProbe: placement.meatProbe,
pitProbe: placement.pitProbe,
avoid: [...placement.avoid],
notes: [...placement.notes],
};
}

/** Return probe placement guidance for every built-in meat profile. */
export function getProbePlacements(): ProbePlacement[] {
return getMeatProfiles().map((profile) => {
const placement = PLACEMENTS[profile.name];
if (!placement) throw new Error(`Missing probe placement for ${profile.name}`);
return copyPlacement(profile.name, placement);
});
}

/** Resolve a meat name or alias to probe placement guidance. */
export function resolveProbePlacement(meat: string): ProbePlacement | null {
const profile = resolveMeatProfile(meat);
if (!profile) return null;
const placement = PLACEMENTS[profile.name];
return placement ? copyPlacement(profile.name, placement) : null;
}
28 changes: 28 additions & 0 deletions packages/sdk/tests/placement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { getProbePlacements, resolveProbePlacement } from "../src/placement.js";
import { getMeatProfiles } from "../src/plan.js";

describe("getProbePlacements", () => {
it("covers every built-in meat profile", () => {
const placements = getProbePlacements();
expect(placements.map((p) => p.meat)).toEqual(getMeatProfiles().map((p) => p.name));
expect(placements.find((p) => p.meat === "Brisket")?.meatProbe).toContain("flat");
});

it("returns copies so callers cannot mutate the source", () => {
const first = getProbePlacements();
(first[0]!.avoid as string[]).push("hacked");
expect(getProbePlacements()[0]?.avoid).not.toContain("hacked");
});
});

describe("resolveProbePlacement", () => {
it("resolves canonical names and aliases", () => {
expect(resolveProbePlacement("BRISKET")?.meat).toBe("Brisket");
expect(resolveProbePlacement("pulled pork")?.meat).toBe("Pork Butt");
});

it("returns null for unknown meats", () => {
expect(resolveProbePlacement("unobtainium")).toBeNull();
});
});