mirror of
https://github.com/i12bp8/ESLPwn.git
synced 2026-10-29 16:32:27 +00:00
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:
1 parent
8fab06a807
commit
2739069f22
40 files changed
+5310
-1
No files matched your search
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user