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
2026-04-26 20:50:16 +02:00
parent 8fab06a807
commit 2739069f22
40 changed files with 5310 additions and 1 deletions
+10
View File
@@ -7,6 +7,16 @@
dist/
build/
# Node / Cloudflare worker
node_modules/
.wrangler/
# ESP-IDF
esp32-wifi-fw/sdkconfig
esp32-wifi-fw/sdkconfig.old
esp32-wifi-fw/managed_components/
esp32-wifi-fw/dependencies.lock
# ufbt
.ufbt/
+1
View File
@@ -35,6 +35,7 @@ This tool is built for IoT security curiosity, learning about obscure protocols,
- **TagTinker Image Prep (web):** Single-file, dependency-free HTML page that lists every supported tag profile, runs a full image pipeline (tone, contrast, detail, sharpen, dither, photo-grade Oklab 3-colour quantisation) and exports a Flipper-ready BMP. Hosted at **[i12bp8.github.io/TagTinker](https://i12bp8.github.io/TagTinker/)** (source: `web-image-prep/`).
- **Drop-folder image flow:** Drop a prepared BMP into `apps_data/tagtinker/dropped/` on the Flipper SD card, then open `Targeted Payloads → <tag> → Set Image` and pick it. The Flipper rescales any BMP on the fly so a single file can target any tag and any page.
- **NFC Tag Scan:** Instantly identify ESL targets by scanning their NFC tag — no manual barcode entry needed.
- **WiFi Plugins (optional):** Plug a Flipper WiFi Dev Board (ESP32-S2) into the GPIO header to unlock live, network-rendered tag designs — crypto price cards, weather tiles, identicons, and more — auto-discovered by the FAP. New plugins live entirely on the cloud worker; the Flipper firmware never has to be re-flashed to add one.
- Display text, custom images, and test-patterns.
- Support for monochrome and accent-color (red/yellow) graphics tags.
+6 -1
View File
@@ -3,7 +3,7 @@ App(
name="TagTinker",
apptype=FlipperAppType.EXTERNAL,
entry_point="tagtinker_app_main",
requires=["gui", "notification", "dialogs", "storage", "bt", "nfc"],
requires=["gui", "notification", "dialogs", "storage", "bt", "nfc", "expansion"],
stack_size=12 * 1024,
fap_icon="tagtinker_10px.png",
fap_category="Infrared",
@@ -34,5 +34,10 @@ App(
"views/numlock_input.c",
"nfc/tagtinker_nfc.c",
"scenes/tagtinker_scene_nfc_scan.c",
"wifi/tagtinker_wifi.c",
"wifi/tagtinker_wifi_bmp.c",
"scenes/tagtinker_scene_wifi_plugins.c",
"scenes/tagtinker_scene_wifi_setup.c",
"scenes/tagtinker_scene_wifi_run.c",
],
)
+1642
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "tagtinker-cloud-plugins",
"version": "1.0.0",
"private": true,
"description": "Cloudflare Worker that renders TagTinker WiFi plugins server-side.",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240909.0",
"typescript": "^5.5.4",
"wrangler": "^3.78.0"
}
}
+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;
},
};
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
name = "tagtinker"
main = "src/index.ts"
compatibility_date = "2024-09-23"
# `npm run deploy` will publish to <name>.<account-subdomain>.workers.dev.
# Once deployed, point the ESP firmware at the resulting URL via
# the FAP "WiFi Setup -> Server URL" field.
+7
View File
@@ -0,0 +1,7 @@
# TagTinker WiFi firmware - ESP-IDF top-level CMake.
cmake_minimum_required(VERSION 3.16)
set(EXTRA_COMPONENT_DIRS shared)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(tagtinker_wifi)
+21
View File
@@ -0,0 +1,21 @@
# TagTinker WiFi cloud-renderer - thin firmware. All drawing and API
# integration lives in cloud-plugins/ (Cloudflare Worker); this binary
# is just a UART <-> HTTPS bridge.
idf_component_register(
SRCS
"main.c"
"wifi_link.c"
"wifi_net.c"
"cloud_client.c"
INCLUDE_DIRS "."
REQUIRES
nvs_flash
esp_wifi
esp_netif
esp_event
esp_http_client
esp-tls
driver
json
shared
)
+230
View File
@@ -0,0 +1,230 @@
/*
* Cloud client - implementation.
*
* Uses esp_http_client. URL escapes parameters before they're appended to
* the query string. The render path streams the response directly via
* the HTTP_EVENT_ON_DATA callback to avoid buffering the (potentially
* 50+ KB) framebuffer in memory.
*/
#include "cloud_client.h"
#include "esp_http_client.h"
#include "esp_crt_bundle.h"
#include "esp_log.h"
#include "nvs.h"
#include "nvs_flash.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char* TAG = "cloud";
static const char* NVS_NS = "tt_cloud";
static char s_base_url[128] = TT_CLOUD_DEFAULT_URL;
const char* cloud_client_url(void) { return s_base_url; }
void cloud_client_set_url(const char* url) {
if(!url || !*url) return;
strncpy(s_base_url, url, sizeof(s_base_url) - 1);
s_base_url[sizeof(s_base_url) - 1] = 0;
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READWRITE, &h) == ESP_OK) {
nvs_set_str(h, "url", s_base_url);
nvs_commit(h);
nvs_close(h);
}
}
void cloud_client_load(void) {
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READONLY, &h) != ESP_OK) return;
size_t l = sizeof(s_base_url);
if(nvs_get_str(h, "url", s_base_url, &l) != ESP_OK) {
strncpy(s_base_url, TT_CLOUD_DEFAULT_URL, sizeof(s_base_url) - 1);
}
nvs_close(h);
}
/* ---- Tiny URL builder --------------------------------------------------- */
static int hexv(uint8_t b) { return b < 10 ? '0' + b : 'a' + (b - 10); }
static void url_append_escaped(char* dst, size_t cap, size_t* pos, const char* s) {
while(*s && *pos + 4 < cap) {
unsigned char c = (unsigned char)*s++;
bool safe = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') || c == '-' || c == '_' || c == '.';
if(safe) {
dst[(*pos)++] = (char)c;
} else {
dst[(*pos)++] = '%';
dst[(*pos)++] = (char)hexv(c >> 4);
dst[(*pos)++] = (char)hexv(c & 0xF);
}
}
dst[*pos] = 0;
}
/* ---- /plugins ----------------------------------------------------------- */
typedef struct {
char* buf;
size_t cap;
size_t len;
} BodyBuf;
static esp_err_t body_evt(esp_http_client_event_t* e) {
BodyBuf* b = e->user_data;
if(e->event_id != HTTP_EVENT_ON_DATA) return ESP_OK;
size_t take = e->data_len;
if(b->len + take + 1 > b->cap) take = (b->cap > b->len + 1) ? (b->cap - b->len - 1) : 0;
if(take == 0) return ESP_OK;
memcpy(b->buf + b->len, e->data, take);
b->len += take;
b->buf[b->len] = 0;
return ESP_OK;
}
char* cloud_client_fetch_plugins_json(size_t* out_len) {
char url[256];
snprintf(url, sizeof(url), "%s/plugins", s_base_url);
BodyBuf b = { .buf = malloc(8192), .cap = 8192, .len = 0 };
if(!b.buf) return NULL;
b.buf[0] = 0;
esp_http_client_config_t cfg = {
.url = url,
.event_handler = body_evt,
.user_data = &b,
.timeout_ms = 20000,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t c = esp_http_client_init(&cfg);
if(!c) { free(b.buf); return NULL; }
esp_http_client_set_header(c, "User-Agent", "TagTinker-WiFi/2.0");
esp_err_t r = esp_http_client_perform(c);
int code = esp_http_client_get_status_code(c);
esp_http_client_cleanup(c);
if(r != ESP_OK || code != 200) {
ESP_LOGW(TAG, "plugins: err=%d code=%d", r, code);
free(b.buf);
return NULL;
}
if(out_len) *out_len = b.len;
return b.buf;
}
/* ---- /render -- streaming -------------------------------------------- */
bool cloud_client_render(
const char* plugin_id,
uint16_t target_w, uint16_t target_h,
uint8_t accent,
const char* const* keys,
const char* const* values,
uint8_t n_params,
uint16_t* out_w, uint16_t* out_h,
uint8_t* out_planes, uint16_t* out_row_stride,
tt_cloud_chunk_cb chunk_cb, void* user,
char err_msg[64]) {
/* Build URL: <base>/render/<id>?w=<>&h=<>&accent=<>&<k>=<v>... */
char url[768];
size_t pos = 0;
int wrote = snprintf(url, sizeof(url), "%s/render/", s_base_url);
if(wrote < 0 || (size_t)wrote >= sizeof(url)) {
if(err_msg) snprintf(err_msg, 64, "url too long");
return false;
}
pos = (size_t)wrote;
url_append_escaped(url, sizeof(url), &pos, plugin_id);
int wrote2 = snprintf(url + pos, sizeof(url) - pos,
"?w=%u&h=%u&accent=%s",
(unsigned)target_w, (unsigned)target_h,
accent == 1 ? "red" : accent == 2 ? "yellow" : "none");
if(wrote2 < 0) {
if(err_msg) snprintf(err_msg, 64, "url build failed");
return false;
}
pos += (size_t)wrote2;
for(uint8_t i = 0; i < n_params; i++) {
if(pos + 4 >= sizeof(url)) break;
url[pos++] = '&';
url_append_escaped(url, sizeof(url), &pos, keys[i]);
url[pos++] = '=';
url_append_escaped(url, sizeof(url), &pos, values[i]);
}
url[pos] = 0;
/* We use the streaming HTTP API so we can read the 8-byte header
* first (and surface w/h/planes to the caller before forwarding any
* payload), then loop reading body bytes as they arrive. This lets
* the caller emit RESULT_BEGIN at exactly the right moment. */
esp_http_client_config_t cfg = {
.url = url,
.timeout_ms = 25000,
.crt_bundle_attach = esp_crt_bundle_attach,
.buffer_size = 1024,
};
esp_http_client_handle_t c = esp_http_client_init(&cfg);
if(!c) { if(err_msg) snprintf(err_msg, 64, "client init"); return false; }
esp_http_client_set_header(c, "User-Agent", "TagTinker-WiFi/2.0");
esp_err_t r = esp_http_client_open(c, 0);
if(r != ESP_OK) {
esp_http_client_cleanup(c);
if(err_msg) snprintf(err_msg, 64, "open %d", r);
return false;
}
int64_t total = esp_http_client_fetch_headers(c);
int code = esp_http_client_get_status_code(c);
if(code != 200) {
esp_http_client_close(c);
esp_http_client_cleanup(c);
if(err_msg) snprintf(err_msg, 64, "http %d", code);
return false;
}
(void)total;
/* Read the 8-byte header. */
uint8_t hdr[8]; size_t got = 0;
while(got < 8) {
int n = esp_http_client_read(c, (char*)hdr + got, 8 - got);
if(n <= 0) break;
got += (size_t)n;
}
if(got < 8) {
esp_http_client_close(c); esp_http_client_cleanup(c);
if(err_msg) snprintf(err_msg, 64, "short header");
return false;
}
uint16_t W = (uint16_t)hdr[0] | ((uint16_t)hdr[1] << 8);
uint16_t H = (uint16_t)hdr[2] | ((uint16_t)hdr[3] << 8);
uint8_t P = hdr[4];
uint16_t RS = (uint16_t)hdr[6] | ((uint16_t)hdr[7] << 8);
if(out_w) *out_w = W;
if(out_h) *out_h = H;
if(out_planes) *out_planes = P;
if(out_row_stride) *out_row_stride = RS;
/* Stream the rest in <= 512-byte chunks. */
uint8_t chunk[512];
while(true) {
int n = esp_http_client_read(c, (char*)chunk, sizeof(chunk));
if(n < 0) break;
if(n == 0) {
if(esp_http_client_is_complete_data_received(c)) break;
continue;
}
if(chunk_cb) chunk_cb(chunk, (uint16_t)n, user);
}
esp_http_client_close(c);
esp_http_client_cleanup(c);
return true;
}
+54
View File
@@ -0,0 +1,54 @@
/*
* Cloud plugin client.
*
* Talks to the TagTinker Cloudflare Worker that hosts plugin manifests
* and renders. Default URL is hard-coded below; override at runtime by
* calling cloud_client_set_url() (the value is persisted in NVS).
*
* GET <base>/plugins -> JSON manifest list
* GET <base>/render/<id>?...-> binary framebuffer (see worker docs)
*
* The framebuffer header (8 bytes, little-endian) is:
* uint16 width, uint16 height, uint8 planes, uint8 reserved,
* uint16 row_stride
* followed by `row_stride * height * planes` bytes of pixel data.
*/
#ifndef TT_CLOUD_CLIENT_H
#define TT_CLOUD_CLIENT_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#define TT_CLOUD_DEFAULT_URL "https://tagtinker.jhackerr.workers.dev"
/* Persisted base URL (no trailing slash). */
const char* cloud_client_url(void);
void cloud_client_set_url(const char* url);
void cloud_client_load(void); /* call once after nvs_flash_init */
/* Forwarded JSON for /plugins. Caller frees with free(). NULL on failure. */
char* cloud_client_fetch_plugins_json(size_t* out_len);
/* Streaming /render call.
*
* Returns true on 200 OK. The header (width, height, planes, row_stride)
* is filled in before any chunk_cb is invoked. chunk_cb is then called
* one or more times with consecutive plane bytes (chunk_len <= 1024).
* Plane 0 is delivered first, then plane 1 (if planes==2). */
typedef void (*tt_cloud_chunk_cb)(const uint8_t* data, uint16_t len, void* user);
bool cloud_client_render(
const char* plugin_id,
uint16_t target_w, uint16_t target_h,
uint8_t accent, /* 0=none, 1=red, 2=yellow */
const char* const* keys,
const char* const* values,
uint8_t n_params,
/* Output header: */
uint16_t* out_w, uint16_t* out_h,
uint8_t* out_planes, uint16_t* out_row_stride,
tt_cloud_chunk_cb chunk_cb, void* user,
char err_msg[64]);
#endif /* TT_CLOUD_CLIENT_H */
+354
View File
@@ -0,0 +1,354 @@
/*
* TagTinker WiFi firmware - thin cloud-renderer.
*
* The ESP no longer carries plugins, fonts, drawing primitives or HTTP
* APIs for individual data sources. Instead it acts as a small bridge:
*
* Flipper LIST_PLUGINS -> GET <cloud>/plugins -> forward N x PLUGIN
* Flipper RUN_PLUGIN -> GET <cloud>/render/<id>?...-> forward as
* RESULT_BEGIN + N x CHUNK + RESULT_END.
*
* Plugins live in the Cloudflare Worker at the URL printed during
* `wrangler deploy`; updating plugins doesn't require re-flashing.
*
* A minimal cJSON-based parser converts the worker's /plugins JSON into
* the framed-protocol PLUGIN frames the FAP already understands, so the
* Flipper-side code is unchanged.
*/
#include "tt_wifi_proto.h"
#include "wifi_link.h"
#include "wifi_net.h"
#include "cloud_client.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "cJSON.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
static const char* TAG = "main";
/* ---- Encoding helpers --------------------------------------------------- */
typedef struct { uint8_t* p; uint16_t cap; uint16_t len; } Wb;
static void wb_init(Wb* b, uint8_t* buf, uint16_t cap) { b->p = buf; b->cap = cap; b->len = 0; }
static void wb_u8 (Wb* b, uint8_t v) { if(b->len < b->cap) b->p[b->len++] = v; }
static void wb_u16(Wb* b, uint16_t v) { wb_u8(b, v & 0xFF); wb_u8(b, v >> 8); }
static void wb_u32(Wb* b, uint32_t v) { wb_u16(b, v); wb_u16(b, v >> 16); }
static void wb_i32(Wb* b, int32_t v) { wb_u32(b, (uint32_t)v); }
static void wb_zstr(Wb* b, const char* s) {
if(!s) s = "";
size_t l = strlen(s);
if(l > 255) l = 255;
wb_u8(b, (uint8_t)l);
for(size_t i = 0; i < l && b->len < b->cap; i++) b->p[b->len++] = (uint8_t)s[i];
}
/* ---- Decoding helpers --------------------------------------------------- */
typedef struct { const uint8_t* p; uint16_t len; uint16_t pos; } Rb;
static void rb_init(Rb* r, const uint8_t* p, uint16_t len) { r->p = p; r->len = len; r->pos = 0; }
static bool rb_u8 (Rb* r, uint8_t* v) { if(r->pos + 1 > r->len) return false; *v = r->p[r->pos++]; return true; }
static bool rb_u16(Rb* r, uint16_t* v) { uint8_t a,b; if(!rb_u8(r,&a)||!rb_u8(r,&b)) return false; *v = (uint16_t)a | ((uint16_t)b << 8); return true; }
static bool rb_zstr(Rb* r, char* out, size_t cap) {
uint8_t l; if(!rb_u8(r, &l)) return false;
if(r->pos + l > r->len) return false;
size_t n = (l < cap - 1) ? l : cap - 1;
memcpy(out, r->p + r->pos, n);
out[n] = 0;
r->pos += l;
return true;
}
/* ---- HELLO / STATUS ---------------------------------------------------- */
static void send_hello(void) {
uint8_t buf[80]; Wb w; wb_init(&w, buf, sizeof(buf));
wb_u16(&w, 0x0200); /* fw version 2.0 (cloud) */
wb_u32(&w, esp_get_free_heap_size());
wb_zstr(&w, "TagTinker WiFi");
wifi_link_send(TT_FRAME_HELLO, buf, w.len);
}
static void send_wifi_status(void) {
uint8_t buf[80]; Wb w; wb_init(&w, buf, sizeof(buf));
wb_u8(&w, wifi_net_state());
wb_u8(&w, (uint8_t)(int8_t)wifi_net_rssi());
wb_zstr(&w, wifi_net_ssid());
wb_zstr(&w, wifi_net_ip());
wifi_link_send(TT_FRAME_WIFI_STATUS, buf, w.len);
}
/* ---- Cached plugins JSON --------------------------------------------- */
/* Filled by handle_list_plugins() and reused by handle_run_plugin() so we
* don't have to do a fresh TLS handshake just to translate plugin index
* back into id - critical on the S2 where we can barely fit one TLS
* session at a time. */
static cJSON* s_cached_root = NULL;
static const cJSON* s_cached_arr = NULL;
static void cached_set(cJSON* root) {
if(s_cached_root) { cJSON_Delete(s_cached_root); }
s_cached_root = root;
s_cached_arr = root ? cJSON_GetObjectItemCaseSensitive(root, "plugins") : NULL;
}
/* ---- /plugins JSON -> PLUGIN frames ----------------------------------- */
/* type tags from the worker -> wire type IDs the FAP expects. */
static uint8_t param_type_from(const char* t) {
if(!t) return 0;
if(strcmp(t, "string") == 0) return 0;
if(strcmp(t, "int") == 0) return 1;
if(strcmp(t, "enum") == 0) return 2;
if(strcmp(t, "bool") == 0) return 3;
return 0;
}
static void emit_plugin_from_json(int idx, const cJSON* p) {
uint8_t buf[TT_FRAME_MAX_PAYLOAD]; Wb w; wb_init(&w, buf, sizeof(buf));
wb_u8(&w, (uint8_t)idx);
const cJSON* id = cJSON_GetObjectItemCaseSensitive(p, "id");
const cJSON* name = cJSON_GetObjectItemCaseSensitive(p, "name");
const cJSON* desc = cJSON_GetObjectItemCaseSensitive(p, "description");
const cJSON* acc = cJSON_GetObjectItemCaseSensitive(p, "accent_modes");
const cJSON* params = cJSON_GetObjectItemCaseSensitive(p, "params");
wb_zstr(&w, cJSON_IsString(id) ? id->valuestring : "");
wb_zstr(&w, cJSON_IsString(name) ? name->valuestring : "");
wb_zstr(&w, cJSON_IsString(desc) ? desc->valuestring : "");
wb_u8 (&w, (uint8_t)(cJSON_IsNumber(acc) ? acc->valueint : 1));
int pc = cJSON_IsArray(params) ? cJSON_GetArraySize(params) : 0;
if(pc > 6) pc = 6;
wb_u8(&w, (uint8_t)pc);
for(int i = 0; i < pc; i++) {
const cJSON* sp = cJSON_GetArrayItem(params, i);
const cJSON* k = cJSON_GetObjectItemCaseSensitive(sp, "key");
const cJSON* l = cJSON_GetObjectItemCaseSensitive(sp, "label");
const cJSON* tt = cJSON_GetObjectItemCaseSensitive(sp, "type");
const cJSON* dv = cJSON_GetObjectItemCaseSensitive(sp, "default");
const cJSON* opts = cJSON_GetObjectItemCaseSensitive(sp, "options");
const cJSON* mn = cJSON_GetObjectItemCaseSensitive(sp, "min");
const cJSON* mx = cJSON_GetObjectItemCaseSensitive(sp, "max");
wb_zstr(&w, cJSON_IsString(k) ? k->valuestring : "");
wb_zstr(&w, cJSON_IsString(l) ? l->valuestring : "");
uint8_t pt = param_type_from(cJSON_IsString(tt) ? tt->valuestring : "string");
wb_u8(&w, pt);
wb_zstr(&w, cJSON_IsString(dv) ? dv->valuestring : "");
if(pt == 2) {
int oc = cJSON_IsArray(opts) ? cJSON_GetArraySize(opts) : 0;
if(oc > 8) oc = 8;
wb_u8(&w, (uint8_t)oc);
for(int j = 0; j < oc; j++) {
const cJSON* o = cJSON_GetArrayItem(opts, j);
wb_zstr(&w, cJSON_IsString(o) ? o->valuestring : "");
}
} else if(pt == 1) {
wb_i32(&w, cJSON_IsNumber(mn) ? mn->valueint : 0);
wb_i32(&w, cJSON_IsNumber(mx) ? mx->valueint : 100);
}
}
wifi_link_send(TT_FRAME_PLUGIN, buf, w.len);
}
static void handle_list_plugins(void) {
if(!wifi_net_wait_connected(8000)) {
wifi_link_send_error("WiFi not connected");
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
return;
}
size_t n = 0;
char* body = cloud_client_fetch_plugins_json(&n);
if(!body) {
wifi_link_send_error("plugin fetch failed");
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
return;
}
cJSON* root = cJSON_Parse(body);
free(body);
if(!root) {
wifi_link_send_error("plugin JSON parse failed");
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
return;
}
cached_set(root);
int total = cJSON_IsArray(s_cached_arr) ? cJSON_GetArraySize(s_cached_arr) : 0;
for(int i = 0; i < total && i < 16; i++) {
emit_plugin_from_json(i, cJSON_GetArrayItem(s_cached_arr, i));
}
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
}
/* ---- /render -- plugin run -------------------------------------------- */
/* Shared state for the render flow: cloud_client fills the dims first,
* then drives our chunk_cb which emits RESULT_BEGIN on its first call,
* then RESULT_CHUNKs, then RESULT_END after the call returns. */
typedef struct {
bool begin_sent;
uint16_t* out_w;
uint16_t* out_h;
uint8_t* out_planes;
uint16_t* out_rs;
uint32_t total_bytes;
uint32_t recv_bytes;
uint8_t last_pct_emitted;
} RenderFwd;
static void render_chunk_cb(const uint8_t* data, uint16_t len, void* user) {
RenderFwd* f = user;
if(!f->begin_sent) {
uint8_t hdr[16]; uint16_t off = 0;
uint16_t w = *f->out_w, h = *f->out_h;
uint8_t p = *f->out_planes;
uint16_t rs = *f->out_rs;
hdr[off++] = (uint8_t)(w & 0xFF); hdr[off++] = (uint8_t)(w >> 8);
hdr[off++] = (uint8_t)(h & 0xFF); hdr[off++] = (uint8_t)(h >> 8);
hdr[off++] = p;
uint32_t total = (uint32_t)rs * h * p;
hdr[off++] = (uint8_t)(total & 0xFF);
hdr[off++] = (uint8_t)((total >> 8) & 0xFF);
hdr[off++] = (uint8_t)((total >> 16) & 0xFF);
hdr[off++] = (uint8_t)((total >> 24) & 0xFF);
wifi_link_send(TT_FRAME_RESULT_BEGIN, hdr, off);
f->begin_sent = true;
f->total_bytes = total;
f->recv_bytes = 0;
f->last_pct_emitted = 50;
wifi_link_send_progress(50, "Receiving image");
}
while(len > 0) {
uint16_t take = len > 512 ? 512 : len;
wifi_link_send(TT_FRAME_RESULT_CHUNK, data, take);
data += take; len -= take;
f->recv_bytes += take;
}
/* 50..95% during streaming. Emit at most one progress frame per 10%
* boundary - sending one after every chunk doubled the frame count
* and starved the Flipper's UART RX during the burst, which was
* dropping tail bytes (see tagtinker_wifi.c history). */
if(f->total_bytes) {
uint32_t pct = 50 + (f->recv_bytes * 45) / f->total_bytes;
if(pct > 95) pct = 95;
if(pct >= f->last_pct_emitted + 10 || (pct >= 95 && f->last_pct_emitted < 95)) {
wifi_link_send_progress((uint8_t)pct, "Receiving image");
f->last_pct_emitted = (uint8_t)pct;
}
}
}
static void handle_run_plugin(const uint8_t* payload, uint16_t len) {
Rb r; rb_init(&r, payload, len);
char id[32];
uint8_t accent = 0, n_params = 0;
uint16_t target_w = 0, target_h = 0;
uint8_t plugin_idx = 0;
if(!rb_u8(&r, &plugin_idx)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u16(&r, &target_w)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u16(&r, &target_h)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u8(&r, &accent)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u8(&r, &n_params)) { wifi_link_send_error("bad RUN frame"); return; }
/* Use the manifest cached during the most recent /plugins call - this
* avoids a second TLS handshake and the associated memory pressure. */
if(!s_cached_arr) {
wifi_link_send_error("no plugin cache (refresh first)"); return;
}
const cJSON* p = cJSON_GetArrayItem(s_cached_arr, plugin_idx);
const cJSON* idj = p ? cJSON_GetObjectItemCaseSensitive(p, "id") : NULL;
if(!cJSON_IsString(idj)) {
wifi_link_send_error("bad plugin index"); return;
}
strncpy(id, idj->valuestring, sizeof(id) - 1);
id[sizeof(id) - 1] = 0;
/* Read params into parallel arrays for cloud_client_render. */
static char keys[6][32];
static char vals[6][96];
const char* k_ptrs[6];
const char* v_ptrs[6];
if(n_params > 6) n_params = 6;
for(uint8_t i = 0; i < n_params; i++) {
if(!rb_zstr(&r, keys[i], sizeof(keys[i])) ||
!rb_zstr(&r, vals[i], sizeof(vals[i]))) {
wifi_link_send_error("bad param"); return;
}
k_ptrs[i] = keys[i];
v_ptrs[i] = vals[i];
}
if(!wifi_net_wait_connected(8000)) {
wifi_link_send_error("WiFi not connected"); return;
}
wifi_link_send_progress(15, "Connecting to cloud");
uint16_t rw = 0, rh = 0, rs = 0; uint8_t rp = 0;
RenderFwd fwd = {
.begin_sent = false,
.out_w = &rw, .out_h = &rh, .out_planes = &rp, .out_rs = &rs,
};
char err[64] = {0};
bool ok = cloud_client_render(
id, target_w, target_h, accent,
k_ptrs, v_ptrs, n_params,
&rw, &rh, &rp, &rs,
render_chunk_cb, &fwd, err);
if(!ok) {
wifi_link_send_error(err[0] ? err : "render failed");
return;
}
if(!fwd.begin_sent) {
wifi_link_send_error("empty body");
return;
}
wifi_link_send(TT_FRAME_RESULT_END, NULL, 0);
wifi_link_send_progress(100, "Done");
}
/* ---- WIFI_SET / WIFI_FORGET ------------------------------------------- */
static void handle_wifi_set(const uint8_t* payload, uint16_t len) {
Rb r; rb_init(&r, payload, len);
char ssid[33], pwd[65];
if(!rb_zstr(&r, ssid, sizeof(ssid)) || !rb_zstr(&r, pwd, sizeof(pwd))) {
wifi_link_send_error("bad WIFI_SET"); return;
}
wifi_net_set_creds(ssid, pwd);
send_wifi_status();
}
/* ---- Main RX dispatch --------------------------------------------------- */
static void on_frame(uint8_t type, const uint8_t* payload, uint16_t len, void* user) {
(void)user;
switch(type) {
case TT_FRAME_PING: wifi_link_send(TT_FRAME_PING, NULL, 0); break;
case TT_FRAME_WIFI_SET: handle_wifi_set(payload, len); break;
case TT_FRAME_WIFI_FORGET: wifi_net_forget(); send_wifi_status(); break;
case TT_FRAME_WIFI_STATUS: send_wifi_status(); break;
case TT_FRAME_LIST_PLUGINS: handle_list_plugins(); break;
case TT_FRAME_RUN_PLUGIN: handle_run_plugin(payload, len); break;
default: ESP_LOGW(TAG, "unhandled type 0x%02X", type); break;
}
}
void app_main(void) {
ESP_LOGI(TAG, "TagTinker cloud-renderer booting");
wifi_net_init();
cloud_client_load();
wifi_link_init(on_frame, NULL);
vTaskDelay(pdMS_TO_TICKS(200));
send_hello();
while(1) {
vTaskDelay(pdMS_TO_TICKS(2000));
send_wifi_status();
}
}
+187
View File
@@ -0,0 +1,187 @@
/*
* Framed UART link - implementation.
*
* UART config: UART1 by default on the Flipper WiFi Dev Board. The numbers
* are picked to match the FAP-side defaults; if you change them on one side
* you must change them on the other.
*
* pin TX = GPIO17, pin RX = GPIO18, baud 230400, no flow control.
*
* RX runs in a dedicated task that re-syncs on the 0xAA 0x55 SOF whenever
* a CRC mismatch or oversize frame is seen.
*/
#include "wifi_link.h"
#include "tt_wifi_proto.h"
#include "driver/uart.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"
#include <string.h>
#include <stdio.h>
/* The Flipper Wi-Fi Devboard wires the ESP32-S2's UART0 (default
* IO_MUX pins U0TXD=GPIO43, U0RXD=GPIO44) to the Flipper. We claim
* UART0 for our framed binary protocol; the IDF console is silenced
* via CONFIG_ESP_CONSOLE_NONE=y so the two never collide. */
#define LINK_UART_NUM UART_NUM_0
#define LINK_PIN_TX UART_PIN_NO_CHANGE
#define LINK_PIN_RX UART_PIN_NO_CHANGE
#define LINK_BAUD 230400
#define LINK_RXBUF 4096
#define LINK_TXBUF 1024
static const char* TAG = "link";
static WifiLinkRxFn s_rx_cb;
static void* s_rx_user;
static SemaphoreHandle_t s_tx_lock;
bool wifi_link_send(uint8_t type, const uint8_t* payload, uint16_t len) {
if(len > TT_FRAME_MAX_PAYLOAD) {
ESP_LOGE(TAG, "tx: payload %u over limit", (unsigned)len);
return false;
}
/* Frame buffer: 2(SOF) + 1(type) + 2(len) + payload + 2(crc). */
uint8_t hdr[5];
hdr[0] = TT_FRAME_SOF0;
hdr[1] = TT_FRAME_SOF1;
hdr[2] = type;
hdr[3] = (uint8_t)(len & 0xFFU);
hdr[4] = (uint8_t)(len >> 8);
/* CRC over [type, len_lo, len_hi, payload...]. */
uint16_t crc = 0xFFFFU;
{
for(int i = 2; i < 5; i++) {
crc ^= (uint16_t)hdr[i] << 8;
for(int b = 0; b < 8; b++)
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U) : (uint16_t)(crc << 1);
}
for(uint16_t i = 0; i < len; i++) {
crc ^= (uint16_t)payload[i] << 8;
for(int b = 0; b < 8; b++)
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U) : (uint16_t)(crc << 1);
}
}
uint8_t tail[2] = { (uint8_t)(crc >> 8), (uint8_t)(crc & 0xFFU) };
xSemaphoreTake(s_tx_lock, portMAX_DELAY);
int ok = uart_write_bytes(LINK_UART_NUM, (const char*)hdr, sizeof(hdr));
if(ok > 0 && len) ok = uart_write_bytes(LINK_UART_NUM, (const char*)payload, len);
if(ok > 0) ok = uart_write_bytes(LINK_UART_NUM, (const char*)tail, sizeof(tail));
xSemaphoreGive(s_tx_lock);
return ok > 0;
}
bool wifi_link_send_progress(uint8_t percent, const char* msg) {
uint8_t buf[1 + 1 + 64];
if(!msg) msg = "";
size_t mlen = strnlen(msg, sizeof(buf) - 2);
buf[0] = percent;
buf[1] = (uint8_t)mlen;
memcpy(&buf[2], msg, mlen);
return wifi_link_send(TT_FRAME_PROGRESS, buf, (uint16_t)(2 + mlen));
}
bool wifi_link_send_error(const char* msg) {
uint8_t buf[1 + 96];
if(!msg) msg = "";
size_t mlen = strnlen(msg, sizeof(buf) - 1);
buf[0] = (uint8_t)mlen;
memcpy(&buf[1], msg, mlen);
return wifi_link_send(TT_FRAME_ERROR, buf, (uint16_t)(1 + mlen));
}
/* ---- RX task ----------------------------------------------------------- */
static inline uint16_t crc16_step(uint16_t crc, uint8_t b) {
crc ^= (uint16_t)b << 8;
for(int i = 0; i < 8; i++)
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U)
: (uint16_t)(crc << 1);
return crc;
}
static void rx_task(void* arg) {
(void)arg;
enum { S_SOF0, S_SOF1, S_TYPE, S_LEN_LO, S_LEN_HI, S_PAYLOAD, S_CRC_HI, S_CRC_LO } st = S_SOF0;
uint8_t type = 0;
uint16_t len = 0, idx = 0;
uint16_t crc_calc = 0xFFFFU;
uint16_t crc_recv = 0;
static uint8_t payload[TT_FRAME_MAX_PAYLOAD];
#define crc_step(B) (crc_calc = crc16_step(crc_calc, (B)))
while(1) {
uint8_t b;
int n = uart_read_bytes(LINK_UART_NUM, &b, 1, pdMS_TO_TICKS(1000));
if(n != 1) continue;
switch(st) {
case S_SOF0:
if(b == TT_FRAME_SOF0) st = S_SOF1;
break;
case S_SOF1:
st = (b == TT_FRAME_SOF1) ? S_TYPE : S_SOF0;
break;
case S_TYPE:
type = b; crc_calc = 0xFFFFU; crc_step(b); st = S_LEN_LO;
break;
case S_LEN_LO:
len = b; crc_step(b); st = S_LEN_HI;
break;
case S_LEN_HI:
len |= (uint16_t)b << 8;
crc_step(b);
if(len > TT_FRAME_MAX_PAYLOAD) { st = S_SOF0; break; }
idx = 0;
st = (len == 0) ? S_CRC_HI : S_PAYLOAD;
break;
case S_PAYLOAD:
payload[idx++] = b; crc_step(b);
if(idx >= len) st = S_CRC_HI;
break;
case S_CRC_HI:
crc_recv = (uint16_t)b << 8; st = S_CRC_LO;
break;
case S_CRC_LO:
crc_recv |= b;
if(crc_recv == crc_calc) {
if(s_rx_cb) s_rx_cb(type, payload, len, s_rx_user);
} else {
ESP_LOGW(TAG, "CRC mismatch type=0x%02X len=%u", type, len);
}
st = S_SOF0;
break;
}
}
}
void wifi_link_init(WifiLinkRxFn cb, void* user) {
s_rx_cb = cb;
s_rx_user = user;
s_tx_lock = xSemaphoreCreateMutex();
uart_config_t cfg = {
.baud_rate = LINK_BAUD,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
uart_driver_install(LINK_UART_NUM, LINK_RXBUF, LINK_TXBUF, 0, NULL, 0);
uart_param_config(LINK_UART_NUM, &cfg);
uart_set_pin(LINK_UART_NUM, LINK_PIN_TX, LINK_PIN_RX, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
/* RX task also runs the frame dispatch + cloud_client_render(). The
* mbedTLS handshake allocates ~6-8 KB on the stack during ECDHE +
* cert chain validation, so 16 KB gives a comfortable margin. The
* old 4 KB stack silently overflowed and locked the task. */
xTaskCreate(rx_task, "link_rx", 16384, NULL, 6, NULL);
}
+27
View File
@@ -0,0 +1,27 @@
/*
* Framed UART link to the Flipper.
*
* The link is a simple two-way pipe of TT_FRAME_* records (see
* shared/tt_wifi_proto.h). All TX is funnelled through wifi_link_send_*; all
* RX is delivered to a callback registered with wifi_link_set_handler().
*/
#ifndef TT_WIFI_LINK_H
#define TT_WIFI_LINK_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
typedef void (*WifiLinkRxFn)(uint8_t type, const uint8_t* payload, uint16_t len, void* user);
void wifi_link_init(WifiLinkRxFn cb, void* user);
/* Send a fully-formed frame. Returns false if the payload is oversized or
* the UART driver couldn't accept it. */
bool wifi_link_send(uint8_t type, const uint8_t* payload, uint16_t len);
/* Convenience helpers for the most common frames. */
bool wifi_link_send_progress(uint8_t percent, const char* msg);
bool wifi_link_send_error (const char* msg);
#endif /* TT_WIFI_LINK_H */
+141
View File
@@ -0,0 +1,141 @@
/*
* WiFi station - implementation.
*/
#include "wifi_net.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "freertos/task.h"
#include <string.h>
static const char* TAG = "net";
static const char* NVS_NS = "tt_wifi";
static EventGroupHandle_t s_eg;
#define EV_CONNECTED (1U << 0)
#define EV_FAIL (1U << 1)
static char s_ssid[33];
static char s_pwd[65];
static char s_ip[16];
static int8_t s_rssi = 0;
static uint8_t s_state = TT_WIFI_DISCONNECTED;
uint8_t wifi_net_state(void) { return s_state; }
int8_t wifi_net_rssi(void) { return s_rssi; }
const char* wifi_net_ssid(void) { return s_ssid; }
const char* wifi_net_ip(void) { return s_ip; }
static void load_creds_from_nvs(void) {
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READONLY, &h) != ESP_OK) return;
size_t l = sizeof(s_ssid);
if(nvs_get_str(h, "ssid", s_ssid, &l) != ESP_OK) s_ssid[0] = 0;
l = sizeof(s_pwd);
if(nvs_get_str(h, "pwd", s_pwd, &l) != ESP_OK) s_pwd[0] = 0;
nvs_close(h);
}
static void save_creds_to_nvs(void) {
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READWRITE, &h) != ESP_OK) return;
nvs_set_str(h, "ssid", s_ssid);
nvs_set_str(h, "pwd", s_pwd);
nvs_commit(h);
nvs_close(h);
}
static void wifi_event_handler(void* arg, esp_event_base_t base, int32_t id, void* data) {
(void)arg;
if(base == WIFI_EVENT) {
switch(id) {
case WIFI_EVENT_STA_START:
s_state = TT_WIFI_CONNECTING;
esp_wifi_connect();
break;
case WIFI_EVENT_STA_DISCONNECTED: {
wifi_event_sta_disconnected_t* d = data;
ESP_LOGW(TAG, "disconnected reason=%d", d->reason);
if(d->reason == WIFI_REASON_NO_AP_FOUND) s_state = TT_WIFI_NO_AP;
else if(d->reason == WIFI_REASON_AUTH_FAIL ||
d->reason == WIFI_REASON_HANDSHAKE_TIMEOUT) s_state = TT_WIFI_AUTH_FAILED;
else s_state = TT_WIFI_DISCONNECTED;
xEventGroupSetBits(s_eg, EV_FAIL);
esp_wifi_connect();
break;
}
}
} else if(base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t* g = data;
snprintf(s_ip, sizeof(s_ip), IPSTR, IP2STR(&g->ip_info.ip));
s_state = TT_WIFI_CONNECTED;
xEventGroupSetBits(s_eg, EV_CONNECTED);
}
}
static void try_connect(void) {
if(!s_ssid[0]) return;
wifi_config_t cfg = {0};
strncpy((char*)cfg.sta.ssid, s_ssid, sizeof(cfg.sta.ssid) - 1);
strncpy((char*)cfg.sta.password, s_pwd, sizeof(cfg.sta.password) - 1);
cfg.sta.threshold.authmode = WIFI_AUTH_OPEN;
cfg.sta.pmf_cfg.capable = true;
esp_wifi_set_config(WIFI_IF_STA, &cfg);
esp_wifi_disconnect();
esp_wifi_connect();
}
void wifi_net_init(void) {
s_eg = xEventGroupCreate();
esp_err_t r = nvs_flash_init();
if(r == ESP_ERR_NVS_NO_FREE_PAGES || r == ESP_ERR_NVS_NEW_VERSION_FOUND) {
nvs_flash_erase(); nvs_flash_init();
}
esp_netif_init();
esp_event_loop_create_default();
esp_netif_create_default_wifi_sta();
wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT();
esp_wifi_init(&init);
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL);
esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL);
esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_set_storage(WIFI_STORAGE_RAM);
load_creds_from_nvs();
esp_wifi_start();
if(s_ssid[0]) try_connect();
}
void wifi_net_set_creds(const char* ssid, const char* pwd) {
strncpy(s_ssid, ssid ? ssid : "", sizeof(s_ssid) - 1);
strncpy(s_pwd, pwd ? pwd : "", sizeof(s_pwd) - 1);
save_creds_to_nvs();
try_connect();
}
void wifi_net_forget(void) {
s_ssid[0] = 0; s_pwd[0] = 0;
save_creds_to_nvs();
esp_wifi_disconnect();
s_state = TT_WIFI_DISCONNECTED;
}
bool wifi_net_wait_connected(uint32_t timeout_ms) {
if(s_state == TT_WIFI_CONNECTED) return true;
EventBits_t bits = xEventGroupWaitBits(
s_eg, EV_CONNECTED, pdFALSE, pdFALSE, pdMS_TO_TICKS(timeout_ms));
return (bits & EV_CONNECTED) != 0;
}
+31
View File
@@ -0,0 +1,31 @@
/*
* WiFi station + NVS-backed credential storage.
*
* Credentials live under NVS namespace "tt_wifi", keys "ssid" / "pwd".
* wifi_net_init() reads them and tries to connect; if missing, the radio
* stays parked until wifi_net_set_creds() is called by the Flipper.
*/
#ifndef TT_WIFI_NET_H
#define TT_WIFI_NET_H
#include <stdbool.h>
#include <stdint.h>
#include "tt_wifi_proto.h"
void wifi_net_init(void);
/* Returns the current state. The lower 4 bits map to TT_WIFI_*. */
uint8_t wifi_net_state(void);
int8_t wifi_net_rssi (void);
const char* wifi_net_ssid(void);
const char* wifi_net_ip (void);
/* Persists creds, drops the current AP, reconnects. */
void wifi_net_set_creds(const char* ssid, const char* pwd);
void wifi_net_forget(void);
/* Block (with timeout) until connected. Returns true on success. */
bool wifi_net_wait_connected(uint32_t timeout_ms);
#endif /* TT_WIFI_NET_H */
+21
View File
@@ -0,0 +1,21 @@
# TagTinker WiFi firmware - partition table aligned to the hardcoded
# offsets the Flipper "ESP Flasher" app writes to. From its source
# (esp_flasher_worker.h):
#
# ESP_ADDR_BOOT = 0x01000 "Bootloader"
# ESP_ADDR_PART = 0x08000 "Partition Table"
# ESP_ADDR_NVS = 0x09000 "NVS"
# ESP_ADDR_BOOT_APP0 = 0x0E000 "boot_app0" (== otadata)
# ESP_ADDR_APP_A = 0x10000 "Firmware A" (== ota_0)
# ESP_ADDR_APP_B = 0x150000 "Firmware B" (== ota_1)
#
# Because ota_1 must live at 0x150000, we need at least 4 MB of flash;
# the Flipper Wi-Fi Devboard's ESP32-S2-WROVER carries 4 MB so this fits.
# Each app slot is 1.25 MB which leaves plenty of headroom over our
# ~744 KB image.
#
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
ota_0, app, ota_0, 0x10000, 0x140000,
ota_1, app, ota_1, 0x150000, 0x140000,
Can't render this file because it contains an unexpected character in line 2 and column 23.
+46
View File
@@ -0,0 +1,46 @@
# TagTinker WiFi firmware - ESP-IDF defaults.
# Targets the Flipper Wi-Fi Devboard (ESP32-S2-MINI; no PSRAM).
# Bigger stack for the main task: TLS handshake + JSON parse needs room.
CONFIG_ESP_MAIN_TASK_STACK_SIZE=12288
# UART0 is owned by our framed protocol to the Flipper. The IDF console
# is moved to USB-Serial-JTAG (the dev board's USB-C) so logs are visible
# during development without colliding with our binary frames.
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_BOOTLOADER_LOG_LEVEL_NONE=y
CONFIG_BOOTLOADER_LOG_LEVEL=0
# Stack overflow detection: panic instead of silently wedging when a task
# blows its stack (we hit this on the link_rx task during mbedTLS).
CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY=y
CONFIG_FREERTOS_WATCHPOINT_END_OF_STACK=y
# WiFi tuning - we don't need the full feature matrix.
CONFIG_ESP32_WIFI_STATIC_RX_BUFFER_NUM=8
CONFIG_ESP32_WIFI_DYNAMIC_RX_BUFFER_NUM=16
CONFIG_ESP32_WIFI_DYNAMIC_TX_BUFFER_NUM=16
# mbedTLS: the small "common subset" Mozilla bundle (~20 CAs incl. the
# Cloudflare DigiCert + ISRG roots), with stock buffer sizes so we don't
# choke on large TLS records. Hardware AES/SHA accel keeps the handshake
# fast. We deliberately leave SSL_IN/OUT_CONTENT_LEN at defaults (16 KB).
CONFIG_MBEDTLS_HARDWARE_AES=y
CONFIG_MBEDTLS_HARDWARE_SHA=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y
# Use our custom OTA-capable partition table at the offsets the Flipper
# "ESP Flasher" app expects (boot=0x1000, part=0x8000, app0=0xE000,
# firmware A=0x10000, firmware B=0x150000).
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
# We don't include a phy_init partition (no room next to the ESP Flasher
# fixed offsets); use the embedded default calibration data.
CONFIG_ESP_PHY_INIT_DATA_IN_PARTITION=n
+4
View File
@@ -0,0 +1,4 @@
idf_component_register(
SRCS ""
INCLUDE_DIRS "."
)
+130
View File
@@ -0,0 +1,130 @@
/*
* TagTinker WiFi - Flipper <-> ESP32 framed UART protocol.
*
* This header is shared verbatim between the FAP and the ESP32 firmware so
* frame layouts only need to be edited in one place.
*
* Frame on the wire:
*
* +------+------+------+--------+----------+--------+
* | 0xAA | 0x55 | TYPE | LEN_LE | PAYLOAD | CRC16 |
* +------+------+------+--------+----------+--------+
* 1B 1B 1B 2B LEN B 2B
*
* - TYPE: one of TT_FRAME_* below.
* - LEN_LE: little-endian payload length (0..16383).
* - CRC16: CRC-16/CCITT-FALSE over TYPE..end-of-PAYLOAD.
*
* Direction in comments: F->E = Flipper to ESP, E->F = ESP to Flipper.
*
* String encoding: zstrings are length-prefixed (u8 len, then bytes). NUL
* terminator is *not* included on the wire. Empty strings are "\0\0".
*/
#ifndef TT_WIFI_PROTO_H
#define TT_WIFI_PROTO_H
#include <stdint.h>
#define TT_FRAME_SOF0 0xAAU
#define TT_FRAME_SOF1 0x55U
/* Maximum payload size we'll allocate buffers for on either side. Keep this
* comfortably under the ESP's RX buffer; image data is chunked into
* RESULT_CHUNK frames so this only bounds control traffic. */
#define TT_FRAME_MAX_PAYLOAD 1024U
/* Frame types -------------------------------------------------------------- */
enum {
/* Handshake / status. */
TT_FRAME_HELLO = 0x01, /* E->F: u16 fw_ver, u32 free_heap, zstring fw_name */
TT_FRAME_PING = 0x02, /* F->E: empty | E->F: empty (echo) */
/* WiFi config. */
TT_FRAME_WIFI_SET = 0x10, /* F->E: zstring ssid, zstring password */
TT_FRAME_WIFI_FORGET = 0x11, /* F->E: empty (clears NVS creds) */
TT_FRAME_WIFI_STATUS = 0x12, /* either way:
* u8 state (TT_WIFI_*),
* i8 rssi,
* zstring ssid,
* zstring ip */
/* Plugin discovery / execution. */
TT_FRAME_LIST_PLUGINS = 0x20, /* F->E: empty */
TT_FRAME_PLUGIN = 0x21, /* E->F: one frame per plugin (see below) */
TT_FRAME_PLUGINS_END = 0x22, /* E->F: end-of-list sentinel */
TT_FRAME_RUN_PLUGIN = 0x30, /* F->E: see TT_RUN_PLUGIN layout below */
TT_FRAME_PROGRESS = 0x31, /* E->F: u8 percent, zstring message */
TT_FRAME_RESULT_BEGIN = 0x32, /* E->F: u16 width, u16 height, u8 planes (1|2),
* u32 total_bytes */
TT_FRAME_RESULT_CHUNK = 0x33, /* E->F: raw plane bytes */
TT_FRAME_RESULT_END = 0x34, /* E->F: empty - all chunks delivered */
TT_FRAME_ERROR = 0x3F, /* E->F: zstring message */
};
/* WiFi state codes (TT_FRAME_WIFI_STATUS payload byte 0). */
enum {
TT_WIFI_DISCONNECTED = 0,
TT_WIFI_CONNECTING = 1,
TT_WIFI_CONNECTED = 2,
TT_WIFI_AUTH_FAILED = 3,
TT_WIFI_NO_AP = 4,
};
/*
* TT_FRAME_PLUGIN payload layout
* ------------------------------
* u8 plugin_index
* zstr id (short stable id, e.g. "crypto")
* zstr name (display name, e.g. "Crypto Price")
* zstr description (one-liner shown on the run screen)
* u8 accent_modes (bitmask: 1=mono, 2=red, 4=yellow)
* u8 param_count
* repeated param_count times:
* zstr key (machine name, e.g. "symbol")
* zstr label (display name, e.g. "Symbol")
* u8 type (TT_PARAM_*)
* zstr default_value (always a string; client parses per type)
* if type == TT_PARAM_ENUM:
* u8 option_count
* repeated option_count times: zstr option
* if type == TT_PARAM_INT:
* i32_le min
* i32_le max
*/
enum {
TT_PARAM_STRING = 0,
TT_PARAM_INT = 1,
TT_PARAM_ENUM = 2,
TT_PARAM_BOOL = 3,
};
/* TT_FRAME_RUN_PLUGIN payload layout
* ---------------------------------
* u8 plugin_index
* u16 target_w
* u16 target_h
* u8 accent (TT_ACCENT_*)
* u8 param_count
* repeated: zstr key, zstr value (string-encoded, the plugin parses)
*/
enum {
TT_ACCENT_NONE = 0, /* mono tag */
TT_ACCENT_RED = 1,
TT_ACCENT_YELLOW = 2,
};
/* CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xor-out).
* Tiny, branchless, no table - fine for the small frames we send. */
static inline uint16_t tt_crc16(const uint8_t* data, uint32_t len) {
uint16_t crc = 0xFFFFU;
for(uint32_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i] << 8;
for(int b = 0; b < 8; b++) {
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U) : (uint16_t)(crc << 1);
}
}
return crc;
}
#endif /* TT_WIFI_PROTO_H */
+9
View File
@@ -22,6 +22,9 @@ void(*const tagtinker_scene_on_enter_handlers[])(void*) = {
tagtinker_scene_about_on_enter,
tagtinker_scene_text_box_on_enter,
tagtinker_scene_nfc_scan_on_enter,
tagtinker_scene_wifi_plugins_on_enter,
tagtinker_scene_wifi_setup_on_enter,
tagtinker_scene_wifi_run_on_enter,
};
bool(*const tagtinker_scene_on_event_handlers[])(void*, SceneManagerEvent) = {
@@ -42,6 +45,9 @@ bool(*const tagtinker_scene_on_event_handlers[])(void*, SceneManagerEvent) = {
tagtinker_scene_about_on_event,
tagtinker_scene_text_box_on_event,
tagtinker_scene_nfc_scan_on_event,
tagtinker_scene_wifi_plugins_on_event,
tagtinker_scene_wifi_setup_on_event,
tagtinker_scene_wifi_run_on_event,
};
void(*const tagtinker_scene_on_exit_handlers[])(void*) = {
@@ -62,6 +68,9 @@ void(*const tagtinker_scene_on_exit_handlers[])(void*) = {
tagtinker_scene_about_on_exit,
tagtinker_scene_text_box_on_exit,
tagtinker_scene_nfc_scan_on_exit,
tagtinker_scene_wifi_plugins_on_exit,
tagtinker_scene_wifi_setup_on_exit,
tagtinker_scene_wifi_run_on_exit,
};
const SceneManagerHandlers tagtinker_scene_handlers = {
+15
View File
@@ -24,6 +24,9 @@ typedef enum {
TagTinkerSceneAbout,
TagTinkerSceneTextBox,
TagTinkerSceneNfcScan,
TagTinkerSceneWifiPlugins,
TagTinkerSceneWifiSetup,
TagTinkerSceneWifiRun,
TagTinkerSceneCount,
} TagTinkerScene;
@@ -94,3 +97,15 @@ void tagtinker_scene_text_box_on_exit(void* ctx);
void tagtinker_scene_nfc_scan_on_enter(void* ctx);
bool tagtinker_scene_nfc_scan_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_nfc_scan_on_exit(void* ctx);
void tagtinker_scene_wifi_plugins_on_enter(void* ctx);
bool tagtinker_scene_wifi_plugins_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_wifi_plugins_on_exit(void* ctx);
void tagtinker_scene_wifi_setup_on_enter(void* ctx);
bool tagtinker_scene_wifi_setup_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_wifi_setup_on_exit(void* ctx);
void tagtinker_scene_wifi_run_on_enter(void* ctx);
bool tagtinker_scene_wifi_run_on_event(void* ctx, SceneManagerEvent event);
void tagtinker_scene_wifi_run_on_exit(void* ctx);
+5
View File
@@ -71,6 +71,7 @@ void tagtinker_scene_target_actions_on_enter(void* ctx) {
if(allow_graphics) {
submenu_add_item(app->submenu, "Set Text", TagTinkerTargetPushText, target_actions_cb, app);
submenu_add_item(app->submenu, "Set Image", TagTinkerTargetPushSyncedImage, target_actions_cb, app);
submenu_add_item(app->submenu, "WiFi Plugins", TagTinkerTargetWifiPlugins, target_actions_cb, app);
}
submenu_add_item(app->submenu, "LED Test", TagTinkerTargetPingFlash, target_actions_cb, app);
@@ -100,6 +101,10 @@ bool tagtinker_scene_target_actions_on_event(void* ctx, SceneManagerEvent event)
if(!tagtinker_target_supports_graphics(&app->targets[app->selected_target])) return true;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneSyncedImageList);
return true;
case TagTinkerTargetWifiPlugins:
if(!tagtinker_target_supports_graphics(&app->targets[app->selected_target])) return true;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneWifiPlugins);
return true;
case TagTinkerTargetPingFlash:
{
TagTinkerTarget* target = &app->targets[app->selected_target];
+191
View File
@@ -0,0 +1,191 @@
/*
* WiFi Plugins
* ============
*
* Top-level scene that:
* 1. Lazy-allocates the TagTinkerWifi link and opens the UART.
* 2. Asks the ESP for its plugin list (LIST_PLUGINS).
* 3. Renders a submenu of plugins, plus persistent header rows for
* "WiFi Setup" and "Forget WiFi" so the user can manage credentials
* without leaving the page.
* 4. Routes to the Run scene when a plugin is picked.
*
* Events from TagTinkerWifi land on the FAP main thread via
* view_dispatcher custom events (we marshal via the message queue rather
* than touching submenu* directly from the worker thread, which is not
* thread-safe).
*/
#include "../tagtinker_app.h"
#include "../wifi/tagtinker_wifi.h"
#include <string.h>
#include <stdio.h>
#define EVT_PLUGIN_BASE 0x100u
#define EVT_WIFI_SETUP 0x001u
#define EVT_WIFI_FORGET 0x002u
#define EVT_WIFI_REFRESH 0x003u
#define EVT_LINK_LIST_DONE 0x200u
#define EVT_LINK_STATUS 0x201u
#define EVT_LINK_LOST 0x202u
#define EVT_LINK_HELLO 0x203u
static TagTinkerWifiPlugin* plugin_array(TagTinkerApp* app) {
return (TagTinkerWifiPlugin*)app->wifi_plugins;
}
static void wifi_plugins_event_cb(const TtWifiEvent* e, void* user) {
TagTinkerApp* app = user;
switch(e->type) {
case TtWifiEvtHello:
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_LINK_HELLO);
break;
case TtWifiEvtWifiStatus:
app->wifi_link_state = (uint8_t)e->u0;
app->wifi_rssi = (int8_t)e->i1;
strncpy(app->wifi_ssid, e->str0 ? e->str0 : "", sizeof(app->wifi_ssid) - 1);
strncpy(app->wifi_ip, e->str1 ? e->str1 : "", sizeof(app->wifi_ip) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_LINK_STATUS);
break;
case TtWifiEvtPlugin:
if(app->wifi_plugin_count < 16U && e->plugin) {
plugin_array(app)[app->wifi_plugin_count++] = *e->plugin;
}
break;
case TtWifiEvtPluginsEnd:
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_LINK_LIST_DONE);
break;
case TtWifiEvtLinkLost:
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_LINK_LOST);
break;
default: break; /* progress/result/error are handled by the run scene */
}
}
static void wifi_plugins_submenu_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, index);
}
/* Update only the header (status badge) without rebuilding the submenu,
* so the periodic 2s WIFI_STATUS push doesn't kick the cursor back to
* the top entry every time. */
static void refresh_header(TagTinkerApp* app) {
char hdr[40];
const char* badge = "...";
switch(app->wifi_link_state) {
case TT_WIFI_DISCONNECTED: badge = "off"; break;
case TT_WIFI_CONNECTING: badge = "..."; break;
case TT_WIFI_CONNECTED: badge = "OK"; break;
case TT_WIFI_AUTH_FAILED: badge = "auth!"; break;
case TT_WIFI_NO_AP: badge = "no AP"; break;
}
snprintf(hdr, sizeof(hdr), "WiFi Plugins [%s]", badge);
submenu_set_header(app->submenu, hdr);
}
static void rebuild_submenu(TagTinkerApp* app) {
/* Preserve the current cursor position across rebuilds. */
uint32_t saved = submenu_get_selected_item(app->submenu);
submenu_reset(app->submenu);
refresh_header(app);
if(app->wifi_plugin_count == 0) {
const char* placeholder = app->wifi_plugins_loading
? "Loading plugins..."
: "(no plugins yet)";
submenu_add_item(app->submenu, placeholder, EVT_WIFI_REFRESH,
wifi_plugins_submenu_cb, app);
} else {
for(uint8_t i = 0; i < app->wifi_plugin_count; i++) {
const TagTinkerWifiPlugin* p = &plugin_array(app)[i];
submenu_add_item(app->submenu, p->name, EVT_PLUGIN_BASE + i,
wifi_plugins_submenu_cb, app);
}
}
submenu_add_item(app->submenu, "WiFi Setup", EVT_WIFI_SETUP,
wifi_plugins_submenu_cb, app);
submenu_add_item(app->submenu, "Forget WiFi", EVT_WIFI_FORGET,
wifi_plugins_submenu_cb, app);
submenu_add_item(app->submenu, "Refresh Plugins", EVT_WIFI_REFRESH,
wifi_plugins_submenu_cb, app);
submenu_set_selected_item(app->submenu, saved);
}
void tagtinker_scene_wifi_plugins_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
/* Lazy-allocate the link + plugin cache the first time we enter. */
if(!app->wifi) {
app->wifi_plugins = malloc(sizeof(TagTinkerWifiPlugin) * 16);
memset(app->wifi_plugins, 0, sizeof(TagTinkerWifiPlugin) * 16);
app->wifi = tagtinker_wifi_alloc(wifi_plugins_event_cb, app);
}
if(!tagtinker_wifi_open((TagTinkerWifi*)app->wifi)) {
/* UART couldn't be acquired - rare unless another app holds it. */
app->wifi_link_state = TT_WIFI_DISCONNECTED;
}
/* Always re-query plugins on entry; the ESP may have been re-flashed. */
app->wifi_plugin_count = 0;
app->wifi_plugins_loading = true;
rebuild_submenu(app);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu);
tagtinker_wifi_query_status((TagTinkerWifi*)app->wifi);
tagtinker_wifi_list_plugins((TagTinkerWifi*)app->wifi);
}
bool tagtinker_scene_wifi_plugins_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
if(event.event == EVT_WIFI_SETUP) {
scene_manager_next_scene(app->scene_manager, TagTinkerSceneWifiSetup);
return true;
}
if(event.event == EVT_WIFI_FORGET) {
tagtinker_wifi_forget((TagTinkerWifi*)app->wifi);
app->wifi_link_state = TT_WIFI_DISCONNECTED;
rebuild_submenu(app);
return true;
}
if(event.event == EVT_WIFI_REFRESH) {
app->wifi_plugin_count = 0;
app->wifi_plugins_loading = true;
rebuild_submenu(app);
tagtinker_wifi_list_plugins((TagTinkerWifi*)app->wifi);
return true;
}
if(event.event == EVT_LINK_STATUS) {
/* Lightweight: only refresh the status badge, keep the cursor put. */
refresh_header(app);
return true;
}
if(event.event == EVT_LINK_LIST_DONE || event.event == EVT_LINK_HELLO ||
event.event == EVT_LINK_LOST) {
app->wifi_plugins_loading = false;
rebuild_submenu(app);
return true;
}
if(event.event >= EVT_PLUGIN_BASE && event.event < EVT_PLUGIN_BASE + 16U) {
uint8_t idx = (uint8_t)(event.event - EVT_PLUGIN_BASE);
if(idx < app->wifi_plugin_count) {
app->wifi_selected_plugin = (int8_t)idx;
scene_manager_next_scene(app->scene_manager, TagTinkerSceneWifiRun);
return true;
}
}
return false;
}
void tagtinker_scene_wifi_plugins_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
submenu_reset(app->submenu);
/* Keep the UART open while we stay inside the WiFi flow. The link is
* closed in the app's free path or when leaving the WiFi area entirely
* (the run scene calls back into us, so don't close on every exit). */
}
+391
View File
@@ -0,0 +1,391 @@
/*
* WiFi Run
* ========
*
* 1. Renders a VariableItemList of the selected plugin's parameters.
* - Enum : cycle through options.
* - Bool : Off/On toggle.
* - Int : numeric range.
* - String: tap to open text_input, write back into wifi_param_values.
* 2. Adds a "Generate" item at the bottom that:
* - Picks a target (defaults to the currently-selected target;
* if none, asks the user via the popup).
* - Sends RUN_PLUGIN to the ESP.
* - Switches to a Popup view that streams progress updates.
* - On RESULT_END, writes a BMP and chains to the existing transmit
* scene via tagtinker_prepare_bmp_tx().
* - On ERROR, shows the message in the popup.
*/
#include "../tagtinker_app.h"
#include "../wifi/tagtinker_wifi.h"
#include "../wifi/tagtinker_wifi_bmp.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#define EVT_GENERATE 0xD0u
#define EVT_PARAM_STRING 0xD1u
#define EVT_TEXT_DONE 0xD2u
#define EVT_PROGRESS 0xD3u
#define EVT_ERROR 0xD4u
#define EVT_RESULT_DONE 0xD5u
/* Per-scene state held in the app to avoid statics. */
static TagTinkerWifiBmpWriter s_bmp_writer;
static int8_t s_string_param_being_edited = -1;
static TagTinkerWifiPlugin* current_plugin(TagTinkerApp* app) {
if(app->wifi_selected_plugin < 0) return NULL;
TagTinkerWifiPlugin* arr = (TagTinkerWifiPlugin*)app->wifi_plugins;
return &arr[app->wifi_selected_plugin];
}
/* ---- Variable-item callbacks --------------------------------------------*/
/* Because VariableItem doesn't directly expose row index in its callback,
* we encode the param index in the high byte of the variable item's
* `current_value_index` when a callback fires - we re-pack it elsewhere.
* Simpler: we maintain a parallel array of the items we created, in order,
* and use variable_item_set_current_value_text to update the displayed text.
* The scenes module provides no easier way; this approach is minimal. */
static VariableItem* s_param_items[6];
static void item_changed_enum(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
/* Locate the param index for this item by matching pointer in s_param_items. */
for(uint8_t i = 0; i < p->param_count; i++) {
if(s_param_items[i] != item) continue;
const TtWifiParam* sp = &p->params[i];
uint8_t idx = variable_item_get_current_value_index(item);
if(idx >= sp->option_count) idx = 0;
const char* opt = sp->options[idx];
variable_item_set_current_value_text(item, opt);
strncpy(app->wifi_param_values[i], opt, sizeof(app->wifi_param_values[i]) - 1);
app->wifi_param_values[i][sizeof(app->wifi_param_values[i]) - 1] = 0;
break;
}
}
static void item_changed_bool(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
for(uint8_t i = 0; i < p->param_count; i++) {
if(s_param_items[i] != item) continue;
uint8_t idx = variable_item_get_current_value_index(item);
variable_item_set_current_value_text(item, idx ? "On" : "Off");
strcpy(app->wifi_param_values[i], idx ? "1" : "0");
break;
}
}
static void item_changed_int(VariableItem* item) {
TagTinkerApp* app = variable_item_get_context(item);
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
for(uint8_t i = 0; i < p->param_count; i++) {
if(s_param_items[i] != item) continue;
const TtWifiParam* sp = &p->params[i];
int32_t v = sp->int_min + variable_item_get_current_value_index(item);
char buf[16]; snprintf(buf, sizeof(buf), "%ld", (long)v);
variable_item_set_current_value_text(item, buf);
strncpy(app->wifi_param_values[i], buf, sizeof(app->wifi_param_values[i]) - 1);
break;
}
}
static void item_enter_cb(void* ctx, uint32_t index) {
TagTinkerApp* app = ctx;
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
/* The very last item is "Generate"; any string-param item opens the
* text_input view. */
if(index < p->param_count) {
const TtWifiParam* sp = &p->params[index];
if(sp->type != TT_PARAM_STRING) return;
s_string_param_being_edited = (int8_t)index;
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_PARAM_STRING);
} else {
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_GENERATE);
}
}
/* ---- Build the param list ---------------------------------------------- */
static void seed_param_value(TagTinkerApp* app, const TtWifiParam* sp, uint8_t i) {
/* If we already have a value (e.g. text_input edit), keep it. The
* scene's on_enter wipes the slots fresh per plugin to prevent the
* shared array leaking values across plugin selections. */
if(app->wifi_param_values[i][0] != 0) return;
strncpy(app->wifi_param_values[i], sp->default_value,
sizeof(app->wifi_param_values[i]) - 1);
app->wifi_param_values[i][sizeof(app->wifi_param_values[i]) - 1] = 0;
}
static void build_param_list(TagTinkerApp* app) {
VariableItemList* list = app->var_item_list;
variable_item_list_reset(list);
memset(s_param_items, 0, sizeof(s_param_items));
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
for(uint8_t i = 0; i < p->param_count; i++) {
const TtWifiParam* sp = &p->params[i];
seed_param_value(app, sp, i);
VariableItem* it = NULL;
if(sp->type == TT_PARAM_ENUM) {
it = variable_item_list_add(list, sp->label, sp->option_count,
item_changed_enum, app);
/* Default-select the option matching the seeded value. */
uint8_t sel = 0;
for(uint8_t j = 0; j < sp->option_count; j++) {
if(strcmp(app->wifi_param_values[i], sp->options[j]) == 0) {
sel = j; break;
}
}
variable_item_set_current_value_index(it, sel);
variable_item_set_current_value_text(it, sp->options[sel]);
} else if(sp->type == TT_PARAM_BOOL) {
it = variable_item_list_add(list, sp->label, 2, item_changed_bool, app);
uint8_t sel = (app->wifi_param_values[i][0] == '1') ? 1 : 0;
variable_item_set_current_value_index(it, sel);
variable_item_set_current_value_text(it, sel ? "On" : "Off");
} else if(sp->type == TT_PARAM_INT) {
int32_t range = sp->int_max - sp->int_min + 1;
if(range <= 0 || range > 100) range = 1;
it = variable_item_list_add(list, sp->label, (uint8_t)range,
item_changed_int, app);
int32_t cur = atoi(app->wifi_param_values[i]);
if(cur < sp->int_min) cur = sp->int_min;
uint8_t idx = (uint8_t)(cur - sp->int_min);
variable_item_set_current_value_index(it, idx);
char buf[16]; snprintf(buf, sizeof(buf), "%ld", (long)cur);
variable_item_set_current_value_text(it, buf);
} else {
/* String: clickable, opens text_input. */
it = variable_item_list_add(list, sp->label, 1, NULL, app);
variable_item_set_current_value_text(it,
app->wifi_param_values[i][0] ? app->wifi_param_values[i] : "(set)");
}
s_param_items[i] = it;
}
variable_item_list_add(list, ">> Generate <<", 0, NULL, app);
variable_item_list_set_enter_callback(list, item_enter_cb, app);
}
/* ---- Text input for STRING params -------------------------------------- */
static char s_text_buf[64];
static void text_done_cb(void* ctx) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_TEXT_DONE);
}
static void open_text_input_for_param(TagTinkerApp* app, uint8_t i) {
text_input_reset(app->text_input);
TagTinkerWifiPlugin* p = current_plugin(app);
text_input_set_header_text(app->text_input, p->params[i].label);
strncpy(s_text_buf, app->wifi_param_values[i], sizeof(s_text_buf) - 1);
s_text_buf[sizeof(s_text_buf) - 1] = 0;
text_input_set_result_callback(
app->text_input, text_done_cb, app, s_text_buf, sizeof(s_text_buf), false);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTextInput);
}
/* ---- Run + result handling --------------------------------------------- */
/* The wifi_plugins scene installed its own callback; we hot-swap it on
* scene enter and restore on exit. */
static TtWifiEventCb s_prev_cb;
static void* s_prev_user;
static void run_event_cb(const TtWifiEvent* e, void* user) {
TagTinkerApp* app = user;
switch(e->type) {
case TtWifiEvtProgress:
app->wifi_progress_pct = (uint8_t)e->u0;
strncpy(app->wifi_progress_msg, e->str0 ? e->str0 : "",
sizeof(app->wifi_progress_msg) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_PROGRESS);
break;
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)) {
strncpy(app->wifi_last_error, "BMP open failed",
sizeof(app->wifi_last_error) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_ERROR);
}
break;
}
case TtWifiEvtResultChunk:
tagtinker_wifi_bmp_chunk(&s_bmp_writer, e->data, e->data_len);
break;
case TtWifiEvtResultEnd:
if(!tagtinker_wifi_bmp_close(&s_bmp_writer)) {
strncpy(app->wifi_last_error, "BMP write failed",
sizeof(app->wifi_last_error) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_ERROR);
} else {
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_RESULT_DONE);
}
break;
case TtWifiEvtError:
strncpy(app->wifi_last_error, e->str0 ? e->str0 : "Unknown error",
sizeof(app->wifi_last_error) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_ERROR);
break;
case TtWifiEvtLinkLost:
strncpy(app->wifi_last_error, "Dev board went silent",
sizeof(app->wifi_last_error) - 1);
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_ERROR);
break;
default:
/* Hello/status/plugin events still useful: forward to the previous
* callback so the plugin-list scene can refresh on return. */
if(s_prev_cb) s_prev_cb(e, s_prev_user);
break;
}
}
static void show_progress_popup(TagTinkerApp* app) {
popup_reset(app->popup);
popup_set_header(app->popup, "WiFi Plugin", 64, 6, AlignCenter, AlignTop);
char body[120];
snprintf(body, sizeof(body), "%u%%\n%s", app->wifi_progress_pct,
app->wifi_progress_msg);
popup_set_text(app->popup, body, 64, 32, AlignCenter, AlignCenter);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewPopup);
}
static void show_error_popup(TagTinkerApp* app) {
popup_reset(app->popup);
popup_set_header(app->popup, "Error", 64, 6, AlignCenter, AlignTop);
popup_set_text(app->popup, app->wifi_last_error, 64, 32, AlignCenter, AlignCenter);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewPopup);
}
static void start_run(TagTinkerApp* app) {
TagTinkerWifiPlugin* p = current_plugin(app);
if(!p) return;
/* Need a target to pick the canvas size. Default to selected_target;
* 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. */
uint8_t accent = TT_ACCENT_NONE;
TtWifiKV kv[6];
uint8_t n = 0;
for(uint8_t i = 0; i < p->param_count && i < 6; i++) {
kv[n].key = p->params[i].key;
kv[n].value = app->wifi_param_values[i];
n++;
}
app->wifi_progress_pct = 0;
snprintf(app->wifi_progress_msg, sizeof(app->wifi_progress_msg), "Starting...");
app->wifi_last_error[0] = 0;
app->wifi_run_in_flight = true;
show_progress_popup(app);
tagtinker_wifi_run_plugin((TagTinkerWifi*)app->wifi,
p->index, tw, th, accent, kv, n);
}
/* ---- Scene entry / event ---------------------------------------------- */
void tagtinker_scene_wifi_run_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
s_string_param_being_edited = -1;
/* Reset the shared param-value array so the new plugin starts fresh
* with its own defaults (otherwise e.g. Crypto's "BTC" leaks into
* Weather's "Location" slot). */
memset(app->wifi_param_values, 0, sizeof(app->wifi_param_values));
/* Hot-swap the WiFi callback so progress/result frames land here.
* The previous callback (the plugins scene's) is restored on exit. */
if(app->wifi) {
tagtinker_wifi_set_callback(
(TagTinkerWifi*)app->wifi, run_event_cb, app,
&s_prev_cb, &s_prev_user);
}
build_param_list(app);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList);
}
bool tagtinker_scene_wifi_run_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
switch(event.event) {
case EVT_PARAM_STRING:
if(s_string_param_being_edited >= 0)
open_text_input_for_param(app, (uint8_t)s_string_param_being_edited);
return true;
case EVT_TEXT_DONE: {
if(s_string_param_being_edited >= 0) {
uint8_t i = (uint8_t)s_string_param_being_edited;
strncpy(app->wifi_param_values[i], s_text_buf,
sizeof(app->wifi_param_values[i]) - 1);
app->wifi_param_values[i][sizeof(app->wifi_param_values[i]) - 1] = 0;
}
s_string_param_being_edited = -1;
build_param_list(app);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList);
return true;
}
case EVT_GENERATE:
start_run(app);
return true;
case EVT_PROGRESS:
if(app->wifi_run_in_flight) show_progress_popup(app);
return true;
case EVT_ERROR:
app->wifi_run_in_flight = false;
tagtinker_wifi_bmp_abort(&s_bmp_writer);
show_error_popup(app);
return true;
case EVT_RESULT_DONE: {
app->wifi_run_in_flight = false;
/* Hand the BMP to the existing TX path. */
if(app->selected_target < 0 || app->selected_target >= app->target_count) {
strncpy(app->wifi_last_error,
"No saved tags - scan one in Targeted Payloads first",
sizeof(app->wifi_last_error) - 1);
show_error_popup(app);
return true;
}
const TagTinkerTarget* t = &app->targets[app->selected_target];
tagtinker_prepare_bmp_tx(app, t->plid, TAGTINKER_WIFI_TMP_BMP,
app->esl_width, app->esl_height, app->img_page);
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
return true;
}
}
return false;
}
void tagtinker_scene_wifi_run_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
/* Restore the plugins-scene callback. */
if(app->wifi && s_prev_cb) {
tagtinker_wifi_set_callback(
(TagTinkerWifi*)app->wifi, s_prev_cb, s_prev_user, NULL, NULL);
s_prev_cb = NULL; s_prev_user = NULL;
}
variable_item_list_reset(app->var_item_list);
popup_reset(app->popup);
text_input_reset(app->text_input);
}
+79
View File
@@ -0,0 +1,79 @@
/*
* WiFi Setup
* ==========
*
* Two-step text input: SSID first, then password. State machine lives in
* the scene_state field of the scene manager so we can re-enter cleanly
* after the text_input view returns.
*
* state=0 -> prompt SSID
* state=1 -> prompt password
* state=2 -> sent, return to plugins scene
*/
#include "../tagtinker_app.h"
#include "../wifi/tagtinker_wifi.h"
#include <string.h>
#define EVT_TEXT_DONE 0xC1u
static void text_done_cb(void* ctx) {
TagTinkerApp* app = ctx;
view_dispatcher_send_custom_event(app->view_dispatcher, EVT_TEXT_DONE);
}
static void prompt_ssid(TagTinkerApp* app) {
text_input_reset(app->text_input);
text_input_set_header_text(app->text_input, "WiFi SSID");
/* Reuse cached creds so re-entering doesn't blank the field. */
strncpy(app->wifi_creds_ssid, app->wifi_ssid, sizeof(app->wifi_creds_ssid) - 1);
app->wifi_creds_ssid[sizeof(app->wifi_creds_ssid) - 1] = 0;
text_input_set_result_callback(
app->text_input, text_done_cb, app,
app->wifi_creds_ssid, sizeof(app->wifi_creds_ssid), false);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTextInput);
}
static void prompt_password(TagTinkerApp* app) {
text_input_reset(app->text_input);
text_input_set_header_text(app->text_input, "Password");
/* Don't pre-fill the password field for visual privacy. */
app->wifi_creds_pwd[0] = 0;
text_input_set_result_callback(
app->text_input, text_done_cb, app,
app->wifi_creds_pwd, sizeof(app->wifi_creds_pwd), false);
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTextInput);
}
void tagtinker_scene_wifi_setup_on_enter(void* ctx) {
TagTinkerApp* app = ctx;
scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneWifiSetup, 0);
prompt_ssid(app);
}
bool tagtinker_scene_wifi_setup_on_event(void* ctx, SceneManagerEvent event) {
TagTinkerApp* app = ctx;
if(event.type != SceneManagerEventTypeCustom) return false;
if(event.event != EVT_TEXT_DONE) return false;
uint32_t st = scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneWifiSetup);
if(st == 0) {
scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneWifiSetup, 1);
prompt_password(app);
return true;
}
/* Both fields collected - send to ESP and pop back. */
if(app->wifi) {
tagtinker_wifi_set_creds((TagTinkerWifi*)app->wifi,
app->wifi_creds_ssid, app->wifi_creds_pwd);
}
/* Wipe the password from app memory once it's on the wire. */
memset(app->wifi_creds_pwd, 0, sizeof(app->wifi_creds_pwd));
scene_manager_previous_scene(app->scene_manager);
return true;
}
void tagtinker_scene_wifi_setup_on_exit(void* ctx) {
TagTinkerApp* app = ctx;
text_input_reset(app->text_input);
}
+16
View File
@@ -0,0 +1,16 @@
/*
* FAP-side stub that pulls in the shared wire-protocol header. The ESP-IDF
* project keeps the canonical copy under esp32-wifi-fw/shared/; this file
* just re-exports it so FAP sources can `#include "../shared/tt_wifi_proto_fap.h"`
* without escaping the FAP project root.
*
* If you ever need to edit the protocol, edit
* esp32-wifi-fw/shared/tt_wifi_proto.h
* and run the sync script (or copy by hand) to update the body of this file.
*/
#ifndef TT_WIFI_PROTO_FAP_H
#define TT_WIFI_PROTO_FAP_H
#include "../esp32-wifi-fw/shared/tt_wifi_proto.h"
#endif
+9
View File
@@ -704,6 +704,15 @@ static void app_free(TagTinkerApp* app) {
tagtinker_free_frame_sequence(app);
/* Tear down WiFi link if it was lazily allocated. */
if(app->wifi) {
extern void tagtinker_wifi_free(void* w);
tagtinker_wifi_free(app->wifi);
app->wifi = NULL;
}
free(app->wifi_plugins);
app->wifi_plugins = NULL;
/* Views */
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewSubmenu);
submenu_free(app->submenu);
+30
View File
@@ -240,12 +240,41 @@ struct TagTinkerApp {
bool ble_sync_compact_protocol;
bool ble_sync_last_compact_protocol;
int8_t ble_sync_ready_target;
/* ---- WiFi Plugins (ESP32 dev board) -------------------------------- */
/* Opaque handle (TagTinkerWifi*) - declared in wifi/tagtinker_wifi.h.
* Stored as void* here so this header doesn't pull in expansion/serial
* deps for unrelated translation units. */
void* wifi;
/* WiFi link state mirrored from the ESP. */
uint8_t wifi_link_state; /* TT_WIFI_* */
int8_t wifi_rssi;
char wifi_ssid[33];
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 */
uint8_t wifi_plugin_count;
bool wifi_plugins_loading;
int8_t wifi_selected_plugin;
/* Per-run state. */
char wifi_progress_msg[64];
uint8_t wifi_progress_pct;
char wifi_last_error[80];
bool wifi_run_in_flight;
bool wifi_result_ready;
/* Param values being collected by the run scene; one slot per plugin
* param, holding the textual value the user picked (string for STRING,
* stringified int for INT, option name for ENUM, "0"/"1" for BOOL). */
char wifi_param_values[6][64];
};
/* Main menu items */
typedef enum {
TagTinkerMenuBroadcast,
TagTinkerMenuTargetESL,
TagTinkerMenuWifiPlugins,
TagTinkerMenuAbout,
} TagTinkerMainMenuItem;
@@ -261,6 +290,7 @@ typedef enum {
TagTinkerTargetRename,
TagTinkerTargetPushText,
TagTinkerTargetPushSyncedImage,
TagTinkerTargetWifiPlugins,
TagTinkerTargetDeleteSyncedImages,
TagTinkerTargetPingFlash,
TagTinkerTargetDeleteTag,
+385
View File
@@ -0,0 +1,385 @@
/*
* Flipper-side TagTinker WiFi link.
*
* Threading model:
*
* - Open grabs the USART, starts the async RX, and spawns a worker thread.
* - The async RX ISR drops bytes into a stream_buffer.
* - The worker thread pulls bytes, runs the SOF/CRC parser, and either
* accumulates plugin manifests (which come in fragments) or directly
* dispatches simpler events to the user callback.
* - All callback invocations happen on the worker thread.
*
* The protocol matches esp32-wifi-fw/shared/tt_wifi_proto.h verbatim.
*/
#include "tagtinker_wifi.h"
#include <furi.h>
#include <furi_hal_serial.h>
#include <furi_hal_serial_control.h>
#include <expansion/expansion.h>
#include <string.h>
#include <stdlib.h>
#define TAG "TtWifi"
#define BAUD 230400U
struct TagTinkerWifi {
FuriHalSerialHandle* serial;
Expansion* expansion;
FuriThread* worker;
FuriStreamBuffer* rx_stream;
volatile bool running;
TtWifiEventCb cb;
void* user;
/* Reusable buffers. */
TagTinkerWifiPlugin pending_plugin;
};
/* ---- Outgoing framing ---------------------------------------------------*/
static void emit(TagTinkerWifi* w, uint8_t type, const uint8_t* p, uint16_t len) {
if(!w->serial) return;
uint8_t hdr[5] = { TT_FRAME_SOF0, TT_FRAME_SOF1, type,
(uint8_t)(len & 0xFF), (uint8_t)(len >> 8) };
/* CRC over [type, len_lo, len_hi, payload]. */
uint16_t crc = 0xFFFFU;
auto inline void step(uint8_t b) {
crc ^= (uint16_t)b << 8;
for(int i = 0; i < 8; i++)
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U) : (uint16_t)(crc << 1);
}
for(int i = 2; i < 5; i++) step(hdr[i]);
for(uint16_t i = 0; i < len; i++) step(p[i]);
uint8_t tail[2] = { (uint8_t)(crc >> 8), (uint8_t)(crc & 0xFF) };
furi_hal_serial_tx(w->serial, hdr, sizeof(hdr));
if(len) furi_hal_serial_tx(w->serial, p, len);
furi_hal_serial_tx(w->serial, tail, sizeof(tail));
furi_hal_serial_tx_wait_complete(w->serial);
}
/* zstring writer helper. */
static uint16_t put_zstr(uint8_t* dst, uint16_t off, const char* s) {
if(!s) s = "";
size_t l = strlen(s); if(l > 255) l = 255;
dst[off++] = (uint8_t)l;
memcpy(dst + off, s, l);
return (uint16_t)(off + l);
}
void tagtinker_wifi_ping(TagTinkerWifi* w) { emit(w, TT_FRAME_PING, NULL, 0); }
void tagtinker_wifi_query_status(TagTinkerWifi* w) { emit(w, TT_FRAME_WIFI_STATUS, NULL, 0); }
void tagtinker_wifi_list_plugins(TagTinkerWifi* w) { emit(w, TT_FRAME_LIST_PLUGINS, NULL, 0); }
void tagtinker_wifi_forget(TagTinkerWifi* w) { emit(w, TT_FRAME_WIFI_FORGET, NULL, 0); }
void tagtinker_wifi_set_creds(TagTinkerWifi* w, const char* ssid, const char* pwd) {
uint8_t buf[160]; uint16_t off = 0;
off = put_zstr(buf, off, ssid);
off = put_zstr(buf, off, pwd);
emit(w, TT_FRAME_WIFI_SET, buf, off);
}
void tagtinker_wifi_run_plugin(
TagTinkerWifi* w, uint8_t idx, uint16_t tw, uint16_t th, uint8_t accent,
const TtWifiKV* kv, uint8_t n) {
uint8_t buf[600]; uint16_t off = 0;
buf[off++] = idx;
buf[off++] = (uint8_t)(tw & 0xFF); buf[off++] = (uint8_t)(tw >> 8);
buf[off++] = (uint8_t)(th & 0xFF); buf[off++] = (uint8_t)(th >> 8);
buf[off++] = accent;
buf[off++] = n;
for(uint8_t i = 0; i < n; i++) {
off = put_zstr(buf, off, kv[i].key);
off = put_zstr(buf, off, kv[i].value);
}
emit(w, TT_FRAME_RUN_PLUGIN, buf, off);
}
/* ---- Incoming parser ----------------------------------------------------*/
static bool rb_u8 (const uint8_t* p, uint16_t len, uint16_t* pos, uint8_t* v) {
if(*pos + 1 > len) return false;
*v = p[(*pos)++];
return true;
}
static bool rb_u16(const uint8_t* p, uint16_t len, uint16_t* pos, uint16_t* v) {
uint8_t a, b; if(!rb_u8(p,len,pos,&a)||!rb_u8(p,len,pos,&b)) return false;
*v = (uint16_t)a | ((uint16_t)b << 8); return true; }
static bool rb_i32(const uint8_t* p, uint16_t len, uint16_t* pos, int32_t* v) {
if(*pos + 4 > len) return false;
uint32_t u = (uint32_t)p[*pos]
| ((uint32_t)p[*pos+1] << 8)
| ((uint32_t)p[*pos+2] << 16)
| ((uint32_t)p[*pos+3] << 24);
*pos += 4; *v = (int32_t)u; return true; }
static bool rb_zstr(const uint8_t* p, uint16_t len, uint16_t* pos, char* out, size_t cap) {
uint8_t l; if(!rb_u8(p,len,pos,&l)) return false;
if(*pos + l > len) return false;
size_t take = (l < cap-1) ? l : cap-1;
memcpy(out, p + *pos, take); out[take] = 0;
*pos += l; return true;
}
static void parse_plugin(TagTinkerWifi* w, const uint8_t* p, uint16_t len) {
TagTinkerWifiPlugin* m = &w->pending_plugin;
memset(m, 0, sizeof(*m));
uint16_t pos = 0;
if(!rb_u8(p, len, &pos, &m->index)) return;
if(!rb_zstr(p, len, &pos, m->id, sizeof(m->id))) return;
if(!rb_zstr(p, len, &pos, m->name, sizeof(m->name))) return;
if(!rb_zstr(p, len, &pos, m->description, sizeof(m->description))) return;
if(!rb_u8(p, len, &pos, &m->accent_modes)) return;
if(!rb_u8(p, len, &pos, &m->param_count)) return;
if(m->param_count > TT_WIFI_MAX_PARAMS) m->param_count = TT_WIFI_MAX_PARAMS;
for(uint8_t i = 0; i < m->param_count; i++) {
TtWifiParam* pp = &m->params[i];
if(!rb_zstr(p, len, &pos, pp->key, sizeof(pp->key))) return;
if(!rb_zstr(p, len, &pos, pp->label, sizeof(pp->label))) return;
if(!rb_u8(p, len, &pos, &pp->type)) return;
if(!rb_zstr(p, len, &pos, pp->default_value, sizeof(pp->default_value))) return;
if(pp->type == TT_PARAM_ENUM) {
if(!rb_u8(p, len, &pos, &pp->option_count)) return;
if(pp->option_count > TT_WIFI_MAX_OPTIONS) pp->option_count = TT_WIFI_MAX_OPTIONS;
for(uint8_t j = 0; j < pp->option_count; j++)
if(!rb_zstr(p, len, &pos, pp->options[j], sizeof(pp->options[j]))) return;
} else if(pp->type == TT_PARAM_INT) {
if(!rb_i32(p, len, &pos, &pp->int_min)) return;
if(!rb_i32(p, len, &pos, &pp->int_max)) return;
}
}
TtWifiEvent ev = { .type = TtWifiEvtPlugin, .plugin = m };
if(w->cb) w->cb(&ev, w->user);
}
static void dispatch(TagTinkerWifi* w, uint8_t type, const uint8_t* p, uint16_t len) {
TtWifiEvent ev = {0};
switch(type) {
case TT_FRAME_HELLO: {
uint16_t pos = 0; uint16_t fwver = 0; int32_t heap = 0; char name[32] = {0};
rb_u16(p, len, &pos, &fwver);
rb_i32(p, len, &pos, &heap);
rb_zstr(p, len, &pos, name, sizeof(name));
ev.type = TtWifiEvtHello;
ev.u0 = fwver; ev.u1 = (uint32_t)heap; ev.str0 = name;
if(w->cb) w->cb(&ev, w->user);
break;
}
case TT_FRAME_WIFI_STATUS: {
uint16_t pos = 0; uint8_t state = 0; uint8_t rssi_u = 0;
char ssid[33] = {0}, ip[20] = {0};
rb_u8(p, len, &pos, &state);
rb_u8(p, len, &pos, &rssi_u);
rb_zstr(p, len, &pos, ssid, sizeof(ssid));
rb_zstr(p, len, &pos, ip, sizeof(ip));
ev.type = TtWifiEvtWifiStatus;
ev.u0 = state; ev.i1 = (int8_t)rssi_u;
ev.str0 = ssid; ev.str1 = ip;
if(w->cb) w->cb(&ev, w->user);
break;
}
case TT_FRAME_PLUGIN:
parse_plugin(w, p, len);
break;
case TT_FRAME_PLUGINS_END:
ev.type = TtWifiEvtPluginsEnd;
if(w->cb) w->cb(&ev, w->user);
break;
case TT_FRAME_PROGRESS: {
uint16_t pos = 0; uint8_t pct = 0;
char msg[80] = {0};
rb_u8(p, len, &pos, &pct);
rb_zstr(p, len, &pos, msg, sizeof(msg));
ev.type = TtWifiEvtProgress;
ev.u0 = pct; ev.str0 = msg;
if(w->cb) w->cb(&ev, w->user);
break;
}
case TT_FRAME_RESULT_BEGIN: {
uint16_t pos = 0; uint16_t tw = 0, th = 0; uint8_t pl = 0; int32_t total = 0;
rb_u16(p, len, &pos, &tw);
rb_u16(p, len, &pos, &th);
rb_u8(p, len, &pos, &pl);
rb_i32(p, len, &pos, &total);
ev.type = TtWifiEvtResultBegin;
ev.u0 = ((uint32_t)th << 16) | tw;
ev.u1 = pl;
ev.u2 = (uint32_t)total;
if(w->cb) w->cb(&ev, w->user);
break;
}
case TT_FRAME_RESULT_CHUNK:
ev.type = TtWifiEvtResultChunk;
ev.data = p; ev.data_len = len;
if(w->cb) w->cb(&ev, w->user);
break;
case TT_FRAME_RESULT_END:
ev.type = TtWifiEvtResultEnd;
if(w->cb) w->cb(&ev, w->user);
break;
case TT_FRAME_ERROR: {
uint16_t pos = 0; char msg[100] = {0};
rb_zstr(p, len, &pos, msg, sizeof(msg));
ev.type = TtWifiEvtError; ev.str0 = msg;
if(w->cb) w->cb(&ev, w->user);
break;
}
default: break;
}
}
/* ---- Worker (parser state machine) -------------------------------------*/
static int32_t worker_thread(void* ctx) {
TagTinkerWifi* w = ctx;
enum { S_SOF0, S_SOF1, S_TYPE, S_LEN_LO, S_LEN_HI, S_PAYLOAD, S_CRC_HI, S_CRC_LO } st = S_SOF0;
uint8_t type = 0;
uint16_t len = 0, idx = 0;
uint16_t crc_calc = 0xFFFFU, crc_recv = 0;
static uint8_t payload[TT_FRAME_MAX_PAYLOAD];
uint32_t last_byte_tick = furi_get_tick();
auto inline void step(uint8_t b) {
crc_calc ^= (uint16_t)b << 8;
for(int i = 0; i < 8; i++)
crc_calc = (crc_calc & 0x8000U) ? (uint16_t)((crc_calc << 1) ^ 0x1021U)
: (uint16_t)(crc_calc << 1);
}
/* Pull bytes in bulk from the stream buffer - one syscall per byte
* was too slow at 230400 baud during plugin frame bursts and the ISR
* was dropping bytes once the stream filled. */
static uint8_t batch[256];
while(w->running) {
size_t got = furi_stream_buffer_receive(w->rx_stream, batch, sizeof(batch), 100);
uint32_t now = furi_get_tick();
if(got == 0) {
if(now - last_byte_tick > furi_ms_to_ticks(3000)) {
last_byte_tick = now;
TtWifiEvent ev = { .type = TtWifiEvtLinkLost };
if(w->cb) w->cb(&ev, w->user);
}
continue;
}
last_byte_tick = now;
for(size_t k = 0; k < got; k++) {
uint8_t b = batch[k];
switch(st) {
case S_SOF0: if(b == TT_FRAME_SOF0) st = S_SOF1; break;
case S_SOF1: st = (b == TT_FRAME_SOF1) ? S_TYPE : S_SOF0; break;
case S_TYPE: type = b; crc_calc = 0xFFFFU; step(b); st = S_LEN_LO; break;
case S_LEN_LO: len = b; step(b); st = S_LEN_HI; break;
case S_LEN_HI: len |= (uint16_t)b << 8; step(b);
if(len > TT_FRAME_MAX_PAYLOAD) { st = S_SOF0; break; }
idx = 0;
st = (len == 0) ? S_CRC_HI : S_PAYLOAD;
break;
case S_PAYLOAD:
payload[idx++] = b; step(b);
if(idx >= len) st = S_CRC_HI;
break;
case S_CRC_HI: crc_recv = (uint16_t)b << 8; st = S_CRC_LO; break;
case S_CRC_LO: crc_recv |= b;
if(crc_recv == crc_calc) dispatch(w, type, payload, len);
st = S_SOF0; break;
}
}
}
return 0;
}
static void rx_isr(FuriHalSerialHandle* h, FuriHalSerialRxEvent ev, void* ctx) {
TagTinkerWifi* w = ctx;
if(ev != FuriHalSerialRxEventData) return;
/* Drain in chunks: one stream_buffer_send per byte was the dominant
* cost in the ISR and could let the HAL FIFO overrun during the
* plugin frame burst. */
uint8_t buf[64];
size_t n = 0;
while(furi_hal_serial_async_rx_available(h)) {
buf[n++] = furi_hal_serial_async_rx(h);
if(n == sizeof(buf)) {
furi_stream_buffer_send(w->rx_stream, buf, n, 0);
n = 0;
}
}
if(n) furi_stream_buffer_send(w->rx_stream, buf, n, 0);
}
/* ---- Lifecycle ---------------------------------------------------------- */
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);
return w;
}
void tagtinker_wifi_free(TagTinkerWifi* w) {
if(!w) return;
tagtinker_wifi_close(w);
if(w->rx_stream) furi_stream_buffer_free(w->rx_stream);
free(w);
}
bool tagtinker_wifi_open(TagTinkerWifi* w) {
if(w->serial) return true;
/* Yield the UART from the expansion service before grabbing it. */
w->expansion = furi_record_open(RECORD_EXPANSION);
expansion_disable(w->expansion);
w->serial = furi_hal_serial_control_acquire(FuriHalSerialIdUsart);
if(!w->serial) {
expansion_enable(w->expansion);
furi_record_close(RECORD_EXPANSION);
w->expansion = NULL;
return false;
}
furi_hal_serial_init(w->serial, BAUD);
w->running = true;
w->worker = furi_thread_alloc_ex("TtWifiRx", 2048, worker_thread, w);
furi_thread_start(w->worker);
furi_hal_serial_async_rx_start(w->serial, rx_isr, w, false);
return true;
}
void tagtinker_wifi_set_callback(
TagTinkerWifi* w,
TtWifiEventCb new_cb, void* new_user,
TtWifiEventCb* out_prev_cb, void** out_prev_user) {
if(out_prev_cb) *out_prev_cb = w->cb;
if(out_prev_user) *out_prev_user = w->user;
w->cb = new_cb;
w->user = new_user;
}
void tagtinker_wifi_close(TagTinkerWifi* w) {
if(!w->serial) return;
furi_hal_serial_async_rx_stop(w->serial);
w->running = false;
if(w->worker) {
furi_thread_join(w->worker);
furi_thread_free(w->worker);
w->worker = NULL;
}
furi_hal_serial_deinit(w->serial);
furi_hal_serial_control_release(w->serial);
w->serial = NULL;
if(w->expansion) {
expansion_enable(w->expansion);
furi_record_close(RECORD_EXPANSION);
w->expansion = NULL;
}
}
+111
View File
@@ -0,0 +1,111 @@
/*
* Flipper-side client for the TagTinker WiFi ESP32 firmware.
*
* Owns the USART handle while WiFi Plugins are active, parses framed
* 0xAA 0x55 packets from the dev board, and exposes a small async API:
*
* - tagtinker_wifi_open()/close() : grab/release the UART.
* - tagtinker_wifi_set_creds(ssid, pwd) : send WIFI_SET.
* - tagtinker_wifi_list_plugins() : kick off LIST and get plugins
* via the event callback.
* - tagtinker_wifi_run_plugin(...) : send RUN; result frames stream
* into the same callback.
*
* The caller registers a single callback that is invoked from the FAP's
* worker thread (not ISR), so it's safe to allocate and call view-dispatcher
* helpers from inside it.
*/
#ifndef TAGTINKER_WIFI_H
#define TAGTINKER_WIFI_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include "../shared/tt_wifi_proto_fap.h"
typedef struct TagTinkerWifi TagTinkerWifi;
/* Event types delivered to the user callback. */
typedef enum {
TtWifiEvtHello, /* HELLO received, fw_name in str0 */
TtWifiEvtWifiStatus, /* state in u0, rssi in i1, ssid in str0, ip in str1 */
TtWifiEvtPlugin, /* one parsed manifest (see TagTinkerWifiPlugin*) */
TtWifiEvtPluginsEnd,
TtWifiEvtProgress, /* percent in u0, message in str0 */
TtWifiEvtResultBegin, /* width in u0(low16), height in u0(high16),
* planes in u1, total_bytes in u2 */
TtWifiEvtResultChunk, /* chunk bytes in data/data_len */
TtWifiEvtResultEnd,
TtWifiEvtError, /* message in str0 */
TtWifiEvtLinkLost, /* dev board went silent (>3s) */
} TtWifiEventType;
/* Param specifications mirror what the ESP advertised. */
#define TT_WIFI_MAX_PARAMS 6
#define TT_WIFI_MAX_OPTIONS 8
typedef struct {
char key[24];
char label[24];
uint8_t type; /* TT_PARAM_* */
char default_value[64];
uint8_t option_count;
char options[TT_WIFI_MAX_OPTIONS][24];
int32_t int_min;
int32_t int_max;
} TtWifiParam;
typedef struct {
uint8_t index;
char id[24];
char name[40];
char description[64];
uint8_t accent_modes;
uint8_t param_count;
TtWifiParam params[TT_WIFI_MAX_PARAMS];
} TagTinkerWifiPlugin;
typedef struct {
TtWifiEventType type;
uint32_t u0, u1, u2;
int32_t i1;
const char* str0;
const char* str1;
const TagTinkerWifiPlugin* plugin; /* TtWifiEvtPlugin only */
const uint8_t* data; uint16_t data_len; /* TtWifiEvtResultChunk only */
} TtWifiEvent;
typedef void (*TtWifiEventCb)(const TtWifiEvent* e, void* user);
TagTinkerWifi* tagtinker_wifi_alloc(TtWifiEventCb cb, void* user);
void tagtinker_wifi_free (TagTinkerWifi* w);
bool tagtinker_wifi_open (TagTinkerWifi* w);
void tagtinker_wifi_close(TagTinkerWifi* w);
/* Hot-swap the event callback. Used so the WiFi-Plugins scene and the
* WiFi-Run scene can each have their own handler without re-opening the
* UART. The previous callback is returned in `out_prev_*` if non-NULL. */
void tagtinker_wifi_set_callback(
TagTinkerWifi* w,
TtWifiEventCb new_cb, void* new_user,
TtWifiEventCb* out_prev_cb, void** out_prev_user);
void tagtinker_wifi_ping (TagTinkerWifi* w);
void tagtinker_wifi_set_creds (TagTinkerWifi* w, const char* ssid, const char* pwd);
void tagtinker_wifi_forget (TagTinkerWifi* w);
void tagtinker_wifi_query_status(TagTinkerWifi* w);
void tagtinker_wifi_list_plugins(TagTinkerWifi* w);
/* Param values is an array of {key, value} pairs; both NUL-terminated. */
typedef struct { const char* key; const char* value; } TtWifiKV;
void tagtinker_wifi_run_plugin(
TagTinkerWifi* w,
uint8_t plugin_index,
uint16_t target_w,
uint16_t target_h,
uint8_t accent,
const TtWifiKV* params, uint8_t n_params);
#endif /* TAGTINKER_WIFI_H */
+127
View File
@@ -0,0 +1,127 @@
/*
* BMP writer for plugin-rendered images.
*
* The ESP streams the canvas top-down (because that's what we draw into),
* but the BMP file format stores rows bottom-up. We buffer the pixel
* section in memory, then write it out reversed when the stream closes.
*
* Bit convention: bit==0 -> palette[0] (black), bit==1 -> palette[1] (white).
* That matches the TagTinker canvas convention exactly so no inversion is
* needed on the byte level.
*/
#include "tagtinker_wifi_bmp.h"
#include <furi.h>
#include <stdlib.h>
#include <string.h>
#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)
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) {
p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8);
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) {
memset(w, 0, sizeof(*w));
w->width = width;
w->height = height;
w->row_stride = (uint16_t)(((width + 31U) / 32U) * 4U);
w->pixel_size = (size_t)w->row_stride * height;
w->pixel_buf = malloc(w->pixel_size);
if(!w->pixel_buf) return false;
/* Default to all-zero (==white under our palette) so partial writes
* don't show garbage. */
memset(w->pixel_buf, 0x00, w->pixel_size);
w->storage = furi_record_open(RECORD_STORAGE);
storage_common_mkdir(w->storage, "/ext/apps_data/tagtinker");
w->file = storage_file_alloc(w->storage);
if(!storage_file_open(w->file, TAGTINKER_WIFI_TMP_BMP,
FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
storage_file_free(w->file); w->file = NULL;
furi_record_close(RECORD_STORAGE); w->storage = NULL;
free(w->pixel_buf); w->pixel_buf = NULL;
return false;
}
return true;
}
bool tagtinker_wifi_bmp_chunk(TagTinkerWifiBmpWriter* w, const uint8_t* data, size_t len) {
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. */
size_t off = (size_t)w->bytes_written;
if(off >= w->pixel_size) return true; /* plane 1 / overflow - drop */
size_t remain = w->pixel_size - off;
size_t take = (len < remain) ? len : remain;
memcpy(w->pixel_buf + off, data, take);
w->bytes_written += (uint32_t)take;
return true;
}
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;
/* --- File + DIB headers ----------------------------------------- */
uint8_t hdr[BMP_HDR_TOTAL] = {0};
/* BITMAPFILEHEADER */
hdr[0] = 'B'; hdr[1] = 'M';
put_le32(&hdr[2], total_size);
put_le32(&hdr[10], BMP_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_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[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;
if(storage_file_write(w->file, hdr, sizeof(hdr)) != sizeof(hdr)) 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;
}
storage_file_close(w->file);
storage_file_free(w->file);
furi_record_close(RECORD_STORAGE);
free(w->pixel_buf);
memset(w, 0, sizeof(*w));
return true;
fail:
tagtinker_wifi_bmp_abort(w);
return false;
}
void tagtinker_wifi_bmp_abort(TagTinkerWifiBmpWriter* w) {
if(w->file) { storage_file_close(w->file); storage_file_free(w->file); }
if(w->storage) furi_record_close(RECORD_STORAGE);
free(w->pixel_buf);
memset(w, 0, sizeof(*w));
}
+38
View File
@@ -0,0 +1,38 @@
/*
* Stream the 1bpp planes returned by the ESP into a Windows-format BMP file
* on the SD card so the existing TX pipeline can read it back without
* needing to know that the image came from a WiFi plugin.
*/
#ifndef TAGTINKER_WIFI_BMP_H
#define TAGTINKER_WIFI_BMP_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include <storage/storage.h>
/* Path used for plugin-rendered BMPs. The same file is overwritten every
* time a plugin runs; previous output is discarded. */
#define TAGTINKER_WIFI_TMP_BMP "/ext/apps_data/tagtinker/wifi_temp.bmp"
typedef struct {
File* file;
Storage* storage;
uint16_t width;
uint16_t height;
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(). */
uint8_t* pixel_buf;
size_t pixel_size;
} TagTinkerWifiBmpWriter;
bool tagtinker_wifi_bmp_open (TagTinkerWifiBmpWriter* w, uint16_t width, uint16_t height);
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);
#endif