Add optional WiFi Plugins (ESP32-S2 bridge + Cloudflare Worker)

- Flipper FAP: WiFi Setup, Plugins picker, plugin-run scene wired into
  Targeted Payloads -> <tag> -> WiFi Plugins
- Framed UART link to the dev board with bulk-read RX path and 16 KB
  stream buffer to keep up with full plugin bursts
- ESP firmware: 900 KB UART<->HTTPS bridge, USB-Serial-JTAG console,
  cert-bundle TLS, 16 KB worker stack to host mbedTLS handshake
- Cloudflare Worker: Crypto, Weather, Identicon plugins out of the
  box; new plugins are a single TypeScript file, no Flipper reflash
- Shared protocol header + 1bpp BMP writer that matches web-image-prep
  palette/stride conventions
This commit is contained in:
i12bp8 committed 2026-04-26 20:50:16 +02:00
1 parent 8fab06a807
commit 2739069f22
40 files changed
+5310 -1

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
/*
* 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 } 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 p = this.plane(ink);
const i = y * this.rowStride + (x >> 3);
p[i] |= 0x80 >> (x & 7);
}
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));
}
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);
const idx = ch >= 32 && ch <= 127 ? ch - 32 : 0;
const glyph = FONT_5x7[idx] ?? 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,
);
}
}
}
}
}
}
}
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;
}
}
+115
View File
@@ -0,0 +1,115 @@
/*
* Compact 5x7 ASCII bitmap font (95 glyphs, 0x20..0x7E).
*
* Each glyph is 7 bytes. Each byte is a row, low 5 bits = pixels left to
* right (we shift starting at bit 4 down to bit 0). Stored MSB-aligned in
* a uint8 by left-shifting 3 bits at runtime (we just mask `(bits >> n)`
* with FONT_5x7_W).
*
* Mirrors esp32-wifi-fw/main/font_5x7.c so designs look identical whether
* a future plugin is rendered server-side here or fell back to the device.
*/
export const FONT_5x7_W = 5;
export const FONT_5x7_H = 7;
// Each entry: 7 row bytes, low 5 bits = pixel row left -> right.
// Columns shifted to bits [4..0]. We test `bits & (1 << (4 - col))` in canvas.
export const FONT_5x7: number[][] = [
[0,0,0,0,0,0,0], // ' '
[0x04,0x04,0x04,0x04,0x00,0x00,0x04], // '!'
[0x0a,0x0a,0x00,0x00,0x00,0x00,0x00], // '"'
[0x0a,0x1f,0x0a,0x1f,0x0a,0x00,0x00], // '#'
[0x04,0x0f,0x14,0x0e,0x05,0x1e,0x04], // '$'
[0x18,0x19,0x02,0x04,0x08,0x13,0x03], // '%'
[0x08,0x14,0x14,0x08,0x15,0x12,0x0d], // '&'
[0x04,0x04,0x00,0x00,0x00,0x00,0x00], // '''
[0x02,0x04,0x08,0x08,0x08,0x04,0x02], // '('
[0x08,0x04,0x02,0x02,0x02,0x04,0x08], // ')'
[0x00,0x04,0x15,0x0e,0x15,0x04,0x00], // '*'
[0x00,0x04,0x04,0x1f,0x04,0x04,0x00], // '+'
[0x00,0x00,0x00,0x00,0x06,0x06,0x04], // ','
[0x00,0x00,0x00,0x1f,0x00,0x00,0x00], // '-'
[0x00,0x00,0x00,0x00,0x00,0x06,0x06], // '.'
[0x00,0x01,0x02,0x04,0x08,0x10,0x00], // '/'
[0x0e,0x11,0x13,0x15,0x19,0x11,0x0e], // '0'
[0x04,0x0c,0x04,0x04,0x04,0x04,0x0e], // '1'
[0x0e,0x11,0x01,0x02,0x04,0x08,0x1f], // '2'
[0x1f,0x02,0x04,0x02,0x01,0x11,0x0e], // '3'
[0x02,0x06,0x0a,0x12,0x1f,0x02,0x02], // '4'
[0x1f,0x10,0x1e,0x01,0x01,0x11,0x0e], // '5'
[0x06,0x08,0x10,0x1e,0x11,0x11,0x0e], // '6'
[0x1f,0x01,0x02,0x04,0x08,0x08,0x08], // '7'
[0x0e,0x11,0x11,0x0e,0x11,0x11,0x0e], // '8'
[0x0e,0x11,0x11,0x0f,0x01,0x02,0x0c], // '9'
[0x00,0x06,0x06,0x00,0x06,0x06,0x00], // ':'
[0x00,0x06,0x06,0x00,0x06,0x06,0x04], // ';'
[0x02,0x04,0x08,0x10,0x08,0x04,0x02], // '<'
[0x00,0x00,0x1f,0x00,0x1f,0x00,0x00], // '='
[0x08,0x04,0x02,0x01,0x02,0x04,0x08], // '>'
[0x0e,0x11,0x01,0x02,0x04,0x00,0x04], // '?'
[0x0e,0x11,0x17,0x15,0x17,0x10,0x0e], // '@'
[0x0e,0x11,0x11,0x1f,0x11,0x11,0x11], // 'A'
[0x1e,0x11,0x11,0x1e,0x11,0x11,0x1e], // 'B'
[0x0e,0x11,0x10,0x10,0x10,0x11,0x0e], // 'C'
[0x1c,0x12,0x11,0x11,0x11,0x12,0x1c], // 'D'
[0x1f,0x10,0x10,0x1e,0x10,0x10,0x1f], // 'E'
[0x1f,0x10,0x10,0x1e,0x10,0x10,0x10], // 'F'
[0x0e,0x11,0x10,0x17,0x11,0x11,0x0e], // 'G'
[0x11,0x11,0x11,0x1f,0x11,0x11,0x11], // 'H'
[0x0e,0x04,0x04,0x04,0x04,0x04,0x0e], // 'I'
[0x07,0x02,0x02,0x02,0x02,0x12,0x0c], // 'J'
[0x11,0x12,0x14,0x18,0x14,0x12,0x11], // 'K'
[0x10,0x10,0x10,0x10,0x10,0x10,0x1f], // 'L'
[0x11,0x1b,0x15,0x15,0x11,0x11,0x11], // 'M'
[0x11,0x11,0x19,0x15,0x13,0x11,0x11], // 'N'
[0x0e,0x11,0x11,0x11,0x11,0x11,0x0e], // 'O'
[0x1e,0x11,0x11,0x1e,0x10,0x10,0x10], // 'P'
[0x0e,0x11,0x11,0x11,0x15,0x12,0x0d], // 'Q'
[0x1e,0x11,0x11,0x1e,0x14,0x12,0x11], // 'R'
[0x0e,0x11,0x10,0x0e,0x01,0x11,0x0e], // 'S'
[0x1f,0x04,0x04,0x04,0x04,0x04,0x04], // 'T'
[0x11,0x11,0x11,0x11,0x11,0x11,0x0e], // 'U'
[0x11,0x11,0x11,0x11,0x11,0x0a,0x04], // 'V'
[0x11,0x11,0x11,0x15,0x15,0x15,0x0a], // 'W'
[0x11,0x11,0x0a,0x04,0x0a,0x11,0x11], // 'X'
[0x11,0x11,0x11,0x0a,0x04,0x04,0x04], // 'Y'
[0x1f,0x01,0x02,0x04,0x08,0x10,0x1f], // 'Z'
[0x0e,0x08,0x08,0x08,0x08,0x08,0x0e], // '['
[0x00,0x10,0x08,0x04,0x02,0x01,0x00], // '\'
[0x0e,0x02,0x02,0x02,0x02,0x02,0x0e], // ']'
[0x04,0x0a,0x11,0x00,0x00,0x00,0x00], // '^'
[0x00,0x00,0x00,0x00,0x00,0x00,0x1f], // '_'
[0x08,0x04,0x02,0x00,0x00,0x00,0x00], // '`'
[0x00,0x00,0x0e,0x01,0x0f,0x11,0x0f], // 'a'
[0x10,0x10,0x16,0x19,0x11,0x11,0x1e], // 'b'
[0x00,0x00,0x0e,0x10,0x10,0x11,0x0e], // 'c'
[0x01,0x01,0x0d,0x13,0x11,0x11,0x0f], // 'd'
[0x00,0x00,0x0e,0x11,0x1f,0x10,0x0e], // 'e'
[0x06,0x09,0x08,0x1c,0x08,0x08,0x08], // 'f'
[0x00,0x0f,0x11,0x11,0x0f,0x01,0x0e], // 'g'
[0x10,0x10,0x16,0x19,0x11,0x11,0x11], // 'h'
[0x04,0x00,0x0c,0x04,0x04,0x04,0x0e], // 'i'
[0x02,0x00,0x06,0x02,0x02,0x12,0x0c], // 'j'
[0x10,0x10,0x12,0x14,0x18,0x14,0x12], // 'k'
[0x0c,0x04,0x04,0x04,0x04,0x04,0x0e], // 'l'
[0x00,0x00,0x1a,0x15,0x15,0x11,0x11], // 'm'
[0x00,0x00,0x16,0x19,0x11,0x11,0x11], // 'n'
[0x00,0x00,0x0e,0x11,0x11,0x11,0x0e], // 'o'
[0x00,0x1e,0x11,0x11,0x1e,0x10,0x10], // 'p'
[0x00,0x0d,0x13,0x13,0x0d,0x01,0x01], // 'q'
[0x00,0x00,0x16,0x19,0x10,0x10,0x10], // 'r'
[0x00,0x00,0x0f,0x10,0x0e,0x01,0x1e], // 's'
[0x08,0x08,0x1c,0x08,0x08,0x09,0x06], // 't'
[0x00,0x00,0x11,0x11,0x11,0x13,0x0d], // 'u'
[0x00,0x00,0x11,0x11,0x11,0x0a,0x04], // 'v'
[0x00,0x00,0x11,0x11,0x15,0x15,0x0a], // 'w'
[0x00,0x00,0x11,0x0a,0x04,0x0a,0x11], // 'x'
[0x00,0x00,0x11,0x11,0x0f,0x01,0x0e], // 'y'
[0x00,0x00,0x1f,0x02,0x04,0x08,0x1f], // 'z'
[0x02,0x04,0x04,0x08,0x04,0x04,0x02], // '{'
[0x04,0x04,0x04,0x00,0x04,0x04,0x04], // '|'
[0x08,0x04,0x04,0x02,0x04,0x04,0x08], // '}'
[0x09,0x15,0x12,0x00,0x00,0x00,0x00], // '~'
[0,0,0,0,0,0,0], // 0x7f (fallback)
];
+105
View File
@@ -0,0 +1,105 @@
/*
* TagTinker WiFi Plugins - Cloudflare Worker entry.
*
* Endpoints:
*
* GET /plugins
* -> JSON: { plugins: [ {id,name,description,accent_modes,params}, ... ] }
*
* GET /render/:id?w=<int>&h=<int>&accent=<none|red|yellow>&<param>=<val>...
* -> application/octet-stream
* Format (little-endian):
* uint16 width, uint16 height, uint8 planes, uint8 reserved,
* uint16 row_stride,
* plane0 bytes (rowStride * height),
* plane1 bytes (if planes == 2).
*
* The ESP32 simply forwards the byte stream to the Flipper as a
* RESULT_BEGIN + N x RESULT_CHUNK + RESULT_END frame sequence.
*/
import { Plugin, AccentMode } from "./plugin";
import { cryptoPlugin } from "./plugins/crypto";
import { weatherPlugin } from "./plugins/weather";
import { identiconPlugin } from "./plugins/identicon";
const PLUGINS: Plugin[] = [cryptoPlugin, weatherPlugin, identiconPlugin];
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
function errorResponse(message: string, status = 400): Response {
return jsonResponse({ error: message }, status);
}
function parseAccent(s: string | null): AccentMode {
return s === "red" || s === "yellow" ? s : "none";
}
async function handleRender(id: string, url: URL): Promise<Response> {
const plugin = PLUGINS.find((p) => p.manifest.id === id);
if (!plugin) return errorResponse(`unknown plugin '${id}'`, 404);
const w = parseInt(url.searchParams.get("w") ?? "296", 10);
const h = parseInt(url.searchParams.get("h") ?? "128", 10);
if (!(w > 0 && w <= 1024 && h > 0 && h <= 1024)) {
return errorResponse("bad w/h", 400);
}
const accent = parseAccent(url.searchParams.get("accent"));
const params: Record<string, string> = {};
for (const [k, v] of url.searchParams.entries()) {
if (k !== "w" && k !== "h" && k !== "accent") params[k] = v;
}
try {
const c = await plugin.render(params, w, h, accent);
const bytes = c.toBytes();
return new Response(bytes, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Cache-Control": "no-store",
...CORS_HEADERS,
},
});
} catch (e: any) {
return errorResponse(`render failed: ${e?.message ?? e}`, 500);
}
}
export default {
async fetch(req: Request): Promise<Response> {
const url = new URL(req.url);
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
if (url.pathname === "/" || url.pathname === "/health") {
return jsonResponse({
ok: true,
service: "tagtinker-cloud-plugins",
plugins: PLUGINS.length,
});
}
if (url.pathname === "/plugins") {
return jsonResponse({ plugins: PLUGINS.map((p) => p.manifest) });
}
const m = url.pathname.match(/^\/render\/([a-zA-Z0-9_-]+)$/);
if (m) return handleRender(m[1], url);
return errorResponse("not found", 404);
},
};
+43
View File
@@ -0,0 +1,43 @@
/*
* Plugin shape. A plugin exports:
* - manifest: served verbatim from /plugins
* - render(params, w, h, accent): builds a Canvas and returns it
*
* Adding a new plugin = drop a new file in src/plugins/, import + push it
* to PLUGINS in src/index.ts. Cloudflare deploys, Flipper picks it up on
* the next "Refresh Plugins".
*/
import { Canvas } from "./canvas";
export type ParamType = "string" | "int" | "enum" | "bool";
export interface ParamSpec {
key: string;
label: string;
type: ParamType;
default: string;
options?: string[];
min?: number;
max?: number;
}
export const ACCENT_NONE = 0;
export const ACCENT_RED = 1;
export const ACCENT_YELLOW = 2;
export interface PluginManifest {
id: string;
name: string;
description: string;
/** Bitmask: 1 = mono OK, 2 = red, 4 = yellow */
accent_modes: number;
params: ParamSpec[];
}
export type AccentMode = "none" | "red" | "yellow";
export interface Plugin {
manifest: PluginManifest;
render(params: Record<string, string>, w: number, h: number, accent: AccentMode): Promise<Canvas>;
}
+156
View File
@@ -0,0 +1,156 @@
/*
* Crypto Price plugin.
*
* 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).
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
const COIN_MAP: Record<string, string> = {
BTC: "bitcoin", ETH: "ethereum", SOL: "solana", XRP: "ripple",
DOGE: "dogecoin", ADA: "cardano", BNB: "binancecoin", LINK: "chainlink",
};
const RANGE_DAYS: Record<string, number> = { "24H": 1, "7D": 7, "30D": 30 };
const CCY_PREFIX: Record<string, string> = { USD: "$", EUR: "EUR ", GBP: "GBP " };
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);
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 : "");
}
async function fetchPrice(id: string, vs: string): Promise<{ price: number; change: number }> {
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=${vs}&include_24hr_change=true`;
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) throw new Error(`price ${r.status}`);
const j: any = await r.json();
const c = j[id];
if (!c) throw new Error("coin not in response");
const k = vs.toLowerCase();
return { price: c[k] ?? 0, change: c[`${k}_24h_change`] ?? 0 };
}
async function fetchHistory(id: string, vs: string, days: number, samples: number): Promise<number[]> {
const url = `https://api.coingecko.com/api/v3/coins/${id}/market_chart?vs_currency=${vs.toLowerCase()}&days=${days}`;
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) throw new Error(`history ${r.status}`);
const j: any = await r.json();
const arr: [number, number][] = j.prices ?? [];
if (arr.length === 0) return [];
const out: number[] = [];
for (let i = 0; i < samples; i++) {
const idx = Math.floor((i * (arr.length - 1)) / Math.max(1, samples - 1));
out.push(arr[idx][1]);
}
return out;
}
export const cryptoPlugin: Plugin = {
manifest: {
id: "crypto",
name: "Crypto Price",
description: "Live coin price + sparkline",
accent_modes: 1 | 2 | 4,
params: [
{ key: "symbol", label: "Coin", type: "enum", default: "BTC",
options: Object.keys(COIN_MAP) },
{ key: "currency", label: "Currency", type: "enum", default: "USD",
options: Object.keys(CCY_PREFIX) },
{ key: "range", label: "Range", type: "enum", default: "24H",
options: Object.keys(RANGE_DAYS) },
],
},
async render(params, W, H, accent: AccentMode) {
const sym = (params.symbol ?? "BTC").toUpperCase();
const vs = (params.currency ?? "USD").toUpperCase();
const range = (params.range ?? "24H").toUpperCase();
const id = COIN_MAP[sym] ?? "bitcoin";
const days = RANGE_DAYS[range] ?? 1;
const prefix = CCY_PREFIX[vs] ?? "$";
const { price, change } = await fetchPrice(id, vs);
const samples = Math.min(W, 256);
const hist = await fetchHistory(id, vs, days, samples);
const planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
const accentInk: Ink = planes === 2 ? 1 : 0;
const margin = W < 200 ? 4 : 8;
// Header
c.drawText(margin, margin, `${sym} / ${vs}`, 0, 1);
// LIVE badge top-right (accent rectangle).
{
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);
}
// Price headline. Pick scale so it just fits.
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;
// 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.
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);
}
return c;
},
};
+121
View File
@@ -0,0 +1,121 @@
/*
* Identicon plugin.
*
* 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.
*
* ┌──────────────────────┐
* │ PIETER │
* │ ████ ██ ████ │
* │ ██ ██████ ██ │
* │ ██████ ██████ │
* │ ██ ██████ ██ │
* │ ████ ██ ████ │
* │ member since │
* │ 2026 · #A4F1 │
* └──────────────────────────────────────┘
*
* No external API calls - pure compute, instant render.
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
function hash32(s: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
}
return h >>> 0;
}
export const identiconPlugin: Plugin = {
manifest: {
id: "identicon",
name: "Identicon",
description: "Symmetric pixel-art avatar from a name",
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",
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 planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
const accentInk: Ink = planes === 2 ? 1 : 0;
const margin = W < 200 ? 6 : 10;
// 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);
}
}
// 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);
}
}
}
// Accent stripe down the side of the block (signature flair).
c.fillRect(ax + used + 4, ay, 3, used, accentInk);
// 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);
// Border + inner bevel for finish.
c.rect(0, 0, W, H, 0);
c.rect(2, 2, W - 4, H - 4, 0);
return c;
},
};
+180
View File
@@ -0,0 +1,180 @@
/*
* Weather plugin.
*
* Renders a clean, minimalist weather card:
*
* ┌────────────────────────────────────────┐
* │ AMSTERDAM NL │
* │ │
* │ ☀ 12°C │
* │ partly cloudy │
* │ │
* │ Mon 14° / 8° Tue 11° / 6° │
* └────────────────────────────────────────┘
*
* Procedural icons (sun, cloud, rain, snow, storm) drawn with the canvas
* primitives - they scale cleanly to any tag size and look distinctive
* even at the smallest 152x152 panels.
*
* Data source: wttr.in (free, no key, returns JSON when ?format=j1).
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
type Sky = "sun" | "cloud" | "rain" | "snow" | "storm" | "fog";
function classify(code: number): Sky {
// wttr WWO weatherCode mapping to a small icon vocabulary.
if ([113].includes(code)) return "sun";
if ([116, 119, 122].includes(code)) return "cloud";
if ([143, 248, 260].includes(code)) return "fog";
if ([200, 386, 389, 392, 395].includes(code)) return "storm";
if ([179, 227, 230, 320, 323, 326, 329, 332, 335, 338, 350, 368, 371, 374, 377].includes(code))
return "snow";
return "rain";
}
function drawIcon(c: Canvas, sky: Sky, cx: number, cy: number, r: number, accent: Ink): void {
switch (sky) {
case "sun": {
// Filled disc + 8 rays in accent ink.
for (let y = -r; y <= r; y++)
for (let x = -r; x <= r; x++)
if (x * x + y * y <= (r - 1) * (r - 1)) c.setPixel(cx + x, cy + y, accent);
const rr = r + 2, R = r + r;
c.line(cx - R, cy, cx - rr, cy, accent);
c.line(cx + rr, cy, cx + R, cy, accent);
c.line(cx, cy - R, cx, cy - rr, accent);
c.line(cx, cy + rr, cx, cy + R, accent);
c.line(cx - R, cy - R, cx - rr, cy - rr, accent);
c.line(cx + rr, cy - rr, cx + R, cy - R, accent);
c.line(cx - R, cy + R, cx - rr, cy + rr, accent);
c.line(cx + rr, cy + rr, cx + R, cy + R, accent);
break;
}
case "cloud": {
// Three overlapping discs forming a cloud silhouette.
const blob = (ox: number, oy: number, br: number) => {
for (let y = -br; y <= br; y++)
for (let x = -br; x <= br; x++)
if (x * x + y * y <= br * br) c.setPixel(cx + ox + x, cy + oy + y, 0);
};
blob(-r, 2, Math.floor(r * 0.6));
blob(0, -2, Math.floor(r * 0.7));
blob(r - 2, 2, Math.floor(r * 0.6));
c.hline(cx - r, cy + Math.floor(r * 0.6), 2 * r, 0);
break;
}
case "rain":
drawIcon(c, "cloud", cx, cy, r, 0);
for (let i = -r; i <= r; i += 4) {
c.line(cx + i, cy + r + 2, cx + i - 2, cy + r + 6, accent);
}
break;
case "snow":
drawIcon(c, "cloud", cx, cy, r, 0);
for (let i = -r; i <= r; i += 5) {
const yy = cy + r + 4;
c.setPixel(cx + i, yy, accent);
c.setPixel(cx + i - 1, yy + 1, accent);
c.setPixel(cx + i + 1, yy + 1, accent);
c.setPixel(cx + i, yy + 2, accent);
}
break;
case "storm":
drawIcon(c, "cloud", cx, cy, r, 0);
// Lightning bolt
const bx = cx, by = cy + r;
c.line(bx, by, bx + 3, by + 4, accent);
c.line(bx + 3, by + 4, bx - 1, by + 5, accent);
c.line(bx - 1, by + 5, bx + 3, by + 9, accent);
break;
case "fog":
for (let i = 0; i < 4; i++) {
c.hline(cx - r + (i % 2) * 2, cy - r + i * 4, 2 * r - (i % 2) * 4, 0);
}
break;
}
}
async function fetchWeather(loc: string): Promise<any> {
const url = `https://wttr.in/${encodeURIComponent(loc)}?format=j1`;
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) throw new Error(`wttr ${r.status}`);
return await r.json();
}
export const weatherPlugin: Plugin = {
manifest: {
id: "weather",
name: "Weather",
description: "Live weather card with forecast",
accent_modes: 1 | 2 | 4,
params: [
{ key: "location", label: "Location", type: "string", default: "Paris" },
{ key: "units", label: "Units", type: "enum", default: "C", options: ["C", "F"] },
],
},
async render(params, W, H, accent: AccentMode) {
const loc = (params.location ?? "Paris").trim() || "Paris";
/* Defensive: only accept C or F. The Flipper used to (briefly) leak
* stale param values across plugins, which once produced "20°USD". */
const rawUnits = (params.units ?? "C").toUpperCase();
const units = rawUnits === "F" ? "F" : "C";
const data = await fetchWeather(loc);
const cur = data.current_condition?.[0] ?? {};
const code = parseInt(cur.weatherCode ?? "113", 10);
const sky = classify(code);
const tempC = parseFloat(cur.temp_C ?? "0");
const tempF = parseFloat(cur.temp_F ?? "32");
const desc = (cur.weatherDesc?.[0]?.value ?? "").toLowerCase();
const area = data.nearest_area?.[0];
const city = (area?.areaName?.[0]?.value ?? loc).toUpperCase();
const country = area?.country?.[0]?.value ?? "";
const planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
const accentInk: Ink = planes === 2 ? 1 : 0;
const margin = W < 200 ? 4 : 8;
// Header: city + country on right.
c.drawText(margin, margin, city, 0, 1);
c.drawTextRight(W - margin, margin, country.toUpperCase(), 0, 1);
c.hline(margin, margin + 9, W - 2 * margin, 0);
// Big icon + temperature
const iconR = Math.min(20, Math.floor(H / 6));
const ix = margin + iconR + 4;
const iy = margin + 18 + iconR;
drawIcon(c, sky, ix, iy, iconR, accentInk);
const t = `${units === "F" ? Math.round(tempF) : Math.round(tempC)}\xB0${units}`;
let scale = W >= 260 ? 3 : 2;
while (scale > 1 && c.textSize(t, scale).w > W - ix - iconR - margin * 2) scale--;
c.drawText(ix + iconR + 8, iy - Math.floor(c.textSize(t, scale).h / 2), t, 0, scale);
// Description in italics-ish (just plain, but a row under).
c.drawText(margin, iy + iconR + 6, desc, 0, 1);
// 2-day forecast.
const fcs = data.weather?.slice(0, 2) ?? [];
if (fcs.length > 0) {
const baseY = H - margin - 9;
const cellW = Math.floor((W - 2 * margin) / fcs.length);
for (let i = 0; i < fcs.length; i++) {
const f = fcs[i];
const lo = units === "F" ? f.mintempF : f.mintempC;
const hi = units === "F" ? f.maxtempF : f.maxtempC;
const dn = f.date ? new Date(f.date).toLocaleDateString("en-US", { weekday: "short" }) : "";
const txt = `${dn} ${hi}\xB0/${lo}\xB0`;
c.drawText(margin + i * cellW, baseY, txt, 0, 1);
}
}
return c;
},
};