mirror of
https://github.com/i12bp8/TagTinker.git
synced 2026-09-01 16:48:34 +00:00
Add optional WiFi Plugins (ESP32-S2 bridge + Cloudflare Worker)
- Flipper FAP: WiFi Setup, Plugins picker, plugin-run scene wired into Targeted Payloads -> <tag> -> WiFi Plugins - Framed UART link to the dev board with bulk-read RX path and 16 KB stream buffer to keep up with full plugin bursts - ESP firmware: 900 KB UART<->HTTPS bridge, USB-Serial-JTAG console, cert-bundle TLS, 16 KB worker stack to host mbedTLS handshake - Cloudflare Worker: Crypto, Weather, Identicon plugins out of the box; new plugins are a single TypeScript file, no Flipper reflash - Shared protocol header + 1bpp BMP writer that matches web-image-prep palette/stride conventions
This commit is contained in:
+10
@@ -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/
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
Generated
+1642
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
];
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Crypto Price plugin.
|
||||
*
|
||||
* Renders the requested coin's spot price headline, 24h delta + a
|
||||
* sparkline filling the bottom band. Accent-capable tags get the arrow
|
||||
* and sparkline drawn on the accent plane.
|
||||
*
|
||||
* ┌─────────────────────────────────────────┐
|
||||
* │ BTC / USD LIVE │
|
||||
* │ │
|
||||
* │ $ 67,415.20 │
|
||||
* │ ▲ +2.31% 24H │
|
||||
* │ ╱╲ │
|
||||
* │╱ ╲╱╲ ╱╲╱╲ │
|
||||
* │ ╲╱ ╲╱╲╱ │
|
||||
* └─────────────────────────────────────────┘
|
||||
*
|
||||
* Data source: CoinGecko free tier (no key required).
|
||||
*/
|
||||
|
||||
import { Canvas, Ink } from "../canvas";
|
||||
import { Plugin, AccentMode } from "../plugin";
|
||||
|
||||
const COIN_MAP: Record<string, string> = {
|
||||
BTC: "bitcoin", ETH: "ethereum", SOL: "solana", XRP: "ripple",
|
||||
DOGE: "dogecoin", ADA: "cardano", BNB: "binancecoin", LINK: "chainlink",
|
||||
};
|
||||
const RANGE_DAYS: Record<string, number> = { "24H": 1, "7D": 7, "30D": 30 };
|
||||
const CCY_PREFIX: Record<string, string> = { USD: "$", EUR: "EUR ", GBP: "GBP " };
|
||||
|
||||
function formatPrice(v: number, prefix: string): string {
|
||||
let body: string;
|
||||
if (v >= 1000) body = v.toFixed(2);
|
||||
else if (v >= 1) body = v.toFixed(2);
|
||||
else if (v >= 0.01) body = v.toFixed(4);
|
||||
else body = v.toFixed(6);
|
||||
// Thousand separators on the integer part.
|
||||
const [intPart, frac] = body.split(".");
|
||||
const withCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
return prefix + withCommas + (frac ? "." + frac : "");
|
||||
}
|
||||
|
||||
async function fetchPrice(id: string, vs: string): Promise<{ price: number; change: number }> {
|
||||
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=${vs}&include_24hr_change=true`;
|
||||
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
|
||||
if (!r.ok) throw new Error(`price ${r.status}`);
|
||||
const j: any = await r.json();
|
||||
const c = j[id];
|
||||
if (!c) throw new Error("coin not in response");
|
||||
const k = vs.toLowerCase();
|
||||
return { price: c[k] ?? 0, change: c[`${k}_24h_change`] ?? 0 };
|
||||
}
|
||||
|
||||
async function fetchHistory(id: string, vs: string, days: number, samples: number): Promise<number[]> {
|
||||
const url = `https://api.coingecko.com/api/v3/coins/${id}/market_chart?vs_currency=${vs.toLowerCase()}&days=${days}`;
|
||||
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
|
||||
if (!r.ok) throw new Error(`history ${r.status}`);
|
||||
const j: any = await r.json();
|
||||
const arr: [number, number][] = j.prices ?? [];
|
||||
if (arr.length === 0) return [];
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const idx = Math.floor((i * (arr.length - 1)) / Math.max(1, samples - 1));
|
||||
out.push(arr[idx][1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const cryptoPlugin: Plugin = {
|
||||
manifest: {
|
||||
id: "crypto",
|
||||
name: "Crypto Price",
|
||||
description: "Live coin price + sparkline",
|
||||
accent_modes: 1 | 2 | 4,
|
||||
params: [
|
||||
{ key: "symbol", label: "Coin", type: "enum", default: "BTC",
|
||||
options: Object.keys(COIN_MAP) },
|
||||
{ key: "currency", label: "Currency", type: "enum", default: "USD",
|
||||
options: Object.keys(CCY_PREFIX) },
|
||||
{ key: "range", label: "Range", type: "enum", default: "24H",
|
||||
options: Object.keys(RANGE_DAYS) },
|
||||
],
|
||||
},
|
||||
|
||||
async render(params, W, H, accent: AccentMode) {
|
||||
const sym = (params.symbol ?? "BTC").toUpperCase();
|
||||
const vs = (params.currency ?? "USD").toUpperCase();
|
||||
const range = (params.range ?? "24H").toUpperCase();
|
||||
const id = COIN_MAP[sym] ?? "bitcoin";
|
||||
const days = RANGE_DAYS[range] ?? 1;
|
||||
const prefix = CCY_PREFIX[vs] ?? "$";
|
||||
|
||||
const { price, change } = await fetchPrice(id, vs);
|
||||
const samples = Math.min(W, 256);
|
||||
const hist = await fetchHistory(id, vs, days, samples);
|
||||
|
||||
const planes: 1 | 2 = accent === "none" ? 1 : 2;
|
||||
const c = new Canvas(W, H, planes);
|
||||
const accentInk: Ink = planes === 2 ? 1 : 0;
|
||||
|
||||
const margin = W < 200 ? 4 : 8;
|
||||
|
||||
// Header
|
||||
c.drawText(margin, margin, `${sym} / ${vs}`, 0, 1);
|
||||
|
||||
// LIVE badge top-right (accent rectangle).
|
||||
{
|
||||
const badge = "LIVE";
|
||||
const { w: bw, h: bh } = c.textSize(badge, 1);
|
||||
const bx = W - margin - bw - 4;
|
||||
const by = margin - 1;
|
||||
c.rect(bx - 2, by - 1, bw + 4, bh + 2, accentInk);
|
||||
c.drawText(bx, by, badge, accentInk, 1);
|
||||
}
|
||||
|
||||
// Price headline. Pick scale so it just fits.
|
||||
const priceTxt = formatPrice(price, prefix);
|
||||
let scale = W >= 280 ? 3 : W >= 180 ? 2 : 1;
|
||||
while (scale > 1 && c.textSize(priceTxt, scale).w > W - 2 * margin) scale--;
|
||||
const priceY = margin + 12 + Math.floor(H / 12);
|
||||
c.drawTextCentered(W >> 1, priceY, priceTxt, 0, scale);
|
||||
const priceH = c.textSize(priceTxt, scale).h;
|
||||
|
||||
// Delta line: "▲ +X.XX% 24H"
|
||||
const delta = `${change >= 0 ? "+" : "-"}${Math.abs(change).toFixed(2)}% ${range}`;
|
||||
const dy = priceY + priceH + Math.floor(H / 16);
|
||||
c.drawTextCentered(W >> 1, dy, delta, 0, 1);
|
||||
{
|
||||
const triW = 5;
|
||||
const dwt = c.textSize(delta, 1).w;
|
||||
const dx = (W >> 1) - (dwt >> 1) - triW - 2;
|
||||
const dyy = dy + 1;
|
||||
if (change >= 0) {
|
||||
c.line(dx, dyy + 5, dx + triW, dyy + 5, accentInk);
|
||||
c.line(dx, dyy + 5, dx + (triW >> 1), dyy, accentInk);
|
||||
c.line(dx + triW, dyy + 5, dx + (triW >> 1), dyy, accentInk);
|
||||
} else {
|
||||
c.line(dx, dyy, dx + triW, dyy, accentInk);
|
||||
c.line(dx, dyy, dx + (triW >> 1), dyy + 5, accentInk);
|
||||
c.line(dx + triW, dyy, dx + (triW >> 1), dyy + 5, accentInk);
|
||||
}
|
||||
}
|
||||
|
||||
// Sparkline filling the bottom third.
|
||||
if (hist.length >= 2) {
|
||||
const sh = Math.floor(H / 3);
|
||||
const sy = H - margin - sh;
|
||||
const sx = margin;
|
||||
const sw = W - 2 * margin;
|
||||
c.hline(sx, sy + sh, sw, 0); // baseline tick on primary plane
|
||||
c.sparkline(sx, sy, sw, sh, hist, accentInk, true);
|
||||
}
|
||||
|
||||
return c;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Identicon plugin.
|
||||
*
|
||||
* Procedural avatar from a name string. Hash -> symmetric pixel art with
|
||||
* an accent stripe so it actually looks like a *card*, not just a square.
|
||||
*
|
||||
* ┌──────────────────────┐
|
||||
* │ PIETER │
|
||||
* │ ████ ██ ████ │
|
||||
* │ ██ ██████ ██ │
|
||||
* │ ██████ ██████ │
|
||||
* │ ██ ██████ ██ │
|
||||
* │ ████ ██ ████ │
|
||||
* │ member since │
|
||||
* │ 2026 · #A4F1 │
|
||||
* └──────────────────────────────────────┘
|
||||
*
|
||||
* No external API calls - pure compute, instant render.
|
||||
*/
|
||||
|
||||
import { Canvas, Ink } from "../canvas";
|
||||
import { Plugin, AccentMode } from "../plugin";
|
||||
|
||||
function hash32(s: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export const identiconPlugin: Plugin = {
|
||||
manifest: {
|
||||
id: "identicon",
|
||||
name: "Identicon",
|
||||
description: "Symmetric pixel-art avatar from a name",
|
||||
accent_modes: 1 | 2 | 4,
|
||||
params: [
|
||||
{ key: "name", label: "Name", type: "string", default: "Dolphin" },
|
||||
{ key: "grid", label: "Grid", type: "enum", default: "5", options: ["5", "7", "9"] },
|
||||
{ key: "style", label: "Style", type: "enum", default: "blocky",
|
||||
options: ["blocky", "rings", "diagonal"] },
|
||||
],
|
||||
},
|
||||
|
||||
async render(params, W, H, accent: AccentMode) {
|
||||
const name = (params.name ?? "Dolphin").trim() || "Dolphin";
|
||||
const grid = parseInt(params.grid ?? "5", 10);
|
||||
const style = params.style ?? "blocky";
|
||||
const seed = hash32(name);
|
||||
|
||||
const planes: 1 | 2 = accent === "none" ? 1 : 2;
|
||||
const c = new Canvas(W, H, planes);
|
||||
const accentInk: Ink = planes === 2 ? 1 : 0;
|
||||
|
||||
const margin = W < 200 ? 6 : 10;
|
||||
|
||||
// Header
|
||||
c.drawText(margin, margin, name.toUpperCase().slice(0, 24), 0, 1);
|
||||
|
||||
// Avatar block: square sized to fit on the left half.
|
||||
const blockSize = Math.min(H - margin * 2 - 24, Math.floor(W * 0.55));
|
||||
const cell = Math.floor(blockSize / grid);
|
||||
const used = cell * grid;
|
||||
const ax = margin;
|
||||
const ay = margin + 16;
|
||||
|
||||
const half = Math.ceil(grid / 2);
|
||||
let r = seed;
|
||||
const next = () => {
|
||||
// xorshift32
|
||||
r ^= r << 13; r >>>= 0;
|
||||
r ^= r >>> 17;
|
||||
r ^= r << 5; r >>>= 0;
|
||||
return r;
|
||||
};
|
||||
|
||||
const cells: boolean[][] = [];
|
||||
for (let y = 0; y < grid; y++) {
|
||||
cells.push([]);
|
||||
for (let x = 0; x < half; x++) {
|
||||
let on = (next() & 0xff) > 110;
|
||||
if (style === "rings") {
|
||||
const dx = x - (half - 1) / 2;
|
||||
const dy = y - (grid - 1) / 2;
|
||||
on = on !== ((Math.round(Math.sqrt(dx * dx + dy * dy)) & 1) === 0);
|
||||
} else if (style === "diagonal") {
|
||||
on = on !== (((x + y) & 1) === 0);
|
||||
}
|
||||
cells[y].push(on);
|
||||
}
|
||||
}
|
||||
// Mirror to right half.
|
||||
for (let y = 0; y < grid; y++) {
|
||||
for (let x = 0; x < grid; x++) {
|
||||
const sx = x < half ? x : grid - 1 - x;
|
||||
if (cells[y][sx]) {
|
||||
c.fillRect(ax + x * cell, ay + y * cell, cell, cell, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accent stripe down the side of the block (signature flair).
|
||||
c.fillRect(ax + used + 4, ay, 3, used, accentInk);
|
||||
|
||||
// Right-hand info column.
|
||||
const infoX = ax + used + 14;
|
||||
let iy = ay;
|
||||
c.drawText(infoX, iy, "MEMBER SINCE", 0, 1); iy += 10;
|
||||
c.drawText(infoX, iy, "2026", accentInk, 2); iy += 18;
|
||||
const codeStr = "#" + (seed >>> 0).toString(16).toUpperCase().padStart(8, "0").slice(0, 4);
|
||||
c.drawText(infoX, iy, codeStr, 0, 1);
|
||||
|
||||
// Border + inner bevel for finish.
|
||||
c.rect(0, 0, W, H, 0);
|
||||
c.rect(2, 2, W - 4, H - 4, 0);
|
||||
|
||||
return c;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Weather plugin.
|
||||
*
|
||||
* Renders a clean, minimalist weather card:
|
||||
*
|
||||
* ┌────────────────────────────────────────┐
|
||||
* │ AMSTERDAM NL │
|
||||
* │ │
|
||||
* │ ☀ 12°C │
|
||||
* │ partly cloudy │
|
||||
* │ │
|
||||
* │ Mon 14° / 8° Tue 11° / 6° │
|
||||
* └────────────────────────────────────────┘
|
||||
*
|
||||
* Procedural icons (sun, cloud, rain, snow, storm) drawn with the canvas
|
||||
* primitives - they scale cleanly to any tag size and look distinctive
|
||||
* even at the smallest 152x152 panels.
|
||||
*
|
||||
* Data source: wttr.in (free, no key, returns JSON when ?format=j1).
|
||||
*/
|
||||
|
||||
import { Canvas, Ink } from "../canvas";
|
||||
import { Plugin, AccentMode } from "../plugin";
|
||||
|
||||
type Sky = "sun" | "cloud" | "rain" | "snow" | "storm" | "fog";
|
||||
|
||||
function classify(code: number): Sky {
|
||||
// wttr WWO weatherCode mapping to a small icon vocabulary.
|
||||
if ([113].includes(code)) return "sun";
|
||||
if ([116, 119, 122].includes(code)) return "cloud";
|
||||
if ([143, 248, 260].includes(code)) return "fog";
|
||||
if ([200, 386, 389, 392, 395].includes(code)) return "storm";
|
||||
if ([179, 227, 230, 320, 323, 326, 329, 332, 335, 338, 350, 368, 371, 374, 377].includes(code))
|
||||
return "snow";
|
||||
return "rain";
|
||||
}
|
||||
|
||||
function drawIcon(c: Canvas, sky: Sky, cx: number, cy: number, r: number, accent: Ink): void {
|
||||
switch (sky) {
|
||||
case "sun": {
|
||||
// Filled disc + 8 rays in accent ink.
|
||||
for (let y = -r; y <= r; y++)
|
||||
for (let x = -r; x <= r; x++)
|
||||
if (x * x + y * y <= (r - 1) * (r - 1)) c.setPixel(cx + x, cy + y, accent);
|
||||
const rr = r + 2, R = r + r;
|
||||
c.line(cx - R, cy, cx - rr, cy, accent);
|
||||
c.line(cx + rr, cy, cx + R, cy, accent);
|
||||
c.line(cx, cy - R, cx, cy - rr, accent);
|
||||
c.line(cx, cy + rr, cx, cy + R, accent);
|
||||
c.line(cx - R, cy - R, cx - rr, cy - rr, accent);
|
||||
c.line(cx + rr, cy - rr, cx + R, cy - R, accent);
|
||||
c.line(cx - R, cy + R, cx - rr, cy + rr, accent);
|
||||
c.line(cx + rr, cy + rr, cx + R, cy + R, accent);
|
||||
break;
|
||||
}
|
||||
case "cloud": {
|
||||
// Three overlapping discs forming a cloud silhouette.
|
||||
const blob = (ox: number, oy: number, br: number) => {
|
||||
for (let y = -br; y <= br; y++)
|
||||
for (let x = -br; x <= br; x++)
|
||||
if (x * x + y * y <= br * br) c.setPixel(cx + ox + x, cy + oy + y, 0);
|
||||
};
|
||||
blob(-r, 2, Math.floor(r * 0.6));
|
||||
blob(0, -2, Math.floor(r * 0.7));
|
||||
blob(r - 2, 2, Math.floor(r * 0.6));
|
||||
c.hline(cx - r, cy + Math.floor(r * 0.6), 2 * r, 0);
|
||||
break;
|
||||
}
|
||||
case "rain":
|
||||
drawIcon(c, "cloud", cx, cy, r, 0);
|
||||
for (let i = -r; i <= r; i += 4) {
|
||||
c.line(cx + i, cy + r + 2, cx + i - 2, cy + r + 6, accent);
|
||||
}
|
||||
break;
|
||||
case "snow":
|
||||
drawIcon(c, "cloud", cx, cy, r, 0);
|
||||
for (let i = -r; i <= r; i += 5) {
|
||||
const yy = cy + r + 4;
|
||||
c.setPixel(cx + i, yy, accent);
|
||||
c.setPixel(cx + i - 1, yy + 1, accent);
|
||||
c.setPixel(cx + i + 1, yy + 1, accent);
|
||||
c.setPixel(cx + i, yy + 2, accent);
|
||||
}
|
||||
break;
|
||||
case "storm":
|
||||
drawIcon(c, "cloud", cx, cy, r, 0);
|
||||
// Lightning bolt
|
||||
const bx = cx, by = cy + r;
|
||||
c.line(bx, by, bx + 3, by + 4, accent);
|
||||
c.line(bx + 3, by + 4, bx - 1, by + 5, accent);
|
||||
c.line(bx - 1, by + 5, bx + 3, by + 9, accent);
|
||||
break;
|
||||
case "fog":
|
||||
for (let i = 0; i < 4; i++) {
|
||||
c.hline(cx - r + (i % 2) * 2, cy - r + i * 4, 2 * r - (i % 2) * 4, 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWeather(loc: string): Promise<any> {
|
||||
const url = `https://wttr.in/${encodeURIComponent(loc)}?format=j1`;
|
||||
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
|
||||
if (!r.ok) throw new Error(`wttr ${r.status}`);
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
export const weatherPlugin: Plugin = {
|
||||
manifest: {
|
||||
id: "weather",
|
||||
name: "Weather",
|
||||
description: "Live weather card with forecast",
|
||||
accent_modes: 1 | 2 | 4,
|
||||
params: [
|
||||
{ key: "location", label: "Location", type: "string", default: "Paris" },
|
||||
{ key: "units", label: "Units", type: "enum", default: "C", options: ["C", "F"] },
|
||||
],
|
||||
},
|
||||
|
||||
async render(params, W, H, accent: AccentMode) {
|
||||
const loc = (params.location ?? "Paris").trim() || "Paris";
|
||||
/* Defensive: only accept C or F. The Flipper used to (briefly) leak
|
||||
* stale param values across plugins, which once produced "20°USD". */
|
||||
const rawUnits = (params.units ?? "C").toUpperCase();
|
||||
const units = rawUnits === "F" ? "F" : "C";
|
||||
const data = await fetchWeather(loc);
|
||||
|
||||
const cur = data.current_condition?.[0] ?? {};
|
||||
const code = parseInt(cur.weatherCode ?? "113", 10);
|
||||
const sky = classify(code);
|
||||
const tempC = parseFloat(cur.temp_C ?? "0");
|
||||
const tempF = parseFloat(cur.temp_F ?? "32");
|
||||
const desc = (cur.weatherDesc?.[0]?.value ?? "").toLowerCase();
|
||||
const area = data.nearest_area?.[0];
|
||||
const city = (area?.areaName?.[0]?.value ?? loc).toUpperCase();
|
||||
const country = area?.country?.[0]?.value ?? "";
|
||||
|
||||
const planes: 1 | 2 = accent === "none" ? 1 : 2;
|
||||
const c = new Canvas(W, H, planes);
|
||||
const accentInk: Ink = planes === 2 ? 1 : 0;
|
||||
|
||||
const margin = W < 200 ? 4 : 8;
|
||||
|
||||
// Header: city + country on right.
|
||||
c.drawText(margin, margin, city, 0, 1);
|
||||
c.drawTextRight(W - margin, margin, country.toUpperCase(), 0, 1);
|
||||
c.hline(margin, margin + 9, W - 2 * margin, 0);
|
||||
|
||||
// Big icon + temperature
|
||||
const iconR = Math.min(20, Math.floor(H / 6));
|
||||
const ix = margin + iconR + 4;
|
||||
const iy = margin + 18 + iconR;
|
||||
drawIcon(c, sky, ix, iy, iconR, accentInk);
|
||||
|
||||
const t = `${units === "F" ? Math.round(tempF) : Math.round(tempC)}\xB0${units}`;
|
||||
let scale = W >= 260 ? 3 : 2;
|
||||
while (scale > 1 && c.textSize(t, scale).w > W - ix - iconR - margin * 2) scale--;
|
||||
c.drawText(ix + iconR + 8, iy - Math.floor(c.textSize(t, scale).h / 2), t, 0, scale);
|
||||
|
||||
// Description in italics-ish (just plain, but a row under).
|
||||
c.drawText(margin, iy + iconR + 6, desc, 0, 1);
|
||||
|
||||
// 2-day forecast.
|
||||
const fcs = data.weather?.slice(0, 2) ?? [];
|
||||
if (fcs.length > 0) {
|
||||
const baseY = H - margin - 9;
|
||||
const cellW = Math.floor((W - 2 * margin) / fcs.length);
|
||||
for (let i = 0; i < fcs.length; i++) {
|
||||
const f = fcs[i];
|
||||
const lo = units === "F" ? f.mintempF : f.mintempC;
|
||||
const hi = units === "F" ? f.maxtempF : f.maxtempC;
|
||||
const dn = f.date ? new Date(f.date).toLocaleDateString("en-US", { weekday: "short" }) : "";
|
||||
const txt = `${dn} ${hi}\xB0/${lo}\xB0`;
|
||||
c.drawText(margin + i * cellW, baseY, txt, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return c;
|
||||
},
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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.
|
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
idf_component_register(
|
||||
SRCS ""
|
||||
INCLUDE_DIRS "."
|
||||
)
|
||||
@@ -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 */
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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). */
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user