diff --git a/.gitignore b/.gitignore index c019c92..e0149b2 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 7868831..f14ba4c 100644 --- a/README.md +++ b/README.md @@ -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 → → 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. diff --git a/application.fam b/application.fam index 53a1021..36b5d05 100644 --- a/application.fam +++ b/application.fam @@ -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", ], ) diff --git a/cloud-plugins/package-lock.json b/cloud-plugins/package-lock.json new file mode 100644 index 0000000..dcb41bd --- /dev/null +++ b/cloud-plugins/package-lock.json @@ -0,0 +1,1642 @@ +{ + "name": "tagtinker-cloud-plugins", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tagtinker-cloud-plugins", + "version": "1.0.0", + "devDependencies": { + "@cloudflare/workers-types": "^4.20240909.0", + "typescript": "^5.5.4", + "wrangler": "^3.78.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260426.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260426.1.tgz", + "integrity": "sha512-cBYeQaWwv/jFV8ualmwp6wIxmAf0rDe2DPPQwPbslKmPHqgv861YpAvm45r05K40QboZgxNQVIPgNkmtHqZeJQ==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/cloud-plugins/package.json b/cloud-plugins/package.json new file mode 100644 index 0000000..605e701 --- /dev/null +++ b/cloud-plugins/package.json @@ -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" + } +} diff --git a/cloud-plugins/src/canvas.ts b/cloud-plugins/src/canvas.ts new file mode 100644 index 0000000..a8b3309 --- /dev/null +++ b/cloud-plugins/src/canvas.ts @@ -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; + } +} diff --git a/cloud-plugins/src/font.ts b/cloud-plugins/src/font.ts new file mode 100644 index 0000000..e194915 --- /dev/null +++ b/cloud-plugins/src/font.ts @@ -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) +]; diff --git a/cloud-plugins/src/index.ts b/cloud-plugins/src/index.ts new file mode 100644 index 0000000..aff9b5a --- /dev/null +++ b/cloud-plugins/src/index.ts @@ -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=&h=&accent=&=... + * -> 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 = { + "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 { + 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 = {}; + 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 { + 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); + }, +}; diff --git a/cloud-plugins/src/plugin.ts b/cloud-plugins/src/plugin.ts new file mode 100644 index 0000000..5eb6b74 --- /dev/null +++ b/cloud-plugins/src/plugin.ts @@ -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, w: number, h: number, accent: AccentMode): Promise; +} diff --git a/cloud-plugins/src/plugins/crypto.ts b/cloud-plugins/src/plugins/crypto.ts new file mode 100644 index 0000000..3e390ac --- /dev/null +++ b/cloud-plugins/src/plugins/crypto.ts @@ -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 = { + BTC: "bitcoin", ETH: "ethereum", SOL: "solana", XRP: "ripple", + DOGE: "dogecoin", ADA: "cardano", BNB: "binancecoin", LINK: "chainlink", +}; +const RANGE_DAYS: Record = { "24H": 1, "7D": 7, "30D": 30 }; +const CCY_PREFIX: Record = { 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 { + 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; + }, +}; diff --git a/cloud-plugins/src/plugins/identicon.ts b/cloud-plugins/src/plugins/identicon.ts new file mode 100644 index 0000000..5c537fd --- /dev/null +++ b/cloud-plugins/src/plugins/identicon.ts @@ -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; + }, +}; diff --git a/cloud-plugins/src/plugins/weather.ts b/cloud-plugins/src/plugins/weather.ts new file mode 100644 index 0000000..635e2a9 --- /dev/null +++ b/cloud-plugins/src/plugins/weather.ts @@ -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 { + 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; + }, +}; diff --git a/cloud-plugins/tsconfig.json b/cloud-plugins/tsconfig.json new file mode 100644 index 0000000..dc96dd4 --- /dev/null +++ b/cloud-plugins/tsconfig.json @@ -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"] +} diff --git a/cloud-plugins/wrangler.toml b/cloud-plugins/wrangler.toml new file mode 100644 index 0000000..2ca1a64 --- /dev/null +++ b/cloud-plugins/wrangler.toml @@ -0,0 +1,7 @@ +name = "tagtinker" +main = "src/index.ts" +compatibility_date = "2024-09-23" + +# `npm run deploy` will publish to ..workers.dev. +# Once deployed, point the ESP firmware at the resulting URL via +# the FAP "WiFi Setup -> Server URL" field. diff --git a/esp32-wifi-fw/CMakeLists.txt b/esp32-wifi-fw/CMakeLists.txt new file mode 100644 index 0000000..89f806d --- /dev/null +++ b/esp32-wifi-fw/CMakeLists.txt @@ -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) diff --git a/esp32-wifi-fw/main/CMakeLists.txt b/esp32-wifi-fw/main/CMakeLists.txt new file mode 100644 index 0000000..56cb6ec --- /dev/null +++ b/esp32-wifi-fw/main/CMakeLists.txt @@ -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 +) diff --git a/esp32-wifi-fw/main/cloud_client.c b/esp32-wifi-fw/main/cloud_client.c new file mode 100644 index 0000000..9c77575 --- /dev/null +++ b/esp32-wifi-fw/main/cloud_client.c @@ -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 +#include +#include +#include + +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: /render/?w=<>&h=<>&accent=<>&=... */ + 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; +} diff --git a/esp32-wifi-fw/main/cloud_client.h b/esp32-wifi-fw/main/cloud_client.h new file mode 100644 index 0000000..50693b5 --- /dev/null +++ b/esp32-wifi-fw/main/cloud_client.h @@ -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 /plugins -> JSON manifest list + * GET /render/?...-> 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 +#include +#include + +#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 */ diff --git a/esp32-wifi-fw/main/main.c b/esp32-wifi-fw/main/main.c new file mode 100644 index 0000000..2926b2e --- /dev/null +++ b/esp32-wifi-fw/main/main.c @@ -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 /plugins -> forward N x PLUGIN + * Flipper RUN_PLUGIN -> GET /render/?...-> 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 +#include +#include + +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(); + } +} diff --git a/esp32-wifi-fw/main/wifi_link.c b/esp32-wifi-fw/main/wifi_link.c new file mode 100644 index 0000000..1f14aae --- /dev/null +++ b/esp32-wifi-fw/main/wifi_link.c @@ -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 +#include + +/* 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); +} diff --git a/esp32-wifi-fw/main/wifi_link.h b/esp32-wifi-fw/main/wifi_link.h new file mode 100644 index 0000000..911874a --- /dev/null +++ b/esp32-wifi-fw/main/wifi_link.h @@ -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 +#include +#include + +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 */ diff --git a/esp32-wifi-fw/main/wifi_net.c b/esp32-wifi-fw/main/wifi_net.c new file mode 100644 index 0000000..ed90a3f --- /dev/null +++ b/esp32-wifi-fw/main/wifi_net.c @@ -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 + +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; +} diff --git a/esp32-wifi-fw/main/wifi_net.h b/esp32-wifi-fw/main/wifi_net.h new file mode 100644 index 0000000..1df8585 --- /dev/null +++ b/esp32-wifi-fw/main/wifi_net.h @@ -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 +#include + +#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 */ diff --git a/esp32-wifi-fw/partitions.csv b/esp32-wifi-fw/partitions.csv new file mode 100644 index 0000000..9a9b020 --- /dev/null +++ b/esp32-wifi-fw/partitions.csv @@ -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, diff --git a/esp32-wifi-fw/sdkconfig.defaults b/esp32-wifi-fw/sdkconfig.defaults new file mode 100644 index 0000000..ee6a495 --- /dev/null +++ b/esp32-wifi-fw/sdkconfig.defaults @@ -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 diff --git a/esp32-wifi-fw/shared/CMakeLists.txt b/esp32-wifi-fw/shared/CMakeLists.txt new file mode 100644 index 0000000..d3f39b6 --- /dev/null +++ b/esp32-wifi-fw/shared/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "" + INCLUDE_DIRS "." +) diff --git a/esp32-wifi-fw/shared/tt_wifi_proto.h b/esp32-wifi-fw/shared/tt_wifi_proto.h new file mode 100644 index 0000000..dc1e308 --- /dev/null +++ b/esp32-wifi-fw/shared/tt_wifi_proto.h @@ -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 + +#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 */ diff --git a/scenes/tagtinker_scene.c b/scenes/tagtinker_scene.c index a13bdf1..d8a102d 100644 --- a/scenes/tagtinker_scene.c +++ b/scenes/tagtinker_scene.c @@ -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 = { diff --git a/scenes/tagtinker_scene.h b/scenes/tagtinker_scene.h index aa24341..434931f 100644 --- a/scenes/tagtinker_scene.h +++ b/scenes/tagtinker_scene.h @@ -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); diff --git a/scenes/tagtinker_scene_target_actions.c b/scenes/tagtinker_scene_target_actions.c index e23d436..0beb0d7 100644 --- a/scenes/tagtinker_scene_target_actions.c +++ b/scenes/tagtinker_scene_target_actions.c @@ -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]; diff --git a/scenes/tagtinker_scene_wifi_plugins.c b/scenes/tagtinker_scene_wifi_plugins.c new file mode 100644 index 0000000..b0ad40e --- /dev/null +++ b/scenes/tagtinker_scene_wifi_plugins.c @@ -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 +#include + +#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). */ +} diff --git a/scenes/tagtinker_scene_wifi_run.c b/scenes/tagtinker_scene_wifi_run.c new file mode 100644 index 0000000..65c95bd --- /dev/null +++ b/scenes/tagtinker_scene_wifi_run.c @@ -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 +#include +#include + +#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); +} diff --git a/scenes/tagtinker_scene_wifi_setup.c b/scenes/tagtinker_scene_wifi_setup.c new file mode 100644 index 0000000..f419801 --- /dev/null +++ b/scenes/tagtinker_scene_wifi_setup.c @@ -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 + +#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); +} diff --git a/shared/tt_wifi_proto_fap.h b/shared/tt_wifi_proto_fap.h new file mode 100644 index 0000000..cc6f487 --- /dev/null +++ b/shared/tt_wifi_proto_fap.h @@ -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 diff --git a/tagtinker_app.c b/tagtinker_app.c index 200d8bb..bd81b9a 100644 --- a/tagtinker_app.c +++ b/tagtinker_app.c @@ -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); diff --git a/tagtinker_app.h b/tagtinker_app.h index aa445cc..5b378ce 100644 --- a/tagtinker_app.h +++ b/tagtinker_app.h @@ -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, diff --git a/wifi/tagtinker_wifi.c b/wifi/tagtinker_wifi.c new file mode 100644 index 0000000..0bd9aa0 --- /dev/null +++ b/wifi/tagtinker_wifi.c @@ -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 +#include +#include +#include + +#include +#include + +#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; + } +} diff --git a/wifi/tagtinker_wifi.h b/wifi/tagtinker_wifi.h new file mode 100644 index 0000000..29144e9 --- /dev/null +++ b/wifi/tagtinker_wifi.h @@ -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 +#include +#include + +#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 */ diff --git a/wifi/tagtinker_wifi_bmp.c b/wifi/tagtinker_wifi_bmp.c new file mode 100644 index 0000000..63f91b8 --- /dev/null +++ b/wifi/tagtinker_wifi_bmp.c @@ -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 +#include +#include + +#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)); +} diff --git a/wifi/tagtinker_wifi_bmp.h b/wifi/tagtinker_wifi_bmp.h new file mode 100644 index 0000000..aff307d --- /dev/null +++ b/wifi/tagtinker_wifi_bmp.h @@ -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 +#include +#include + +#include + +/* 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