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

+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;
},
};