Files
ESLPwn/cloud-plugins/src/canvas.ts
T
i12bp8 287481453e WiFi plugins: GitHub Profile + DiceBear identicon + tri-state canvas
Worker (cloud-plugins/):
- New plugin 'GitHub Profile': avatar dithered upper-left, display
  name + @handle (with accent star) + word-wrapped bio in the right
  column, followers/repos line, and a 53x7 contribution heatmap
  with an accent palette (red dots for everyday activity, black for
  the rare hottest days). Tuned for 208x112 ESLs and adapts to
  296x128 with a bigger avatar and roomier heatmap.
- Identicon plugin gains DiceBear style selector (pixel-art /
  bottts / lorelei / adventurer / micah / fun-emoji / shapes) on
  top of the original symmetric grid 'local' mode. Both are
  rendered through the new shared image_util pipeline.
- Crypto plugin redesigned: editorial layout with corner brackets,
  bold price headline, white-knockout delta badge with a real
  triangle glyph, currency symbol prefix using the canvas font's
  new euro / pound / yen glyphs, and a stippled accent fill under
  the sparkline.

Canvas:
- setPixel is now last-write-wins across planes, so drawing black
  on top of an accent-filled area produces a clean black pixel
  instead of the muddy plane-conflict the e-paper driver was
  showing previously. Every pixel is therefore exactly one of
  white / black / accent which is what plugin authors intuit.
- New whitePixel + drawTextWhite primitives for knockout text
  (e.g. white labels punched through a coloured badge).
- FONT_EXTRA shipped with the canvas for currency / arrow / shape
  / star / bracket glyphs. Plugins can now write '\u20AC1234' and
  have the euro symbol Just Work.

Image pipeline (new image_util.ts):
- One fetchImageGray() that sniffs PNG vs JPEG, decodes via
  upng-js / jpeg-js, composites alpha over white, and returns a
  tightly-packed grayscale buffer.
- One blitGrayDither() that nearest-neighbour scales the gray
  buffer to a destination rect and serpentine Floyd-Steinberg
  dithers it on the way in. Used by identicon and github.

FAP:
- WiFi BMP writer is now plane-aware: writes the same biPlanes=1,
  biBitCount=2, 3-entry palette layout the rest of the IR TX
  pipeline already understood for web-image-prep BMPs. Tri-colour
  tags now actually receive accent ink from cloud-rendered plugins.
- WiFi run scene un-forces accent: plugins are invoked with the
  tag's actual accent capability and the BMP is opened with the
  matching plane count.
- Run scene now calls bmp_abort on exit so an aborted transfer
  doesn't leak the ~10 KB pixel buffer.
- Plugin manifest cache capped at TT_WIFI_MAX_FAP_PLUGINS=8 (down
  from 16) and the UART RX stream buffer shrunk to 8 KB. Together
  these cut WiFi-flow heap pressure by ~23 KB and fix the OOM
  crashes that occasionally happened when opening WiFi Plugins.
- Broadcast scene auto-transmit fix: enter_callback now filters
  by the >> Transmit << row index, and the cursor defaults to
  row 0 instead of Transmit. Previously a stale OK key-release
  from the prior submenu would land on the Transmit row and
  blast the radio without any user action.
2026-04-27 12:49:12 +02:00

294 lines
10 KiB
TypeScript

/*
* Server-side 1bpp / 2bpp canvas. Each plane is a packed bitmap with the
* exact byte layout the ESP forwards to the Flipper (and the Flipper
* TXes verbatim to the tag): rows top-down, bytes MSB-first within a
* row, row_stride padded to 4 bytes (BMP convention).
*
* bit == 0 -> ink off (white)
* bit == 1 -> ink on (black on plane 0, accent on plane 1)
*
* Plane 1 only allocated when accent is requested AND supported.
*/
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)
export class Canvas {
readonly width: number;
readonly height: number;
readonly planes: number;
readonly rowStride: number;
readonly plane0: Uint8Array;
readonly plane1: Uint8Array | null;
constructor(width: number, height: number, planes: 1 | 2) {
this.width = width;
this.height = height;
this.planes = planes;
this.rowStride = ((width + 31) >> 5) << 2; // round up to 4 bytes
this.plane0 = new Uint8Array(this.rowStride * height);
this.plane1 = planes === 2 ? new Uint8Array(this.rowStride * height) : null;
}
private plane(ink: Ink): Uint8Array {
if (ink === 1 && this.plane1) return this.plane1;
return this.plane0;
}
setPixel(x: number, y: number, ink: Ink = 0): 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);
/* 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 {
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));
}
/** 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);
}
vline(x: number, y: number, h: number, ink: Ink = 0): void {
for (let i = 0; i < h; i++) this.setPixel(x, y + i, ink);
}
line(x0: number, y0: number, x1: number, y1: number, ink: Ink = 0): void {
// Bresenham
const dx = Math.abs(x1 - x0);
const sx = x0 < x1 ? 1 : -1;
const dy = -Math.abs(y1 - y0);
const sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
while (true) {
this.setPixel(x0, y0, ink);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
rect(x: number, y: number, w: number, h: number, ink: Ink = 0): void {
this.hline(x, y, w, ink);
this.hline(x, y + h - 1, w, ink);
this.vline(x, y, h, ink);
this.vline(x + w - 1, y, h, ink);
}
fillRect(x: number, y: number, w: number, h: number, ink: Ink = 0): void {
for (let yy = 0; yy < h; yy++) this.hline(x, y + yy, w, ink);
}
/* ---- Text -------------------------------------------------------- */
textSize(s: string, scale = 1): { w: number; h: number } {
return { w: s.length * (FONT_5x7_W + 1) * scale, h: FONT_5x7_H * scale };
}
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);
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++) {
if (bits & (1 << (FONT_5x7_W - 1 - col))) {
for (let dy = 0; dy < scale; dy++) {
for (let dx = 0; dx < scale; dx++) {
this.setPixel(
x + (ci * (FONT_5x7_W + 1) + col) * scale + dx,
y + row * scale + dy,
ink,
);
}
}
}
}
}
}
}
/** 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);
}
drawTextRight(rx: number, y: number, s: string, ink: Ink = 0, scale = 1): void {
const { w } = this.textSize(s, scale);
this.drawText(rx - w, y, s, ink, scale);
}
/* ---- Sparkline --------------------------------------------------- */
sparkline(
x: number, y: number, w: number, h: number,
samples: number[], ink: Ink = 0, dot = true,
): void {
if (samples.length < 2) return;
let lo = Infinity, hi = -Infinity;
for (const v of samples) {
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 < samples.length; i++) {
const px = x + Math.round((i * (w - 1)) / (samples.length - 1));
const py = y + h - 1 - Math.round(((samples[i] - lo) * (h - 1)) / (hi - lo));
xs.push(px); ys.push(py);
}
for (let i = 1; i < samples.length; i++) {
this.line(xs[i - 1], ys[i - 1], xs[i], ys[i], ink);
}
if (dot) {
const lx = xs[xs.length - 1], ly = ys[ys.length - 1];
this.fillRect(lx - 1, ly - 1, 3, 3, ink);
}
}
/* ---- Floyd-Steinberg dither (image to 1bpp/2bpp) ----------------- */
/**
* Blit a pre-scaled grayscale (or RGB) image into the canvas with
* Floyd-Steinberg dithering. `gray` is row-major, 1 byte per pixel.
* `rgb` (optional) is row-major RGB triplets; when present and an accent
* mode is set, saturated red/yellow pixels are routed to plane 1.
*/
blitDithered(
dx: number, dy: number, dw: number, dh: number,
gray: Uint8Array, rgb: Uint8Array | null, sw: number, sh: number,
accentMode: "none" | "red" | "yellow",
): void {
// Nearest-neighbour scale source -> dest, then dither in place.
const buf = new Float32Array(dw * dh);
const accFlag = new Uint8Array(dw * dh);
for (let y = 0; y < dh; y++) {
const sy = Math.min(sh - 1, Math.floor((y * sh) / dh));
for (let x = 0; x < dw; x++) {
const sx = Math.min(sw - 1, Math.floor((x * sw) / dw));
const gi = sy * sw + sx;
buf[y * dw + x] = gray[gi];
if (rgb && accentMode !== "none") {
const ri = gi * 3;
const r = rgb[ri], g = rgb[ri + 1], b = rgb[ri + 2];
if (accentMode === "red" && r > 150 && g < 90 && b < 90) {
accFlag[y * dw + x] = 1;
} else if (accentMode === "yellow" && r > 180 && g > 150 && b < 100) {
accFlag[y * dw + x] = 1;
}
}
}
}
for (let y = 0; y < dh; y++) {
for (let x = 0; x < dw; x++) {
const i = y * dw + x;
const old = buf[i];
const isAccent = accFlag[i] === 1;
const newPx = old < 128 ? 0 : 255;
if (newPx === 0) {
this.setPixel(dx + x, dy + y, isAccent ? 1 : 0);
}
const err = old - newPx;
if (x + 1 < dw) buf[i + 1] += err * 7 / 16;
if (y + 1 < dh) {
if (x > 0) buf[i + dw - 1] += err * 3 / 16;
buf[i + dw] += err * 5 / 16;
if (x + 1 < dw) buf[i + dw + 1] += err * 1 / 16;
}
}
}
}
/* ---- Serialization ---------------------------------------------- */
/**
* Encode as the binary blob the ESP forwards to the Flipper:
* uint16 width LE, uint16 height LE, uint8 planes, uint8 reserved,
* uint16 row_stride LE,
* plane0 bytes,
* plane1 bytes (if planes == 2).
*/
toBytes(): Uint8Array {
const planeBytes = this.rowStride * this.height;
const total = 8 + planeBytes * this.planes;
const out = new Uint8Array(total);
const view = new DataView(out.buffer);
view.setUint16(0, this.width, true);
view.setUint16(2, this.height, true);
out[4] = this.planes;
out[5] = 0;
view.setUint16(6, this.rowStride, true);
out.set(this.plane0, 8);
if (this.plane1) out.set(this.plane1, 8 + planeBytes);
return out;
}
}