diff --git a/cloud-plugins/package-lock.json b/cloud-plugins/package-lock.json index dcb41bd..24fddea 100644 --- a/cloud-plugins/package-lock.json +++ b/cloud-plugins/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "tagtinker-cloud-plugins", "version": "1.0.0", + "dependencies": { + "jpeg-js": "^0.4.4", + "upng-js": "^2.1.0" + }, "devDependencies": { "@cloudflare/workers-types": "^4.20240909.0", "typescript": "^5.5.4", @@ -1253,6 +1257,12 @@ "license": "MIT", "optional": true }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -1319,6 +1329,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -1535,6 +1551,15 @@ "ufo": "^1.5.4" } }, + "node_modules/upng-js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/upng-js/-/upng-js-2.1.0.tgz", + "integrity": "sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.5" + } + }, "node_modules/workerd": { "version": "1.20250718.0", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", diff --git a/cloud-plugins/package.json b/cloud-plugins/package.json index 605e701..852bd4c 100644 --- a/cloud-plugins/package.json +++ b/cloud-plugins/package.json @@ -12,5 +12,9 @@ "@cloudflare/workers-types": "^4.20240909.0", "typescript": "^5.5.4", "wrangler": "^3.78.0" + }, + "dependencies": { + "jpeg-js": "^0.4.4", + "upng-js": "^2.1.0" } } diff --git a/cloud-plugins/src/canvas.ts b/cloud-plugins/src/canvas.ts index a8b3309..1e13d04 100644 --- a/cloud-plugins/src/canvas.ts +++ b/cloud-plugins/src/canvas.ts @@ -10,7 +10,7 @@ * Plane 1 only allocated when accent is requested AND supported. */ -import { FONT_5x7, FONT_5x7_W, FONT_5x7_H } from "./font"; +import { FONT_5x7, FONT_5x7_W, FONT_5x7_H, FONT_EXTRA } from "./font"; export type Ink = 0 | 1; // 0 = primary (black), 1 = accent (red/yellow) @@ -38,9 +38,22 @@ export class Canvas { setPixel(x: number, y: number, ink: Ink = 0): void { if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; - const p = this.plane(ink); const i = y * this.rowStride + (x >> 3); - p[i] |= 0x80 >> (x & 7); + const mask = 0x80 >> (x & 7); + /* Last-write-wins on tri-state pixels: setting a pixel on one plane + * clears the same pixel on the OTHER plane. Without this, drawing + * black text on top of an accent-filled badge produces undefined + * results on the e-paper driver (typically the accent plane wins + * and the text vanishes). With this rule a pixel is always exactly + * one of {white, black, accent}, which is what plugin authors + * intuitively expect when stacking primitives. */ + if (ink === 1 && this.plane1) { + this.plane1[i] |= mask; + this.plane0[i] &= ~mask; + } else { + this.plane0[i] |= mask; + if (this.plane1) this.plane1[i] &= ~mask; + } } clearPixel(x: number, y: number, ink: Ink = 0): void { @@ -50,6 +63,18 @@ export class Canvas { p[i] &= ~(0x80 >> (x & 7)); } + /** Force a pixel to "white" (paper) by clearing BOTH planes. Useful + * for knockout text inside a coloured badge: white text on red + * background = no ink at all on those pixels, just the paper + * showing through. */ + whitePixel(x: number, y: number): void { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; + const i = y * this.rowStride + (x >> 3); + const mask = ~(0x80 >> (x & 7)); + this.plane0[i] &= mask; + if (this.plane1) this.plane1[i] &= mask; + } + hline(x: number, y: number, w: number, ink: Ink = 0): void { for (let i = 0; i < w; i++) this.setPixel(x + i, y, ink); } @@ -94,8 +119,16 @@ export class Canvas { drawText(x: number, y: number, s: string, ink: Ink = 0, scale = 1): void { for (let ci = 0; ci < s.length; ci++) { const ch = s.charCodeAt(ci); - const idx = ch >= 32 && ch <= 127 ? ch - 32 : 0; - const glyph = FONT_5x7[idx] ?? FONT_5x7[0]; + let glyph: number[]; + if (ch >= 32 && ch <= 127) { + glyph = FONT_5x7[ch - 32] ?? FONT_5x7[0]; + } else { + /* Non-ASCII codepoint: try the extra glyph table (€, £, ¥, ▲, ...). + * Plugins can ship Unicode strings directly and this lookup + * keeps the per-glyph cell width identical, so textSize() math + * still works unchanged. */ + glyph = FONT_EXTRA[ch] ?? FONT_5x7[0]; + } for (let row = 0; row < FONT_5x7_H; row++) { const bits = glyph[row]; for (let col = 0; col < FONT_5x7_W; col++) { @@ -115,6 +148,33 @@ export class Canvas { } } + /** Knockout-text version of drawText: each glyph pixel is forced to + * white (clears BOTH planes). Use this when drawing a label inside + * a solid coloured badge so the paper shows through the letters. */ + drawTextWhite(x: number, y: number, s: string, scale = 1): void { + for (let ci = 0; ci < s.length; ci++) { + const ch = s.charCodeAt(ci); + let glyph: number[]; + if (ch >= 32 && ch <= 127) glyph = FONT_5x7[ch - 32] ?? FONT_5x7[0]; + else glyph = FONT_EXTRA[ch] ?? FONT_5x7[0]; + for (let row = 0; row < FONT_5x7_H; row++) { + const bits = glyph[row]; + for (let col = 0; col < FONT_5x7_W; col++) { + if (bits & (1 << (FONT_5x7_W - 1 - col))) { + for (let dy = 0; dy < scale; dy++) { + for (let dx = 0; dx < scale; dx++) { + this.whitePixel( + x + (ci * (FONT_5x7_W + 1) + col) * scale + dx, + y + row * scale + dy, + ); + } + } + } + } + } + } + } + drawTextCentered(cx: number, y: number, s: string, ink: Ink = 0, scale = 1): void { const { w } = this.textSize(s, scale); this.drawText(cx - (w >> 1), y, s, ink, scale); diff --git a/cloud-plugins/src/font.ts b/cloud-plugins/src/font.ts index e194915..2b1d45c 100644 --- a/cloud-plugins/src/font.ts +++ b/cloud-plugins/src/font.ts @@ -113,3 +113,30 @@ export const FONT_5x7: number[][] = [ [0x09,0x15,0x12,0x00,0x00,0x00,0x00], // '~' [0,0,0,0,0,0,0], // 0x7f (fallback) ]; + +/* Extra non-ASCII glyphs the canvas will render via direct codepoint + * lookup. Each entry is a 7-row 5-wide bitmap in the same packing as + * FONT_5x7 (low 5 bits = pixels left-to-right). Keyed by Unicode code + * point so plugins can write `"€69,420"` literally and have it Just Work. */ +export const FONT_EXTRA: Record = { + 0x00A3: [0x07,0x08,0x08,0x1E,0x08,0x08,0x1F], // £ pound + 0x00A5: [0x11,0x0A,0x04,0x1F,0x04,0x1F,0x04], // ¥ yen + 0x00A2: [0x04,0x0E,0x14,0x14,0x14,0x0E,0x04], // ¢ cent + 0x20AC: [0x06,0x09,0x1E,0x08,0x1E,0x09,0x06], // € euro + 0x00B0: [0x06,0x09,0x06,0x00,0x00,0x00,0x00], // ° degree + 0x2191: [0x04,0x0E,0x15,0x04,0x04,0x04,0x04], // ↑ up arrow + 0x2193: [0x04,0x04,0x04,0x04,0x15,0x0E,0x04], // ↓ down arrow + 0x25B2: [0x04,0x04,0x0E,0x0E,0x1F,0x1F,0x00], // ▲ filled up + 0x25BC: [0x00,0x1F,0x1F,0x0E,0x0E,0x04,0x04], // ▼ filled down + 0x2022: [0x00,0x00,0x0E,0x1F,0x0E,0x00,0x00], // • bullet + 0x25C9: [0x0E,0x11,0x15,0x1B,0x15,0x11,0x0E], // ◉ fisheye + 0x25EF: [0x0E,0x11,0x11,0x11,0x11,0x11,0x0E], // ◯ large circle + 0x25C6: [0x04,0x0E,0x1F,0x1F,0x1F,0x0E,0x04], // ◆ diamond + 0x25A0: [0x00,0x1F,0x1F,0x1F,0x1F,0x1F,0x00], // ■ filled square + 0x25A1: [0x00,0x1F,0x11,0x11,0x11,0x1F,0x00], // □ empty square + 0x2588: [0x1F,0x1F,0x1F,0x1F,0x1F,0x1F,0x1F], // █ full block + 0x2592: [0x15,0x0A,0x15,0x0A,0x15,0x0A,0x15], // ▒ medium shade + 0x253C: [0x04,0x04,0x04,0x1F,0x04,0x04,0x04], // ┼ crosshair + 0x2605: [0x04,0x04,0x1F,0x0E,0x0E,0x11,0x00], // ★ black star + 0x2606: [0x04,0x0A,0x11,0x0A,0x0A,0x11,0x00], // ☆ white star (rough) +}; diff --git a/cloud-plugins/src/image_util.ts b/cloud-plugins/src/image_util.ts new file mode 100644 index 0000000..2a4748f --- /dev/null +++ b/cloud-plugins/src/image_util.ts @@ -0,0 +1,113 @@ +/* + * Shared image-fetch / decode / dither helpers used by every plugin + * that wants to render a downloaded raster onto the e-paper canvas. + * + * - fetchImageGray(url): downloads, sniffs PNG vs JPEG, decodes, then + * composites alpha over white and converts to perceptual luma. The + * result is always a tightly packed Uint8Array of grayscale 0..255. + * - blitGrayDither(canvas, ...): nearest-neighbour scales a grayscale + * buffer into a destination rect on a Canvas and serpentine + * Floyd-Steinberg dithers it to monochrome ink in the process. + * + * Keeping these in one file means the worker bundle ships UPNG / jpeg-js + * once, not once per plugin, and any pixel-level improvements (e.g. + * a different dither) propagate everywhere automatically. + */ + +import * as UPNG from "upng-js"; +// jpeg-js is CommonJS without proper types, so import as any. +// eslint-disable-next-line @typescript-eslint/no-var-requires +import jpegJs from "jpeg-js"; +import { Canvas, Ink } from "./canvas"; + +export interface GrayImage { gray: Uint8Array; w: number; h: number; } + +/** Fetch a remote image and reduce it to a grayscale luma buffer. PNG + * and JPEG are both supported - we sniff the magic bytes rather than + * trusting the URL extension or the Content-Type header (which CDNs + * frequently get wrong). */ +export async function fetchImageGray(url: string): Promise { + let raw: ArrayBuffer; + try { + const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } }); + if (!r.ok) return null; + raw = await r.arrayBuffer(); + } catch { + return null; + } + return decodeImageGray(raw); +} + +/** Decode a buffer into grayscale, sniffing PNG vs JPEG on first bytes. */ +export function decodeImageGray(buf: ArrayBuffer): GrayImage | null { + const u8 = new Uint8Array(buf); + if (u8.length < 4) return null; + let rgba: Uint8Array; + let w: number, h: number; + /* PNG magic: 89 50 4E 47 */ + if (u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4E && u8[3] === 0x47) { + let dec; + try { dec = UPNG.decode(buf); } catch { return null; } + const arr = UPNG.toRGBA8(dec); + if (!arr.length) return null; + rgba = new Uint8Array(arr[0]); + w = dec.width; h = dec.height; + /* JPEG magic: FF D8 FF */ + } else if (u8[0] === 0xFF && u8[1] === 0xD8 && u8[2] === 0xFF) { + let dec; + try { dec = jpegJs.decode(u8, { useTArray: true }); } catch { return null; } + rgba = dec.data as Uint8Array; + w = dec.width; h = dec.height; + } else { + return null; + } + + const gray = new Uint8Array(w * h); + for (let i = 0, j = 0; i < gray.length; i++, j += 4) { + const a = rgba[j + 3] / 255; + const r = rgba[j] * a + 255 * (1 - a); + const g = rgba[j + 1] * a + 255 * (1 - a); + const b = rgba[j + 2] * a + 255 * (1 - a); + gray[i] = (0.2126 * r + 0.7152 * g + 0.0722 * b) | 0; + } + return { gray, w, h }; +} + +/** Serpentine Floyd-Steinberg dither into a destination rect. The + * source is nearest-neighbour scaled into a working float buffer so + * the dither runs at output resolution (where artefacts actually + * matter to the user). */ +export function blitGrayDither( + c: Canvas, dx: number, dy: number, dstW: number, dstH: number, + img: GrayImage, ink: Ink = 0, +): void { + const { gray, w: srcW, h: srcH } = img; + const buf = new Float32Array(dstW * dstH); + for (let y = 0; y < dstH; y++) { + const sy = Math.min(srcH - 1, Math.floor((y * srcH) / dstH)); + for (let x = 0; x < dstW; x++) { + const sx = Math.min(srcW - 1, Math.floor((x * srcW) / dstW)); + buf[y * dstW + x] = gray[sy * srcW + sx]; + } + } + for (let y = 0; y < dstH; y++) { + const rev = (y & 1) === 1; + const xStart = rev ? dstW - 1 : 0; + const xEnd = rev ? -1 : dstW; + const xStep = rev ? -1 : 1; + for (let x = xStart; x !== xEnd; x += xStep) { + const i = y * dstW + x; + const old = buf[i]; + const np = old < 128 ? 0 : 255; + if (np === 0) c.setPixel(dx + x, dy + y, ink); + const err = old - np; + const sx = rev ? -1 : 1; + if (x + sx >= 0 && x + sx < dstW) buf[i + sx] += err * 7 / 16; + if (y + 1 < dstH) { + if (x - sx >= 0 && x - sx < dstW) buf[i + dstW - sx] += err * 3 / 16; + buf[i + dstW] += err * 5 / 16; + if (x + sx >= 0 && x + sx < dstW) buf[i + dstW + sx] += err * 1 / 16; + } + } + } +} diff --git a/cloud-plugins/src/index.ts b/cloud-plugins/src/index.ts index aff9b5a..7fc822d 100644 --- a/cloud-plugins/src/index.ts +++ b/cloud-plugins/src/index.ts @@ -22,8 +22,11 @@ import { Plugin, AccentMode } from "./plugin"; import { cryptoPlugin } from "./plugins/crypto"; import { weatherPlugin } from "./plugins/weather"; import { identiconPlugin } from "./plugins/identicon"; +import { githubPlugin } from "./plugins/github"; -const PLUGINS: Plugin[] = [cryptoPlugin, weatherPlugin, identiconPlugin]; +const PLUGINS: Plugin[] = [ + cryptoPlugin, weatherPlugin, identiconPlugin, githubPlugin, +]; const CORS_HEADERS: Record = { "Access-Control-Allow-Origin": "*", diff --git a/cloud-plugins/src/plugins/crypto.ts b/cloud-plugins/src/plugins/crypto.ts index 3e390ac..82b864f 100644 --- a/cloud-plugins/src/plugins/crypto.ts +++ b/cloud-plugins/src/plugins/crypto.ts @@ -1,21 +1,10 @@ /* - * Crypto Price plugin. + * Crypto Price plugin - editorial / share-worthy layout. * - * Renders the requested coin's spot price headline, 24h delta + a - * sparkline filling the bottom band. Accent-capable tags get the arrow - * and sparkline drawn on the accent plane. - * - * ┌─────────────────────────────────────────┐ - * │ BTC / USD LIVE │ - * │ │ - * │ $ 67,415.20 │ - * │ ▲ +2.31% 24H │ - * │ ╱╲ │ - * │╱ ╲╱╲ ╱╲╱╲ │ - * │ ╲╱ ╲╱╲╱ │ - * └─────────────────────────────────────────┘ - * - * Data source: CoinGecko free tier (no key required). + * Mono tags get a clean black & white card; tri-colour tags get a + * dramatic accent treatment on the sparkline fill, the delta badge, + * the corner brackets and a thin underline accent on the price - the + * kind of thing that looks intentional in a photo of an e-paper tag. */ import { Canvas, Ink } from "../canvas"; @@ -26,15 +15,15 @@ const COIN_MAP: Record = { DOGE: "dogecoin", ADA: "cardano", BNB: "binancecoin", LINK: "chainlink", }; const RANGE_DAYS: Record = { "24H": 1, "7D": 7, "30D": 30 }; -const CCY_PREFIX: Record = { USD: "$", EUR: "EUR ", GBP: "GBP " }; +/* The Canvas now ships €/£/¥ glyphs in FONT_EXTRA so we can render them + * literally as the price prefix - same width budget as "$". */ +const CCY_PREFIX: Record = { USD: "$", EUR: "\u20AC", GBP: "\u00A3" }; function formatPrice(v: number, prefix: string): string { let body: string; - if (v >= 1000) body = v.toFixed(2); - else if (v >= 1) body = v.toFixed(2); + if (v >= 1) body = v.toFixed(2); else if (v >= 0.01) body = v.toFixed(4); else body = v.toFixed(6); - // Thousand separators on the integer part. const [intPart, frac] = body.split("."); const withCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ","); return prefix + withCommas + (frac ? "." + frac : ""); @@ -66,11 +55,59 @@ async function fetchHistory(id: string, vs: string, days: number, samples: numbe return out; } +/* ─── Drawing helpers ─────────────────────────────────────────────── */ + +const BAYER4 = [ + [ 0, 8, 2, 10], + [12, 4, 14, 6], + [ 3, 11, 1, 9], + [15, 7, 13, 5], +]; + +function fillStippled(c: Canvas, x: number, y: number, w: number, h: number, + density: number, ink: Ink) { + const t = Math.max(0, Math.min(15, Math.round(density * 16) - 1)); + for (let yy = 0; yy < h; yy++) { + for (let xx = 0; xx < w; xx++) { + if (BAYER4[yy & 3][xx & 3] <= t) c.setPixel(x + xx, y + yy, ink); + } + } +} + +function fillUnderCurve(c: Canvas, xs: number[], ys: number[], + baselineY: number, ink: Ink) { + for (let i = 0; i < xs.length - 1; i++) { + const x0 = xs[i], y0 = ys[i]; + const x1 = xs[i + 1], y1 = ys[i + 1]; + const dx = x1 - x0; + if (dx <= 0) continue; + for (let x = x0; x <= x1; x++) { + const t = (x - x0) / dx; + const y = Math.round(y0 + (y1 - y0) * t); + const top = Math.min(y, baselineY); + const bot = Math.max(y, baselineY); + for (let yy = top; yy <= bot; yy++) { + if (BAYER4[yy & 3][x & 3] < 8) c.setPixel(x, yy, ink); + } + } + } +} + +function cornerBrackets(c: Canvas, x: number, y: number, w: number, h: number, + len: number, ink: Ink) { + c.hline(x, y, len, ink); c.vline(x, y, len, ink); + c.hline(x + w - len, y, len, ink); c.vline(x + w - 1, y, len, ink); + c.hline(x, y + h - 1, len, ink); c.vline(x, y + h - len, len, ink); + c.hline(x + w - len, y + h - 1, len, ink); c.vline(x + w - 1, y + h - len, len, ink); +} + +/* ─── Plugin ─────────────────────────────────────────────────────── */ + export const cryptoPlugin: Plugin = { manifest: { id: "crypto", name: "Crypto Price", - description: "Live coin price + sparkline", + description: "Editorial price card with sparkline", accent_modes: 1 | 2 | 4, params: [ { key: "symbol", label: "Coin", type: "enum", default: "BTC", @@ -96,59 +133,122 @@ export const cryptoPlugin: Plugin = { const planes: 1 | 2 = accent === "none" ? 1 : 2; const c = new Canvas(W, H, planes); - const accentInk: Ink = planes === 2 ? 1 : 0; + /* On mono tags accent ink == primary - visually a bit boring but the + * structural design (corner brackets, dithered fills, big type) still + * carries the look. On tri-colour tags the accent plane gives the + * splash of red/yellow that makes the card pop. */ + const acc: Ink = planes === 2 ? 1 : 0; + const blk: Ink = 0; const margin = W < 200 ? 4 : 8; - // Header - c.drawText(margin, margin, `${sym} / ${vs}`, 0, 1); + /* ── Corner viewfinder brackets in accent ─────────────────────── */ + const bracketLen = Math.max(8, Math.min(16, Math.floor(W / 24))); + cornerBrackets(c, 1, 1, W - 2, H - 2, bracketLen, acc); - // LIVE badge top-right (accent rectangle). + /* ── Header row: symbol/ccy on the left, delta badge right ────── */ + const headerY = margin + 2; + const symLabel = `${sym}/${vs}`; + /* Header: "BTC/USD" in scale 2 - the focal element of the top bar. */ + c.drawText(margin + 4, headerY, symLabel, blk, 2); + const symH = c.textSize(symLabel, 2).h; + + /* Range chip on header row right of the symbol. */ { - const badge = "LIVE"; - const { w: bw, h: bh } = c.textSize(badge, 1); - const bx = W - margin - bw - 4; - const by = margin - 1; - c.rect(bx - 2, by - 1, bw + 4, bh + 2, accentInk); - c.drawText(bx, by, badge, accentInk, 1); + const chip = range; + const cs = c.textSize(chip, 1); + const cx = margin + 4 + c.textSize(symLabel, 2).w + 6; + const cy = headerY + Math.max(0, (symH - cs.h) >> 1); + /* Box outline in primary, the chip text reads black on white. */ + c.rect(cx - 2, cy - 1, cs.w + 4, cs.h + 2, blk); + c.drawText(cx, cy, chip, blk, 1); } - // Price headline. Pick scale so it just fits. + /* Delta badge top-right: solid accent block with a chunky black + * triangle and the % delta in black, all rendered on plane 0 over + * the accent plane fill. Reads cleanly as "black ink on red/yellow + * paint" on tri-colour tags - and as plain black on mono tags. */ + { + const arrow = change >= 0 ? "\u25B2" : "\u25BC"; // ▲ / ▼ glyph + const pct = `${change >= 0 ? "+" : "-"}${Math.abs(change).toFixed(2)}%`; + const arrowSize = c.textSize(arrow, 1); + const pctSize = c.textSize(pct, 1); + const padX = 5; + const gap = 3; + const bw = padX + arrowSize.w + gap + pctSize.w + padX; + const bh = Math.max(arrowSize.h, pctSize.h) + 6; + const bx = W - margin - bw - 2; + const by = headerY + Math.max(0, (symH - bh) >> 1); + + /* Solid accent fill behind everything, then knock out the arrow + * and the % text in WHITE (paper showing through both planes). + * White-on-red has way higher contrast than black-on-red on the + * actual e-paper - the black ink and the red ink absorb similar + * amounts of light, so black text on red reads as a muddy + * monochrome blob. White (paper) on red is the print-magazine + * standard for a reason. */ + c.fillRect(bx, by, bw, bh, acc); + const ax = bx + padX; + const ay = by + ((bh - arrowSize.h) >> 1); + c.drawTextWhite(ax, ay, arrow, 1); + const px = ax + arrowSize.w + gap; + const py = by + ((bh - pctSize.h) >> 1); + c.drawTextWhite(px, py, pct, 1); + } + + /* ── Big price ─────────────────────────────────────────────── */ const priceTxt = formatPrice(price, prefix); - let scale = W >= 280 ? 3 : W >= 180 ? 2 : 1; - while (scale > 1 && c.textSize(priceTxt, scale).w > W - 2 * margin) scale--; - const priceY = margin + 12 + Math.floor(H / 12); - c.drawTextCentered(W >> 1, priceY, priceTxt, 0, scale); - const priceH = c.textSize(priceTxt, scale).h; + /* Pick the largest scale that fits. */ + let pscale = W >= 280 ? 4 : W >= 200 ? 3 : 2; + while (pscale > 1 && c.textSize(priceTxt, pscale).w > W - 2 * margin - 6) pscale--; + const pSize = c.textSize(priceTxt, pscale); + const priceY = headerY + symH + Math.max(6, Math.floor(H / 14)); + const priceX = (W - pSize.w) >> 1; + c.drawText(priceX, priceY, priceTxt, blk, pscale); - // Delta line: "▲ +X.XX% 24H" - const delta = `${change >= 0 ? "+" : "-"}${Math.abs(change).toFixed(2)}% ${range}`; - const dy = priceY + priceH + Math.floor(H / 16); - c.drawTextCentered(W >> 1, dy, delta, 0, 1); - { - const triW = 5; - const dwt = c.textSize(delta, 1).w; - const dx = (W >> 1) - (dwt >> 1) - triW - 2; - const dyy = dy + 1; - if (change >= 0) { - c.line(dx, dyy + 5, dx + triW, dyy + 5, accentInk); - c.line(dx, dyy + 5, dx + (triW >> 1), dyy, accentInk); - c.line(dx + triW, dyy + 5, dx + (triW >> 1), dyy, accentInk); - } else { - c.line(dx, dyy, dx + triW, dyy, accentInk); - c.line(dx, dyy, dx + (triW >> 1), dyy + 5, accentInk); - c.line(dx + triW, dyy, dx + (triW >> 1), dyy + 5, accentInk); - } - } - - // Sparkline filling the bottom third. + /* ── Sparkline + accent fill ─────────────────────────────── */ if (hist.length >= 2) { - const sh = Math.floor(H / 3); - const sy = H - margin - sh; - const sx = margin; - const sw = W - 2 * margin; - c.hline(sx, sy + sh, sw, 0); // baseline tick on primary plane - c.sparkline(sx, sy, sw, sh, hist, accentInk, true); + /* No underline now - the price headline breathes and the sparkline + * gets the full lower half of the card. */ + const sparkTop = priceY + pSize.h + Math.max(6, Math.floor(H / 14)); + const sparkBot = H - margin - 4; + const sparkH = sparkBot - sparkTop; + const sparkX = margin + 4; + const sparkW = W - 2 * (margin + 4); + + if (sparkH > 8) { + let lo = Infinity, hi = -Infinity; + for (const v of hist) { if (v < lo) lo = v; if (v > hi) hi = v; } + if (hi === lo) hi = lo + 1; + const xs: number[] = []; + const ys: number[] = []; + for (let i = 0; i < hist.length; i++) { + const px = sparkX + Math.round((i * (sparkW - 1)) / (hist.length - 1)); + const py = sparkTop + sparkH - 1 - + Math.round(((hist[i] - lo) * (sparkH - 1)) / (hi - lo)); + xs.push(px); ys.push(py); + } + + /* Accent stippled fill below the curve - the visual hero. */ + fillUnderCurve(c, xs, ys, sparkBot - 1, acc); + + /* Curve itself in BLACK, drawn thick so it pops over the + * stippled fill - this is the "real" line the eye reads. */ + for (let i = 1; i < xs.length; i++) { + c.line(xs[i - 1], ys[i - 1], xs[i], ys[i], blk); + /* Add a 1-pixel "halo" above for extra weight. */ + if (ys[i] > sparkTop) + c.line(xs[i - 1], ys[i - 1] - 1, xs[i], ys[i] - 1, blk); + } + + /* Baseline rule. */ + c.hline(sparkX, sparkBot, sparkW, blk); + + /* Latest value pin: small accent dot + black ring. */ + const lx = xs[xs.length - 1], ly = ys[ys.length - 1]; + c.fillRect(lx - 2, ly - 2, 5, 5, acc); + c.rect(lx - 2, ly - 2, 5, 5, blk); + } } return c; diff --git a/cloud-plugins/src/plugins/github.ts b/cloud-plugins/src/plugins/github.ts new file mode 100644 index 0000000..eaab336 --- /dev/null +++ b/cloud-plugins/src/plugins/github.ts @@ -0,0 +1,363 @@ +/* + * GitHub Profile plugin. + * + * Layout (designed for 208x112, scales up to 296x128): + * + * ┌────────────────────────────────────────┐ + * │ ┌───────┐ Linus Torvalds │ <- display name (scale 1) + * │ │ │ @torvalds ★ 178K │ <- handle + stars (scale 1, accent star) + * │ │AVATAR │ │ + * │ │ │ Creator of Linux, │ <- bio (scale 1, word-wrapped, + * │ │ │ unrepentantly cranky, │ multiple lines, can extend + * │ │ │ mostly benevolent dictator │ below the avatar) + * │ └───────┘ │ + * │ │ + * │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │ <- contribution heatmap + * │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │ (red dots, hottest = black) + * │ ░▒░▒░░▒▒░▒░░░▒▒░░▒░░▒▒░░▒░░▒▒░░▒░░▒▒░ │ + * │ 1,234 contribs / yr │ <- caption + * └────────────────────────────────────────┘ + * + * Palette (kept light + airy on purpose): + * - Black: typography, avatar frame, hottest-day cells (level 4). + * - Accent: the star glyph and most heatmap cells (levels 1-3). + * - White: paper / bg. + * + * No solid coloured banner, no boxed badges - the card reads as a + * tiny print-magazine page rather than a UI screenshot. + */ + +import { Canvas, Ink } from "../canvas"; +import { Plugin, AccentMode } from "../plugin"; +import { fetchImageGray, blitGrayDither } from "../image_util"; + +interface GhUser { + login: string; + name: string | null; + bio: string | null; + avatar_url: string; + followers: number; + public_repos: number; +} + +interface GhRepoLite { stargazers_count: number; } + +interface ContribCell { date: string; count: number; level: 0|1|2|3|4; } +interface ContribResp { + total: Record; + contributions: ContribCell[]; +} + +async function fetchUser(login: string): Promise { + try { + const r = await fetch(`https://api.github.com/users/${encodeURIComponent(login)}`, { + headers: { "User-Agent": "TagTinker/1.0", "Accept": "application/vnd.github+json" }, + }); + if (!r.ok) return null; + return await r.json() as GhUser; + } catch { return null; } +} + +async function fetchTotalStars(login: string): Promise { + try { + const r = await fetch( + `https://api.github.com/users/${encodeURIComponent(login)}/repos` + + `?per_page=100&sort=pushed`, + { headers: { "User-Agent": "TagTinker/1.0", "Accept": "application/vnd.github+json" } }); + if (!r.ok) return null; + const repos: GhRepoLite[] = await r.json(); + let total = 0; + for (const r of repos) total += r.stargazers_count | 0; + return total; + } catch { return null; } +} + +async function fetchContributions(login: string): Promise { + try { + const r = await fetch( + `https://github-contributions-api.jogruber.de/v4/${encodeURIComponent(login)}?y=last`, + { headers: { "User-Agent": "TagTinker/1.0" } }); + if (!r.ok) return null; + return await r.json() as ContribResp; + } catch { return null; } +} + +function compact(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0) + "M"; + if (n >= 1_000) return (n / 1_000).toFixed(n < 10_000 ? 1 : 0) + "K"; + return String(n); +} + +function ascii(s: string): string { + /* Smart-typography → ASCII so apostrophes / em-dashes survive. */ + return s + .replace(/[\u2018\u2019\u02BC]/g, "'") + .replace(/[\u201C\u201D]/g, '"') + .replace(/[\u2013\u2014]/g, "-") + .replace(/\u2026/g, "...") + .replace(/[^\x20-\x7E]/g, ""); +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + if (max <= 3) return s.slice(0, max); + return s.slice(0, max - 3) + "..."; +} + +/** Greedy word-wrap into at most `maxLines` lines of `maxChars`. The + * last line gets an ellipsis if there's still content left over. */ +function wrapText(s: string, maxChars: number, maxLines: number): string[] { + if (maxChars <= 0 || maxLines <= 0) return []; + const words = s.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let cur = ""; + let i = 0; + for (; i < words.length; i++) { + const w = words[i]; + const cand = cur ? cur + " " + w : w; + if (cand.length <= maxChars) { + cur = cand; + } else { + if (cur) lines.push(cur); + cur = w.length <= maxChars ? w : w.slice(0, maxChars); + if (lines.length >= maxLines) { i++; break; } + } + } + if (cur && lines.length < maxLines) { lines.push(cur); cur = ""; } + /* Anything that didn't fit gets reflected as an ellipsis on the + * last line, so the user can tell text was truncated. */ + if (i < words.length || cur) { + if (lines.length === 0) return lines; + const last = lines[lines.length - 1]; + if (last.length <= maxChars - 3) lines[lines.length - 1] = last + "..."; + else lines[lines.length - 1] = last.slice(0, maxChars - 3) + "..."; + } + return lines; +} + +export const githubPlugin: Plugin = { + manifest: { + id: "github", + name: "GitHub Profile", + description: "Avatar, bio and contribution heatmap card", + accent_modes: 1 | 2 | 4, + params: [ + { key: "username", label: "Username", type: "string", default: "torvalds" }, + ], + }, + + async render(params, W, H, accent: AccentMode) { + const login = ((params.username ?? "torvalds").trim() || "torvalds"); + + const planes: 1 | 2 = accent === "none" ? 1 : 2; + const c = new Canvas(W, H, planes); + const acc: Ink = planes === 2 ? 1 : 0; + const blk: Ink = 0; + + const [user, stars, contrib] = await Promise.all([ + fetchUser(login), + fetchTotalStars(login), + fetchContributions(login), + ]); + + if (!user) { + const t1 = "USER NOT FOUND"; + const t2 = "@" + login; + const s1 = c.textSize(t1, 2), s2 = c.textSize(t2, 1); + c.drawText((W - s1.w) >> 1, (H >> 1) - s1.h, t1, blk, 2); + c.drawText((W - s2.w) >> 1, (H >> 1) + 2, t2, blk, 1); + c.rect(0, 0, W, H, blk); + return c; + } + + /* ── Geometry ───────────────────────────────────────────── + * Tuned for 208×112; scales up to 296×128 with a bigger + * avatar and roomier heatmap cells. */ + const isWide = W >= 256; + const margin = 4; + const charW = 6; // FONT_5x7_W + gap + const lineH = 9; // FONT_5x7_H + gap + + /* Avatar: dominate the upper-left. Bigger here = more typography + * room AND more pixels for the dithered face. */ + const avatarS = isWide ? 64 : 60; + const avX = margin; + const avY = margin; + + c.rect(avX, avY, avatarS, avatarS, blk); + const avatar = await fetchImageGray(`${user.avatar_url}&s=${avatarS * 2}`); + if (avatar) { + blitGrayDither(c, avX + 1, avY + 1, avatarS - 2, avatarS - 2, avatar, blk); + } else { + const ini = (user.login.charAt(0) || "?").toUpperCase(); + const sz = c.textSize(ini, 5); + c.drawText(avX + ((avatarS - sz.w) >> 1), + avY + ((avatarS - sz.h) >> 1), + ini, blk, 5); + } + + /* ── Right column: name / handle+stars / bio ─────────────── */ + const tX = avX + avatarS + 6; + const tW = W - tX - margin; + const maxChars = Math.max(1, Math.floor(tW / charW)); + + /* Heatmap budget computed up-front so we know where the bio is + * allowed to flow into. */ + const cols = 53, rows = 7; + const captionH = lineH + 2; + const heatCellTarget = isWide ? 4 : 3; + const heatBudget = rows * heatCellTarget; + const heatBlockH = heatBudget + captionH + 4; + const bioMaxBot = H - heatBlockH - 2; + + let ty = avY; + + /* Display name first, in primary ink. Falls back to login + * (capitalised) if the user has no display name set. */ + const displayName = user.name && user.name.trim() + ? truncate(ascii(user.name).trim(), maxChars) + : truncate(user.login, maxChars); + if (displayName) { + c.drawText(tX, ty, displayName, blk, 1); + ty += lineH; + } + + /* Handle + stars on a single line. The ★ glyph is drawn in + * accent for a deliberate splash of colour right next to the + * stars number; everything else stays primary. */ + { + const handle = "@" + ascii(user.login); + const sNum = `\u2605 ${compact(stars ?? 0)}`; + const hSize = c.textSize(handle, 1); + const sSize = c.textSize(sNum, 1); + const sep = " "; + const sepW = c.textSize(sep, 1).w; + /* If both pieces fit, draw them inline. Else drop the handle + * (the avatar already says who this is). */ + if (hSize.w + sepW + sSize.w <= tW) { + c.drawText(tX, ty, handle, blk, 1); + /* Star glyph in accent, number text in primary - splits the + * `★ 178K` string into two drawText calls so the colour break + * is exactly at the glyph. */ + const sx = tX + hSize.w + sepW; + c.drawText(sx, ty, "\u2605", acc, 1); + c.drawText(sx + c.textSize("\u2605 ", 1).w, ty, compact(stars ?? 0), blk, 1); + } else { + c.drawText(tX, ty, "\u2605", acc, 1); + c.drawText(tX + c.textSize("\u2605 ", 1).w, ty, compact(stars ?? 0), blk, 1); + } + ty += lineH + 2; + } + + /* Followers / repos line(s) - drawn at the BOTTOM of the right + * column, left-aligned to the same `tX` as name / handle / bio. + * Reserve their vertical room first so the bio knows when to + * stop wrapping. The single-line form is preferred; if it + * doesn't fit the column width, fall back to two stacked lines. */ + const statSingle = `${compact(user.followers)} followers \u00B7 ${compact(user.public_repos)} repos`; + const statLines: string[] = c.textSize(statSingle, 1).w <= tW + ? [statSingle] + : [`${compact(user.followers)} followers`, + `${compact(user.public_repos)} repos`]; + const statsBlockH = statLines.length * lineH; + /* Stats sit just above the heatmap caption / heatmap, butted up + * against the bottom of the right column. */ + const statsTop = bioMaxBot - statsBlockH; + const bioBudget = statsTop - 2 - ty; // px the bio is allowed + + /* Bio - word-wrapped into the budget left after the stats are + * accounted for. */ + if (user.bio && bioBudget >= lineH) { + const maxLines = Math.max(0, Math.floor(bioBudget / lineH)); + if (maxLines > 0) { + const lines = wrapText(ascii(user.bio).trim(), maxChars, maxLines); + for (const line of lines) { + c.drawText(tX, ty, line, blk, 1); + ty += lineH; + } + } + } + + /* Now drop the stats in left-aligned with everything else. */ + { + let sy = statsTop; + for (const line of statLines) { + c.drawText(tX, sy, line, blk, 1); + sy += lineH; + } + } + + /* ── Heatmap ─────────────────────────────────────────────── */ + const heatTop = H - heatBlockH; + const heatBot = H - captionH; + const heatH = heatBot - heatTop; + + let cell = Math.max(1, Math.min( + Math.floor((W - margin * 2 + 1) / cols), + Math.floor((heatH + 1) / rows), + )); + let gap = cell >= 3 ? 1 : 0; + while (cell > 1 && + (cell * cols + gap * (cols - 1) > W - margin * 2 || + cell * rows + gap * (rows - 1) > heatH)) { + if (gap > 0) gap--; + else cell--; + } + const gw = cell * cols + gap * (cols - 1); + const gh = cell * rows + gap * (rows - 1); + const gx = (W - gw) >> 1; + const gy = heatTop + ((heatH - gh) >> 1); + + if (contrib) { + const data = contrib.contributions; + const padN = Math.max(0, data.length - cols * rows); + for (let i = padN; i < data.length; i++) { + const off = i - padN; + const col = Math.floor(off / rows); + const row = off % rows; + const cx = gx + col * (cell + gap); + const cy = gy + row * (cell + gap); + const lvl = data[i].level; + if (lvl === 0) continue; + /* See doc-comment at top of file for the palette story. */ + if (lvl >= 4) { + c.fillRect(cx, cy, cell, cell, blk); + } else if (lvl >= 3 || cell <= 2) { + c.fillRect(cx, cy, cell, cell, acc); + } else if (lvl === 2) { + c.fillRect(cx + 1, cy + 1, cell - 2, cell - 2, acc); + } else { + if (cell >= 4) c.fillRect(cx + 1, cy + 1, cell - 2, cell - 2, acc); + else c.setPixel(cx + (cell >> 1), cy + (cell >> 1), acc); + } + } + } else { + const t = "GRAPH OFFLINE"; + const sz = c.textSize(t, 1); + c.drawText((W - sz.w) >> 1, gy + ((gh - sz.h) >> 1), t, blk, 1); + } + + /* ── Caption: tight, abbreviated so it always fits ───────── + * "1.2K contribs / yr" is 17 chars ≈ 102 px at scale 1, fits in + * 200 px easily. We use compact() on the total so a 50K-streak + * pro doesn't break the layout. */ + { + const total = contrib + ? (contrib.total["lastYear"] + ?? Object.values(contrib.total).reduce((a, b) => a + b, 0)) + : 0; + const cap = contrib + ? `${compact(total)} contribs / yr` + : `last 12 mo \u00B7 offline`; + const cs = c.textSize(cap, 1); + const cx = (W - cs.w) >> 1; + const cy = H - cs.h - 2; + c.drawText(cx, cy, cap, blk, 1); + } + + /* Outer 1-px frame. */ + c.rect(0, 0, W, H, blk); + + return c; + }, +}; diff --git a/cloud-plugins/src/plugins/identicon.ts b/cloud-plugins/src/plugins/identicon.ts index 5c537fd..6a3bbf3 100644 --- a/cloud-plugins/src/plugins/identicon.ts +++ b/cloud-plugins/src/plugins/identicon.ts @@ -1,26 +1,31 @@ /* - * Identicon plugin. + * Identicon plugin - DEF CON / hardware-hacker badge aesthetic. * - * Procedural avatar from a name string. Hash -> symmetric pixel art with - * an accent stripe so it actually looks like a *card*, not just a square. + * The card is laid out like an actual electronics badge: a "chip" on the + * left holding the symmetric pixel-art identicon (with silkscreen border + * and orientation notch), a typographic stack on the right with the + * callsign in big type / a hex UID / a role tag, PCB-style pin headers + * along the top edge, and a binary "barcode" footer that's deterministic + * from the seed so two people with the same name get the same pattern - + * exactly like a real con badge. Accent ink (red/yellow on tri-colour + * tags) carries the role tag, chip notch, pin row and a few signature + * highlights so the card pops in a photo. * - * ┌──────────────────────┐ - * │ PIETER │ - * │ ████ ██ ████ │ - * │ ██ ██████ ██ │ - * │ ██████ ██████ │ - * │ ██ ██████ ██ │ - * │ ████ ██ ████ │ - * │ member since │ - * │ 2026 · #A4F1 │ - * └──────────────────────────────────────┘ - * - * No external API calls - pure compute, instant render. + * Avatar sources: + * - "local" : the built-in symmetric 5x5/7x7/9x9 pixel grid. + * - DiceBear : pixel-art / bottts / lorelei / micah / adventurer / + * fun-emoji / shapes / pixel - fetched from the public + * DiceBear API as a small PNG, decoded with upng-js, + * and Floyd-Steinberg dithered into the chip area. + * Same callsign always picks the same face because + * the seed is the literal name. */ import { Canvas, Ink } from "../canvas"; import { Plugin, AccentMode } from "../plugin"; +import { fetchImageGray, blitGrayDither } from "../image_util"; +/* FNV-1a 32-bit. Used everywhere we need a deterministic seed. */ function hash32(s: string): number { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { @@ -30,91 +35,357 @@ function hash32(s: string): number { return h >>> 0; } +/* xorshift32 PRNG factory. */ +function rng(seed: number) { + let r = seed || 0xCAFEBABE; + return () => { + r ^= r << 13; r >>>= 0; + r ^= r >>> 17; + r ^= r << 5; r >>>= 0; + return r; + }; +} + +/* Hacker-flavoured class tags. Pick deterministically from the seed + * so the same name always gets the same role - just like a real con + * where your callsign is the immutable part of your identity. */ +const ROLES = [ + "OPERATOR", "ANALYST", "PHREAK", "CRACKER", + "HACKER", "AGENT", "GHOST", "ROGUE", + "ARTIST", "WIZARD", "SCOUT", "CIPHER", +]; + +/* DiceBear style ids supported by this plugin. The empty string maps to + * the local generator. Order matters - it's the order shown in the FAP + * dropdown. */ +const STYLES = [ + "local", "pixel-art", "bottts", "lorelei", + "adventurer", "micah", "fun-emoji", "shapes", +]; + +/* DiceBear avatar URL builder. The actual fetch / decode / dither is + * done by the shared `image_util` helpers so every other image plugin + * (GitHub avatars, NASA APOD, etc.) shares the same pipeline. */ +function dicebearUrl(style: string, seed: string, size: number): string { + return `https://api.dicebear.com/9.x/${encodeURIComponent(style)}` + + `/png?seed=${encodeURIComponent(seed)}` + + `&size=${size}` + + `&backgroundColor=ffffff`; +} + +/* ─── Drawing helpers ─────────────────────────────────────────────── */ + +/** A dashed horizontal line - reads as a PCB trace. */ +function dashedHLine(c: Canvas, x: number, y: number, w: number, dash: number, gap: number, ink: Ink) { + let xx = 0; + while (xx < w) { + const len = Math.min(dash, w - xx); + for (let i = 0; i < len; i++) c.setPixel(x + xx + i, y, ink); + xx += dash + gap; + } +} + +/** Pin-header row: alternating filled/empty squares like an IC pin + * strip. Used at the top of the badge for hardware authenticity. */ +function pinHeader(c: Canvas, x: number, y: number, count: number, + pin: number, gap: number, ink: Ink) { + for (let i = 0; i < count; i++) { + const px = x + i * (pin + gap); + if ((i & 1) === 0) { + // filled square + for (let dy = 0; dy < pin; dy++) + for (let dx = 0; dx < pin; dx++) + c.setPixel(px + dx, y + dy, ink); + } else { + // empty square (outline only) + c.rect(px, y, pin, pin, ink); + } + } +} + +/** Chip silkscreen: rounded-corner rectangle with a notch on top + * (the universal "this is an IC" affordance). */ +function chipFrame(c: Canvas, x: number, y: number, w: number, h: number, + notchInk: Ink, frameInk: Ink) { + // Outer frame + c.rect(x, y, w, h, frameInk); + // Inner shadow line for "engraved" feel + c.rect(x + 2, y + 2, w - 4, h - 4, frameInk); + // Top-centre orientation notch (semicircle approximation in 5 px) + const nx = x + (w >> 1) - 2; + const ny = y; + // Fill paper above the notch then re-draw with notch ink. + for (let i = 0; i < 5; i++) c.whitePixel(nx + i, ny); + for (let i = 0; i < 5; i++) c.whitePixel(nx + i, ny + 1); + // Notch arc + c.setPixel(nx + 1, ny, notchInk); + c.setPixel(nx + 2, ny, notchInk); + c.setPixel(nx + 3, ny, notchInk); + c.setPixel(nx, ny + 1, notchInk); + c.setPixel(nx + 4, ny + 1, notchInk); + c.setPixel(nx + 1, ny + 2, notchInk); + c.setPixel(nx + 2, ny + 2, notchInk); + c.setPixel(nx + 3, ny + 2, notchInk); +} + +/** Crosshair / reticle - decorative element. */ +function crosshair(c: Canvas, cx: number, cy: number, r: number, ink: Ink) { + c.line(cx - r, cy, cx + r, cy, ink); + c.line(cx, cy - r, cx, cy + r, ink); + // Outer tick marks + c.setPixel(cx - r - 2, cy, ink); + c.setPixel(cx + r + 2, cy, ink); + c.setPixel(cx, cy - r - 2, ink); + c.setPixel(cx, cy + r + 2, ink); + // Centre dot + c.setPixel(cx, cy, ink); +} + +/** Binary "barcode" strip derived from a seed. Each bit becomes a + * filled or empty cell of the given width. Looks like a hardware + * serial barcode, reads as cyberpunk decoration. */ +function binaryBar(c: Canvas, x: number, y: number, totalW: number, h: number, + seed: number, ink: Ink) { + const cellW = 2; + const cells = Math.floor(totalW / cellW); + let r = seed; + for (let i = 0; i < cells; i++) { + r ^= r << 13; r >>>= 0; + r ^= r >>> 17; + r ^= r << 5; r >>>= 0; + if ((r & 0xFF) > 110) { + for (let dy = 0; dy < h; dy++) + for (let dx = 0; dx < cellW; dx++) + c.setPixel(x + i * cellW + dx, y + dy, ink); + } + } +} + +/* ─── Plugin ─────────────────────────────────────────────────────── */ + export const identiconPlugin: Plugin = { manifest: { id: "identicon", name: "Identicon", - description: "Symmetric pixel-art avatar from a name", + description: "DEF CON-style hacker badge from a callsign", accent_modes: 1 | 2 | 4, params: [ - { key: "name", label: "Name", type: "string", default: "Dolphin" }, - { key: "grid", label: "Grid", type: "enum", default: "5", options: ["5", "7", "9"] }, - { key: "style", label: "Style", type: "enum", default: "blocky", + { key: "name", label: "Callsign", type: "string", default: "Dolphin" }, + { key: "style", label: "Style", type: "enum", default: "pixel-art", + options: STYLES }, + /* These two only matter when style == "local" - the DiceBear + * styles render a pre-composed image and ignore them. */ + { key: "grid", label: "Grid", type: "enum", default: "7", + options: ["5", "7", "9"] }, + { key: "subStyle", label: "Local Pat.", type: "enum", default: "blocky", options: ["blocky", "rings", "diagonal"] }, ], }, async render(params, W, H, accent: AccentMode) { - const name = (params.name ?? "Dolphin").trim() || "Dolphin"; - const grid = parseInt(params.grid ?? "5", 10); - const style = params.style ?? "blocky"; - const seed = hash32(name); + const name = ((params.name ?? "Dolphin").trim() || "Dolphin").toUpperCase(); + const style = params.style ?? "pixel-art"; + const grid = parseInt(params.grid ?? "7", 10); + const subStyle = params.subStyle ?? "blocky"; + const seed = hash32(name); const planes: 1 | 2 = accent === "none" ? 1 : 2; const c = new Canvas(W, H, planes); - const accentInk: Ink = planes === 2 ? 1 : 0; + const acc: Ink = planes === 2 ? 1 : 0; + const blk: Ink = 0; - const margin = W < 200 ? 6 : 10; + /* ── Geometry ──────────────────────────────────────────────── */ + const margin = 4; + const headerH = 8; // top pin row + trace + const footerH = 12; // bottom barcode + tagline - // Header - c.drawText(margin, margin, name.toUpperCase().slice(0, 24), 0, 1); - - // Avatar block: square sized to fit on the left half. - const blockSize = Math.min(H - margin * 2 - 24, Math.floor(W * 0.55)); - const cell = Math.floor(blockSize / grid); - const used = cell * grid; - const ax = margin; - const ay = margin + 16; - - const half = Math.ceil(grid / 2); - let r = seed; - const next = () => { - // xorshift32 - r ^= r << 13; r >>>= 0; - r ^= r >>> 17; - r ^= r << 5; r >>>= 0; - return r; - }; - - const cells: boolean[][] = []; - for (let y = 0; y < grid; y++) { - cells.push([]); - for (let x = 0; x < half; x++) { - let on = (next() & 0xff) > 110; - if (style === "rings") { - const dx = x - (half - 1) / 2; - const dy = y - (grid - 1) / 2; - on = on !== ((Math.round(Math.sqrt(dx * dx + dy * dy)) & 1) === 0); - } else if (style === "diagonal") { - on = on !== (((x + y) & 1) === 0); - } - cells[y].push(on); - } + /* ── Top pin header strip ──────────────────────────────────── */ + { + const pinSize = 3; + const pinGap = 2; + const stride = pinSize + pinGap; + const count = Math.floor((W - margin * 2) / stride); + const px = margin + 1; + const py = margin; + pinHeader(c, px, py, count, pinSize, pinGap, acc); } - // Mirror to right half. - for (let y = 0; y < grid; y++) { - for (let x = 0; x < grid; x++) { - const sx = x < half ? x : grid - 1 - x; - if (cells[y][sx]) { - c.fillRect(ax + x * cell, ay + y * cell, cell, cell, 0); + /* PCB trace just under the pin row, with a gap in the middle for + * a small label tag. */ + { + const traceY = margin + 8; + const tagText = `\u25C6 DEFCON 2026 \u25C6`; + const tagSize = c.textSize(tagText, 1); + const tagX = (W - tagSize.w) >> 1; + // Left dashed segment. + dashedHLine(c, margin + 4, traceY, tagX - margin - 8, 4, 2, blk); + // Right dashed segment. + const rStart = tagX + tagSize.w + 4; + dashedHLine(c, rStart, traceY, W - margin - 4 - rStart, 4, 2, blk); + // Tag label centred. + c.drawText(tagX, traceY - 3, tagText, blk, 1); + } + + /* ── Identicon "chip" on the left ─────────────────────────── */ + const bodyTop = margin + headerH + 8; + const bodyBot = H - footerH - margin; + const bodyH = bodyBot - bodyTop; + + const chipSize = Math.min(bodyH, Math.floor(W * 0.42)); + const chipX = margin + 2; + const chipY = bodyTop + ((bodyH - chipSize) >> 1); + + chipFrame(c, chipX, chipY, chipSize, chipSize, acc, blk); + + /* Inner area where the avatar lives - the chip frame eats 6 px on + * each side so the avatar isn't crowding the silkscreen. */ + const inset = 6; + const innerW = chipSize - inset * 2; + const innerH = chipSize - inset * 2; + const innerX = chipX + inset; + const innerY = chipY + inset; + + if (style === "local") { + /* Built-in symmetric pixel grid (zero-network). */ + const cell = Math.floor(innerW / grid); + const used = cell * grid; + const ax = chipX + ((chipSize - used) >> 1); + const ay = chipY + ((chipSize - used) >> 1); + const half = Math.ceil(grid / 2); + const next = rng(seed); + + const cells: boolean[][] = []; + for (let y = 0; y < grid; y++) { + cells.push([]); + for (let x = 0; x < half; x++) { + let on = (next() & 0xFF) > 110; + if (subStyle === "rings") { + const dx = x - (half - 1) / 2; + const dy = y - (grid - 1) / 2; + on = on !== ((Math.round(Math.sqrt(dx * dx + dy * dy)) & 1) === 0); + } else if (subStyle === "diagonal") { + on = on !== (((x + y) & 1) === 0); + } + cells[y].push(on); } } + for (let y = 0; y < grid; y++) { + for (let x = 0; x < grid; x++) { + const sx = x < half ? x : grid - 1 - x; + if (cells[y][sx]) { + c.fillRect(ax + x * cell, ay + y * cell, cell, cell, blk); + } + } + } + } else { + /* DiceBear-rendered avatar: fetch a small PNG (max(innerW, 96)) + * for crisp dithering, then Floyd-Steinberg into the chip. We + * fall through to the local generator on any network failure + * so a flaky connection still produces a valid badge. */ + const fetchSize = Math.max(96, innerW); + const dib = await fetchImageGray(dicebearUrl(style, name, fetchSize)); + if (dib) { + blitGrayDither(c, innerX, innerY, innerW, innerH, dib, blk); + } else { + /* Soft fallback: a single accented "?" in the chip area so the + * user knows the avatar fetch failed but the rest of the + * badge is still useful. */ + const q = "?"; + const qs = c.textSize(q, 4); + c.drawText(innerX + ((innerW - qs.w) >> 1), + innerY + ((innerH - qs.h) >> 1), + q, acc, 4); + } } - // Accent stripe down the side of the block (signature flair). - c.fillRect(ax + used + 4, ay, 3, used, accentInk); + /* Tiny crosshair in the chip's bottom-right corner - reads as a + * registration/orientation mark, very PCB. */ + crosshair(c, chipX + chipSize - 6, chipY + chipSize - 6, 2, acc); - // Right-hand info column. - const infoX = ax + used + 14; - let iy = ay; - c.drawText(infoX, iy, "MEMBER SINCE", 0, 1); iy += 10; - c.drawText(infoX, iy, "2026", accentInk, 2); iy += 18; - const codeStr = "#" + (seed >>> 0).toString(16).toUpperCase().padStart(8, "0").slice(0, 4); - c.drawText(infoX, iy, codeStr, 0, 1); + /* ── Right column: callsign / UID / role tag ──────────────── */ + const rightX = chipX + chipSize + 8; + const rightW = W - rightX - margin - 2; - // Border + inner bevel for finish. - c.rect(0, 0, W, H, 0); - c.rect(2, 2, W - 4, H - 4, 0); + /* CALLSIGN: pick the largest scale the name fits in. */ + let callScale = 3; + while (callScale > 1 && c.textSize(name, callScale).w > rightW) callScale--; + const callY = bodyTop + 2; + c.drawText(rightX, callY, name.slice(0, 12), blk, callScale); + const callH = c.textSize(name, callScale).h; + + /* Black underline below callsign - thick statement bar. */ + { + const ulY = callY + callH + 2; + const ulW = Math.min(rightW - 4, c.textSize(name.slice(0, 12), callScale).w); + for (let i = 0; i < 2; i++) c.hline(rightX, ulY + i, ulW, blk); + } + + /* UID line: "UID 0xA4F1B2C3" in mono. */ + const uidY = callY + callH + 8; + const uidStr = "UID 0x" + (seed >>> 0).toString(16).toUpperCase().padStart(8, "0"); + c.drawText(rightX, uidY, uidStr, blk, 1); + + /* Role pill: "OPERATOR" etc. in solid accent block with white + * knockout text - matches the crypto badge styling. */ + { + const role = ROLES[seed % ROLES.length]; + const roleSize = c.textSize(role, 1); + const padX = 4; + const padY = 3; + const bw = roleSize.w + padX * 2; + const bh = roleSize.h + padY * 2; + const bx = rightX; + const by = uidY + roleSize.h + 6; + c.fillRect(bx, by, bw, bh, acc); + c.drawTextWhite(bx + padX, by + padY, role, 1); + } + + /* "MEMBER SINCE 2026" small caps line, lower right. */ + { + const labelY = bodyBot - 8; + const small = "MEMBER \u00B7 2026"; + c.drawText(rightX, labelY, small, blk, 1); + } + + /* ── Footer: binary barcode + tagline ─────────────────────── */ + { + const fy = H - footerH - 1; + // Left & right ranger marks. + c.drawText(margin, fy + 4, "\u25A0\u25A0\u25A1\u25A0", blk, 1); + const tail = "\u25A0\u25A1\u25A0\u25A0"; + const tailW = c.textSize(tail, 1).w; + c.drawText(W - margin - tailW, fy + 4, tail, blk, 1); + + // Binary barcode strip in the middle. + const barX = margin + 28; + const barW = W - margin * 2 - 56; + binaryBar(c, barX, fy + 2, barW, 6, seed ^ 0xA5A5A5A5, blk); + } + + /* ── Outer card border + corner brackets in accent ────────── */ + c.rect(0, 0, W, H, blk); + /* Heavy corner brackets in accent give the card the "viewfinder" + * frame look that translates so well to a phone photo. */ + const cornerLen = 8; + // top-left + for (let i = 0; i < 2; i++) { + c.hline(0, i, cornerLen, acc); + c.vline(i, 0, cornerLen, acc); + } + // top-right + for (let i = 0; i < 2; i++) { + c.hline(W - cornerLen, i, cornerLen, acc); + c.vline(W - 1 - i, 0, cornerLen, acc); + } + // bottom-left + for (let i = 0; i < 2; i++) { + c.hline(0, H - 1 - i, cornerLen, acc); + c.vline(i, H - cornerLen, cornerLen, acc); + } + // bottom-right + for (let i = 0; i < 2; i++) { + c.hline(W - cornerLen, H - 1 - i, cornerLen, acc); + c.vline(W - 1 - i, H - cornerLen, cornerLen, acc); + } return c; }, diff --git a/cloud-plugins/src/upng.d.ts b/cloud-plugins/src/upng.d.ts new file mode 100644 index 0000000..d32f174 --- /dev/null +++ b/cloud-plugins/src/upng.d.ts @@ -0,0 +1,35 @@ +/* Minimal type shims for the CommonJS image libraries we use in the + * worker bundle. These libs have no official @types packages, and we + * only touch a tiny slice of each surface, so a hand-written shim is + * cheaper than wrestling with `allowJs`. */ + +declare module "jpeg-js" { + interface DecodedJpeg { + width: number; + height: number; + data: Uint8Array | Buffer; + } + interface JpegJs { + decode(buf: ArrayBuffer | Uint8Array, + opts?: { useTArray?: boolean; maxMemoryUsageInMB?: number; formatAsRGBA?: boolean }) + : DecodedJpeg; + encode(rawImageData: { data: Uint8Array; width: number; height: number }, + quality?: number): { data: Uint8Array; width: number; height: number }; + } + const jpegJs: JpegJs; + export default jpegJs; +} + +declare module "upng-js" { + interface DecodedPng { + width: number; + height: number; + depth: number; + ctype: number; + data: Uint8Array; + tabs: Record; + frames: unknown[]; + } + export function decode(buf: ArrayBuffer | Uint8Array): DecodedPng; + export function toRGBA8(out: DecodedPng): ArrayBuffer[]; +} diff --git a/scenes/tagtinker_scene_broadcast.c b/scenes/tagtinker_scene_broadcast.c index fd47eac..8ae8532 100644 --- a/scenes/tagtinker_scene_broadcast.c +++ b/scenes/tagtinker_scene_broadcast.c @@ -60,9 +60,20 @@ static void repeats_changed(VariableItem* item) { app->repeats = repeat_values[idx]; } +/* Row index of the ">> Transmit <<" item. Set when the list is built + * because the row count differs between flip-page (Page/Duration/Forever + * + Repeats/Repeat = 5 rows above Transmit) and diagnostic (just + * Repeats/Repeat = 2 rows above Transmit). */ +static uint8_t s_transmit_row_index = 0; + static void broadcast_enter_callback(void* context, uint32_t index) { - UNUSED(index); TagTinkerApp* app = context; + /* VariableItemList fires this on OK for ANY row. We only want OK on + * the explicit ">> Transmit <<" row to start the broadcast - pressing + * OK on the Page / Duration / Forever / Repeats rows used to also + * start a transmit, which made it impossible to actually open those + * settings without sending stuff to every tag in the room. */ + if(index != s_transmit_row_index) return; view_dispatcher_send_custom_event(app->view_dispatcher, 0); } @@ -113,10 +124,27 @@ void tagtinker_scene_broadcast_on_enter(void* context) { variable_item_set_current_value_index(item, app->tx_spam ? 1 : 0); variable_item_set_current_value_text(item, forever_labels[app->tx_spam ? 1 : 0]); + /* The Transmit row is whatever index comes after everything we've + * added so far. Diagnostic mode skips Page/Duration/Forever, hence + * the index isn't a constant. */ + s_transmit_row_index = + (app->broadcast_type == TagTinkerBroadcastFlipPage) ? 5 : 2; variable_item_list_add(vil, ">> Transmit <<", 0, NULL, app); - /* OK press on any item → trigger transmit */ + /* The list calls back on OK for ANY row; broadcast_enter_callback + * filters by row index so only the Transmit row actually starts + * the broadcast. + * + * Cursor is intentionally LEFT on row 0 (the first setting) and + * NOT on Transmit. Reason: the OK key-press that picked us out of + * the previous Submenu generates a key-release input event that + * arrives just after this scene's VIL becomes active. If the + * cursor were on the Transmit row at that moment the stale OK + * would auto-fire the enter callback and the radio would start + * blasting before the user touched anything. Starting on row 0 + * (which has a change_callback) means stale OKs are harmless. */ variable_item_list_set_enter_callback(vil, broadcast_enter_callback, app); + variable_item_list_set_selected_item(vil, 0); view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList); } diff --git a/scenes/tagtinker_scene_wifi_plugins.c b/scenes/tagtinker_scene_wifi_plugins.c index b0ad40e..42069d0 100644 --- a/scenes/tagtinker_scene_wifi_plugins.c +++ b/scenes/tagtinker_scene_wifi_plugins.c @@ -49,7 +49,7 @@ static void wifi_plugins_event_cb(const TtWifiEvent* e, void* user) { view_dispatcher_send_custom_event(app->view_dispatcher, EVT_LINK_STATUS); break; case TtWifiEvtPlugin: - if(app->wifi_plugin_count < 16U && e->plugin) { + if(app->wifi_plugin_count < TT_WIFI_MAX_FAP_PLUGINS && e->plugin) { plugin_array(app)[app->wifi_plugin_count++] = *e->plugin; } break; @@ -118,10 +118,14 @@ static void rebuild_submenu(TagTinkerApp* app) { void tagtinker_scene_wifi_plugins_on_enter(void* ctx) { TagTinkerApp* app = ctx; - /* Lazy-allocate the link + plugin cache the first time we enter. */ + /* Lazy-allocate the link + plugin cache the first time we enter. + * The cache is the dominant heap cost of the WiFi flow (~1.9 KB per + * slot), so capping at TT_WIFI_MAX_FAP_PLUGINS keeps the IR TX + * pipeline that follows a plugin run well-fed on heap. */ if(!app->wifi) { - app->wifi_plugins = malloc(sizeof(TagTinkerWifiPlugin) * 16); - memset(app->wifi_plugins, 0, sizeof(TagTinkerWifiPlugin) * 16); + const size_t bytes = sizeof(TagTinkerWifiPlugin) * TT_WIFI_MAX_FAP_PLUGINS; + app->wifi_plugins = malloc(bytes); + memset(app->wifi_plugins, 0, bytes); app->wifi = tagtinker_wifi_alloc(wifi_plugins_event_cb, app); } if(!tagtinker_wifi_open((TagTinkerWifi*)app->wifi)) { @@ -171,7 +175,7 @@ bool tagtinker_scene_wifi_plugins_on_event(void* ctx, SceneManagerEvent event) { rebuild_submenu(app); return true; } - if(event.event >= EVT_PLUGIN_BASE && event.event < EVT_PLUGIN_BASE + 16U) { + if(event.event >= EVT_PLUGIN_BASE && event.event < EVT_PLUGIN_BASE + TT_WIFI_MAX_FAP_PLUGINS) { uint8_t idx = (uint8_t)(event.event - EVT_PLUGIN_BASE); if(idx < app->wifi_plugin_count) { app->wifi_selected_plugin = (int8_t)idx; diff --git a/scenes/tagtinker_scene_wifi_run.c b/scenes/tagtinker_scene_wifi_run.c index 65c95bd..c14b57b 100644 --- a/scenes/tagtinker_scene_wifi_run.c +++ b/scenes/tagtinker_scene_wifi_run.c @@ -217,7 +217,18 @@ static void run_event_cb(const TtWifiEvent* e, void* user) { case TtWifiEvtResultBegin: { uint16_t w = (uint16_t)(e->u0 & 0xFFFFu); uint16_t h = (uint16_t)(e->u0 >> 16); - if(!tagtinker_wifi_bmp_open(&s_bmp_writer, w, h)) { + uint8_t pl = (uint8_t)(e->u1 ? e->u1 : 1); + /* Pick a palette accent that matches the destination tag's colour + * so the BMP file embeds the right BGR for previewers. The IR TX + * path itself only cares about plane bits + the target profile. */ + uint8_t ar = 0xE0, ag = 0x10, ab = 0x10; /* default red */ + if(app->selected_target >= 0 && app->selected_target < app->target_count) { + const TagTinkerTarget* t = &app->targets[app->selected_target]; + if(t->profile.color == TagTinkerTagColorYellow) { + ar = 0xF0; ag = 0xC0; ab = 0x10; + } + } + if(!tagtinker_wifi_bmp_open(&s_bmp_writer, w, h, pl, ar, ag, ab)) { strncpy(app->wifi_last_error, "BMP open failed", sizeof(app->wifi_last_error) - 1); view_dispatcher_send_custom_event(app->view_dispatcher, EVT_ERROR); @@ -278,11 +289,19 @@ static void start_run(TagTinkerApp* app) { * else fallback to a reasonable sane size. */ uint16_t tw = app->esl_width ? app->esl_width : 296; uint16_t th = app->esl_height ? app->esl_height : 128; - /* TODO: extend the IR TX pipeline to take a 2-plane BMP and TX both - * planes (black + accent) so we can light up red/yellow tags. Until - * then, force mono so all ink lands on plane 0 - the only plane we - * currently forward to the tag. */ + /* Honour the tag's accent capability: red/yellow profiles get the + * accent plane, mono profiles stay mono. The BMP writer + the IR TX + * pipeline already understand 2-plane BMPs (same convention as the + * web image prep tool), so plugins can use the accent freely. */ uint8_t accent = TT_ACCENT_NONE; + if(app->selected_target >= 0 && app->selected_target < app->target_count) { + const TagTinkerTarget* t = &app->targets[app->selected_target]; + if(tagtinker_target_supports_accent(t)) { + accent = (t->profile.color == TagTinkerTagColorYellow) + ? TT_ACCENT_YELLOW + : TT_ACCENT_RED; + } + } TtWifiKV kv[6]; uint8_t n = 0; @@ -385,6 +404,12 @@ void tagtinker_scene_wifi_run_on_exit(void* ctx) { (TagTinkerWifi*)app->wifi, s_prev_cb, s_prev_user, NULL, NULL); s_prev_cb = NULL; s_prev_user = NULL; } + /* Release the ~10 KB pixel buffer if a transfer was abandoned mid-flight + * (e.g. the user backs out of the popup before RESULT_END). Without this + * the buffer leaks on every run and the IR transmit scene that follows + * has noticeably less heap to malloc its plane buffers - the OOM crashes + * we were seeing. abort() is a no-op if the writer is already closed. */ + tagtinker_wifi_bmp_abort(&s_bmp_writer); variable_item_list_reset(app->var_item_list); popup_reset(app->popup); text_input_reset(app->text_input); diff --git a/tagtinker_app.h b/tagtinker_app.h index 5b378ce..d6a010b 100644 --- a/tagtinker_app.h +++ b/tagtinker_app.h @@ -253,8 +253,8 @@ struct TagTinkerApp { char wifi_ip[20]; char wifi_creds_ssid[33]; /* used by setup scene before sending */ char wifi_creds_pwd[65]; - /* Plugin discovery cache. Up to TT_PLUGIN_MAX_PLUGINS_FAP. */ - void* wifi_plugins; /* TagTinkerWifiPlugin[16], heap-alloced */ + /* Plugin discovery cache. Up to TT_WIFI_MAX_FAP_PLUGINS slots. */ + void* wifi_plugins; /* TagTinkerWifiPlugin[TT_WIFI_MAX_FAP_PLUGINS], heap-alloced */ uint8_t wifi_plugin_count; bool wifi_plugins_loading; int8_t wifi_selected_plugin; diff --git a/wifi/tagtinker_wifi.c b/wifi/tagtinker_wifi.c index 0bd9aa0..ad06fbf 100644 --- a/wifi/tagtinker_wifi.c +++ b/wifi/tagtinker_wifi.c @@ -317,11 +317,11 @@ TagTinkerWifi* tagtinker_wifi_alloc(TtWifiEventCb cb, void* user) { TagTinkerWifi* w = malloc(sizeof(*w)); memset(w, 0, sizeof(*w)); w->cb = cb; w->user = user; - /* 16 KB: a full plugin render is ~5 KB of pixel data plus header - * frames + interleaved progress frames. The old 4 KB buffer would - * back-pressure during the burst and the ISR (which uses timeout 0) - * silently dropped tail bytes. */ - w->rx_stream = furi_stream_buffer_alloc(16384, 1); + /* 8 KB is plenty after the bulk-read ISR fix: the worker pulls 256 + * bytes at a time and never lags behind the burst from the ESP. The + * older 16 KB sizing was a workaround for the per-byte syscall + * bottleneck and just wastes heap that the IR TX pipeline could use. */ + w->rx_stream = furi_stream_buffer_alloc(8192, 1); return w; } diff --git a/wifi/tagtinker_wifi.h b/wifi/tagtinker_wifi.h index 29144e9..c91fa4a 100644 --- a/wifi/tagtinker_wifi.h +++ b/wifi/tagtinker_wifi.h @@ -45,6 +45,13 @@ typedef enum { #define TT_WIFI_MAX_PARAMS 6 #define TT_WIFI_MAX_OPTIONS 8 +/* Max plugin manifests the FAP will cache. Each TagTinkerWifiPlugin is + * ~1.9 KB so the whole cache is ~15 KB at 8 entries - that's the heap + * cost of opening the WiFi Plugins menu. Bumping this number directly + * reduces the heap available to the IR transmit pipeline that follows + * a plugin run, so leave it small. */ +#define TT_WIFI_MAX_FAP_PLUGINS 8 + typedef struct { char key[24]; char label[24]; diff --git a/wifi/tagtinker_wifi_bmp.c b/wifi/tagtinker_wifi_bmp.c index 63f91b8..8851b31 100644 --- a/wifi/tagtinker_wifi_bmp.c +++ b/wifi/tagtinker_wifi_bmp.c @@ -15,10 +15,10 @@ #include #include -#define BMP_FILE_HDR 14U -#define BMP_DIB_HDR 40U -#define BMP_PALETTE 8U -#define BMP_HDR_TOTAL (BMP_FILE_HDR + BMP_DIB_HDR + BMP_PALETTE) +#define BMP_FILE_HDR 14U +#define BMP_DIB_HDR 40U +#define BMP_PALETTE_2 8U /* mono: 2 entries * 4 bytes BGRA */ +#define BMP_PALETTE_3 12U /* tri: 3 entries (white / black / accent) */ static void put_le16(uint8_t* p, uint16_t v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); } static void put_le32(uint8_t* p, uint32_t v) { @@ -26,12 +26,20 @@ static void put_le32(uint8_t* p, uint32_t v) { p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24); } -bool tagtinker_wifi_bmp_open(TagTinkerWifiBmpWriter* w, uint16_t width, uint16_t height) { +bool tagtinker_wifi_bmp_open(TagTinkerWifiBmpWriter* w, + uint16_t width, uint16_t height, + uint8_t planes, + uint8_t accent_r, uint8_t accent_g, uint8_t accent_b) { memset(w, 0, sizeof(*w)); w->width = width; w->height = height; + w->planes = (planes >= 2) ? 2 : 1; + w->accent_r = accent_r ? accent_r : 0xE0; + w->accent_g = accent_g; + w->accent_b = accent_b; w->row_stride = (uint16_t)(((width + 31U) / 32U) * 4U); - w->pixel_size = (size_t)w->row_stride * height; + w->plane_size = (size_t)w->row_stride * height; + w->pixel_size = w->plane_size * w->planes; w->pixel_buf = malloc(w->pixel_size); if(!w->pixel_buf) return false; @@ -56,10 +64,10 @@ bool tagtinker_wifi_bmp_chunk(TagTinkerWifiBmpWriter* w, const uint8_t* data, si if(!w->pixel_buf) return false; /* Append at the running byte offset. Tracking rows here would silently * corrupt partial rows because chunks from the worker aren't aligned - * to row_stride. Plane 1 (if any) is appended after plane 0; we simply - * stop accepting bytes once plane 0 is full. */ + * to row_stride. The worker emits planes back-to-back (plane 0 then + * plane 1), and pixel_size already accounts for all planes. */ size_t off = (size_t)w->bytes_written; - if(off >= w->pixel_size) return true; /* plane 1 / overflow - drop */ + if(off >= w->pixel_size) return true; /* overflow - drop */ size_t remain = w->pixel_size - off; size_t take = (len < remain) ? len : remain; memcpy(w->pixel_buf + off, data, take); @@ -70,41 +78,59 @@ bool tagtinker_wifi_bmp_chunk(TagTinkerWifiBmpWriter* w, const uint8_t* data, si bool tagtinker_wifi_bmp_close(TagTinkerWifiBmpWriter* w) { if(!w->file) return false; - uint32_t pixel_section = (uint32_t)w->pixel_size; - uint32_t total_size = BMP_HDR_TOTAL + pixel_section; + const uint16_t pal_bytes = (w->planes == 2) ? BMP_PALETTE_3 : BMP_PALETTE_2; + const uint16_t hdr_total = BMP_FILE_HDR + BMP_DIB_HDR + pal_bytes; + const uint32_t pixel_section = (uint32_t)w->pixel_size; + const uint32_t total_size = hdr_total + pixel_section; + + /* The web-image-prep convention (which the rest of the FAP TX pipeline + * already detects via info.bpp == 2 in tx_bmp_open) is: + * + * biPlanes = 1 (BMP standard requires this) + * biBitCount = 2 for 2-plane accent, 1 for plain mono + * palette has 2 (mono) or 3 (white/black/accent) entries + * pixel data is 2 stacked 1bpp planes when biBitCount==2 + * + * The streaming TX reader uses biBitCount as the "accent in this BMP?" + * flag, not biPlanes - so we have to write it that way too. */ /* --- File + DIB headers ----------------------------------------- */ - uint8_t hdr[BMP_HDR_TOTAL] = {0}; + uint8_t hdr[BMP_FILE_HDR + BMP_DIB_HDR + BMP_PALETTE_3] = {0}; /* BITMAPFILEHEADER */ hdr[0] = 'B'; hdr[1] = 'M'; put_le32(&hdr[2], total_size); - put_le32(&hdr[10], BMP_HDR_TOTAL); + put_le32(&hdr[10], hdr_total); /* BITMAPINFOHEADER */ put_le32(&hdr[14], BMP_DIB_HDR); put_le32(&hdr[18], (uint32_t)w->width); put_le32(&hdr[22], (uint32_t)w->height); /* positive = bottom-up */ - put_le16(&hdr[26], 1); /* planes */ - put_le16(&hdr[28], 1); /* bpp */ + put_le16(&hdr[26], 1); /* biPlanes (BMP req) */ + put_le16(&hdr[28], (uint16_t)w->planes); /* biBitCount: 1=mono, 2=accent */ put_le32(&hdr[30], 0); /* BI_RGB */ put_le32(&hdr[34], pixel_section); put_le32(&hdr[38], 2835); /* 72 DPI */ put_le32(&hdr[42], 2835); - put_le32(&hdr[46], 2); /* colors used */ + put_le32(&hdr[46], (uint32_t)(w->planes == 2 ? 3 : 2)); /* colors used */ put_le32(&hdr[50], 0); /* important colors */ - /* Palette (BGRA): index 0 = white, index 1 = black. - * Matches the convention used by the web image prep tool and the rest - * of the TagTinker TX pipeline (bit value 1 = ink on / black pixel). */ - hdr[54] = 0xFF; hdr[55] = 0xFF; hdr[56] = 0xFF; hdr[57] = 0x00; - hdr[58] = 0x00; hdr[59] = 0x00; hdr[60] = 0x00; hdr[61] = 0x00; + /* Palette (BGRA per entry). */ + hdr[54] = 0xFF; hdr[55] = 0xFF; hdr[56] = 0xFF; hdr[57] = 0x00; /* white */ + hdr[58] = 0x00; hdr[59] = 0x00; hdr[60] = 0x00; hdr[61] = 0x00; /* black */ + if(w->planes == 2) { + hdr[62] = w->accent_b; hdr[63] = w->accent_g; + hdr[64] = w->accent_r; hdr[65] = 0x00; /* accent */ + } - if(storage_file_write(w->file, hdr, sizeof(hdr)) != sizeof(hdr)) goto fail; + if(storage_file_write(w->file, hdr, hdr_total) != hdr_total) goto fail; - /* --- Flip rows bottom-up and write ------------------------------ */ - for(int32_t row = (int32_t)w->height - 1; row >= 0; row--) { - const uint8_t* src = w->pixel_buf + (size_t)row * w->row_stride; - if(storage_file_write(w->file, src, w->row_stride) != w->row_stride) goto fail; + /* --- Flip rows bottom-up per plane and write ------------------- */ + for(uint8_t pl = 0; pl < w->planes; pl++) { + const uint8_t* plane_base = w->pixel_buf + pl * w->plane_size; + for(int32_t row = (int32_t)w->height - 1; row >= 0; row--) { + const uint8_t* src = plane_base + (size_t)row * w->row_stride; + if(storage_file_write(w->file, src, w->row_stride) != w->row_stride) goto fail; + } } storage_file_close(w->file); diff --git a/wifi/tagtinker_wifi_bmp.h b/wifi/tagtinker_wifi_bmp.h index aff307d..87b993c 100644 --- a/wifi/tagtinker_wifi_bmp.h +++ b/wifi/tagtinker_wifi_bmp.h @@ -21,16 +21,28 @@ typedef struct { Storage* storage; uint16_t width; uint16_t height; + uint8_t planes; /* 1 = mono, 2 = mono + accent (matches web-image-prep BMPs) */ + uint8_t accent_r; + uint8_t accent_g; + uint8_t accent_b; uint16_t row_stride; /* bytes per row, padded to 4 (matches canvas) */ uint32_t bytes_written; /* running offset into pixel_buf */ /* The Flipper file system can't seek-write efficiently, and BMP rows are * stored bottom-up - so we buffer the whole pixel section in memory then - * flip on close(). */ + * flip on close(). When planes==2, plane 0 (mono) and plane 1 (accent) + * are concatenated in the buffer in worker order. */ uint8_t* pixel_buf; - size_t pixel_size; + size_t pixel_size; /* total bytes for ALL planes */ + size_t plane_size; /* bytes per plane */ } TagTinkerWifiBmpWriter; -bool tagtinker_wifi_bmp_open (TagTinkerWifiBmpWriter* w, uint16_t width, uint16_t height); +/* `planes` matches the worker's RESULT_BEGIN plane count (1 or 2). + * `accent_rgb` is sampled into the BMP palette[2] when planes==2; ignored + * otherwise. Pass 0 to use a default red. */ +bool tagtinker_wifi_bmp_open(TagTinkerWifiBmpWriter* w, + uint16_t width, uint16_t height, + uint8_t planes, + uint8_t accent_r, uint8_t accent_g, uint8_t accent_b); bool tagtinker_wifi_bmp_chunk(TagTinkerWifiBmpWriter* w, const uint8_t* data, size_t len); bool tagtinker_wifi_bmp_close(TagTinkerWifiBmpWriter* w); void tagtinker_wifi_bmp_abort(TagTinkerWifiBmpWriter* w);