LuxLibre

FlashKa web pages (v0.7 and v0.6): control app and firmware installer

Two small web pages belong to the FlashKa keychain (flashlight and presenter remote), for the FlashKa v0.7 and the v0.6 alike. Both run entirely inside the browser: no server, no account, nothing is uploaded, no CDN, no web fonts. Each page is one file and also works offline.

File (folder app/) What it is Works in
index.html Start page with two big links. Every browser.
flashka-app.html Control app. Switch the light, choose the brightness and the profile, see the battery, set up the presenter mode, … over Bluetooth. Reads from the light whether it is a FlashKa v0.7 or v0.6 and shows that board’s numbers. Has a Demo mode that needs no flashlight. Also controls a v0.5 light (protocol 1) without the presenter features. Chrome or Edge on Android, Windows, macOS, Linux, ChromeOS. On iPhone and iPad the free Bluefy browser. Not Firefox, not Safari.
flashka-flasher.html Firmware installer. Puts the firmware on the flashlight through the USB-C port (Web Serial). The firmware images of both boards are inside the file; the person chooses the board first. Chrome or Edge on a computer (Windows, macOS, Linux, ChromeOS). Not on phones or tablets, not Firefox, not Safari.

Contents

  1. The control app (including presenter mode)
  2. The firmware installer
  3. Rebuilding the installer with new firmware
  4. Free hosting on GitHub Pages, step by step
  5. Why https, and opening the files directly
  6. Where the protocol document leaves room
  7. Tests and screenshots
  8. What could not be tested
  9. Files

1. The control app

app/flashka-app.html speaks the Bluetooth protocol of ble_protocol.md (version 2, and still version 1 of the v0.5 lights) through Web Bluetooth: requestDevice with the name prefixes FlashKa and Aurora (the second one only so that a v0.5 light is still found), the FlashKa Control Service (the v1 UUIDs are unchanged) and the Battery Service, notifications of Status and Battery Level, one GATT operation at a time.

Light says (Status byte 0) App does
1 (v0.5 firmware) Everything of v0.5. The Presenter card is hidden, the Presenter characteristic is never read, the footer says “protocol v1”.
2 (v0.6 firmware) Everything, plus the Presenter card. Status is 20 bytes.
anything else Works as far as it can and shows a toast “Version mismatch”.

If a protocol 2 light does not offer the Presenter characteristic, the card is hidden too.

Two boards: FlashKa v0.7 and v0.6

The protocol is the same on both boards, but the numbers behind it are not: the v0.7 (firmware 0.7.0) has its own built-in profiles, allows at most 2850 mA of turbo in a Custom profile instead of 3000 mA, holds its temperature just below the profile’s temperature limit instead of dimming linearly above it (protocol document section 9), and its LED sits behind an optic whose output the app can estimate. The app reads the board from the light’s Info characteristic (docs/firmware.md section 9):

Info text Board App shows
FlashKa fw 0.7.x FlashKa v0.7 the v0.7 profile tables (cards, Custom default), Custom Turbo up to 2850 mA, and the estimated light out of the front: “1530 mA · 601 lm” in the status, “High 1530 mA (601 lm)” on the profile cards, with a note that these are estimates (LED data at 85 °C inside the LED x the 87 % optic, not measured); in the Custom editor the temperature limit reads “The light keeps its temperature just below this.” and the battery current limit “Protects the cell (rated 3 A). Near 3.0 A the battery protection can switch the light off until USB is plugged in; if that happens in turbo, use 2500.”; in turbo from a part-empty cell the status shows the current the LED really gets (less than the set point, protocol document section 9)
FlashKa fw 0.6.x FlashKa v0.6 the tables of the protocol document, Custom Turbo up to 3000 mA, currents only; temperature limit “Above this the light is dimmed.”
anything else, or not read yet unknown (a v0.5 light, a future firmware) as FlashKa v0.6

The lumens use the firmware’s own LED table and integer arithmetic, so the app and the firmware give the same numbers (a unit test compares them with flashka-core/src/board.rs). The light clamps every Custom value anyway (the profile is read back after each write), so a wrong guess could only show a range the light then corrects.

What it shows

What it does

Presenter mode (protocol 2)

The Presenter card (specification section 4.3) is only shown for a protocol 2 light.

FACTORY_RESET (0x7F) exists in the protocol module and is tested, but the app has no button for it on purpose.

The flashlight listens for Bluetooth only in “windows” (protocol document section 1). If it is not in the list: switch the light on, or click the main button three times quickly while it is off (the ring around the main button blinks blue), or plug in USB. The start screen of the app says so.

Demo mode

“Try demo mode” on the start screen runs a flashlight written in JavaScript inside the page. It is not a mock of the screen: it receives the same 1 to 3 byte commands, 20-byte profile writes and 4-byte presenter writes, and answers with the same 20-byte (protocol 2) Status packets, produced by the same encoder and decoder that the real Bluetooth link uses. It returns the same ATT error codes. A “Demo tools” card acts like things a person does to a real flashlight: press the flashlight button (starts the 3 s app lock), lose the Bluetooth connection, plug in USB, a hot day (45 degC air), battery charge slider, a drain speed of real time, 20 x or 120 x, and the presenter features: “Light model” (FlashKa v0.7, FlashKa v0.6 - the default - or a v0.5 light with protocol 1; changing it restarts the demo), “A paired computer is in range”, “Hold < and > for 10 s (forget computers)”. While the demo light is in presenter mode the app is disconnected, so two more demo buttons appear in the banner: “A computer pairs with the light” and “Hold < and > for 2 s (leave presenter mode)”, then Reconnect.

The model is small and simplified. It follows protocol document section 6 (and the specification, section 4.3, for presenter mode), the rest is plausible, not measured:

Part Model
Battery 800 mAh (two LP601646 cells), open-circuit voltage 3.00 V (0 %), 3.30 (3 %), 3.55 (10 %), 3.66 (20 %), 3.76 (40 %), 3.87 (60 %), 4.00 (80 %), 4.19 V (100 %); internal resistance 0.15 ohm — the same capacity the firmware’s runtime estimate uses (docs/firmware.md).
LED forward voltage 2.75 V + 0.15 V/A, step-down converter efficiency 0.9, so the battery current is V_LED x I / (0.9 x V_BAT) (document section 6); 8 mA for the electronics (none from the battery while USB is plugged in).
Heat 75 % of the LED’s electrical power becomes heat; 7.5 K/W to the air, time constant 70 s; air 24 degC (45 degC on a “hot day”).
Protection Temperature: linear reduction from the profile limit, 15 degC above it the level is Low, above 70 degC Moon, above 80 degC off. Low voltage: one level down every 5 s below the profile’s step-down voltage, Moon below 3.10 V, off below 3.00 V (switch-on refused until the cell is above 3.30 V or USB is present). Cell current limit of the profile. Turbo refused within 5 degC of the temperature limit.
Turbo Runs for the profile’s turbo time, then steps down; never remembered for the next switch-on.
Charging Only with USB, between 0 and 45 degC and while the LED draws 500 mA or less; 280 mA, tapering above 80 % (the value the firmware assumes; the charger’s nominal current is about 300 mA, docs/firmware.md).
Button priority 3 s app lock after every button press.
Runtime estimate (remaining charge minus a 5 % reserve) / battery current.
Status packets On every change and at least every 2 s.
Presenter mode PRESENTER 1: lockout 0x83, battery at 3 % or less 0x82; otherwise the main LED goes off, mode 1 and the link ends 0.4 s later. The light is not connectable until presenter mode ends. Idle timer (the demo speed-up applies to it), battery empty and 2 s of < + > end it. A computer that pairs gives mode 2; at most 4 bonds. About 20 mA extra (an estimate, like the firmware’s). In presenter mode the main button is the B key and does not switch the light.
Presenter settings Written values are clamped (key set 0-1, idle 5-120); SAVE stores them; FACTORY_RESET restores 30 min / Page keys / no swap (paired computers are kept, assumption).
v0.5 light createFlashlight({ protocol: 1 }): 18-byte Status, no Presenter characteristic, opcodes 0x0A and 0x0B are “invalid” (0x81).
FlashKa v0.7 createFlashlight({ model: 'v07' }): Info “FlashKa fw 0.7.0”, the v0.7 profiles (cell limit 2800 mA in Extreme and Balanced, low-voltage step-down 3.15 / 3.20 / 3.25 V, Endurance 47 degC), Custom Turbo clamped to 2850 mA; one Vapcell INR14500 F15 as 1400 mAh (the firmware’s figure) with its open-circuit curve (3.00 V empty, 3.25 V at 5 %, 3.38 V at 10 %, 3.47 V at 20 %, 3.68 V at 50 %, 3.95 V at 80 %, 4.20 V full) and 0.132 ohm; LED 2.80 V + 0.15 V/A, converter efficiency 0.87; when the cell sags the converter runs at 100 % duty and the LED gets at most (V - 2.80 V) / 0.278 ohm, so turbo dims on a half-full cell (about 2.1 A at 50 %); charging at 475 mA; 85 % of the LED power becomes heat, 15.4 K/W to the air, time constant 450 s (the octagonal body of the v0.7 simulation in docs/firmware.md section 9 as one node); temperature: the profile limit is held by the regulator of docs/firmware.md section 9.11 (the same law, in floating point; with the one node the current settles more slowly than in the firmware’s three-node simulation), Moon above 70 degC and off above 80 degC as above. The rows above describe the v0.6 light.

2. The firmware installer

app/flashka-flasher.html installs the firmware through the USB-C port with esptool-js 0.7.0 over Web Serial. The page, esptool-js, the helper program for the ESP32-C3 and the firmware image are all inside the one file: no download happens while installing.

What a person does

  1. Which FlashKa do you have? Two pictures: “FlashKa v0.7 - three keys in the middle” and “FlashKa v0.6 - stainless button”. Nothing can be installed before one is chosen; each shows the firmware version included for it.
  2. Plug in the flashlight with a USB-C data cable, directly into the computer.
  3. Connect: click the button and choose the port named “USB JTAG/serial debug unit” (Windows: “USB Serial Device (COMx)”) in the browser’s list.
  4. Install firmware: the firmware of the chosen board; a progress bar shows preparing, writing, checking, restarting.
  5. Done: unplug the cable and plug it in again; press the flashlight’s button once if the light does not come on.

A box “Something not working?” covers: no port in the list (different cable, charge-only cables have no data wires; different USB port; “Show all serial ports”), a port that is listed but fails to connect (unplug, hold the button, plug in, let go: this forces the chip’s built-in download mode; Connect again), errors while installing, a busy port, and a dark light after installing. The page says up front that phones and tablets cannot install firmware and that Chrome or Edge on a computer is needed.

What happens behind the buttons

Safety

Without Web Serial (Firefox, Safari, iPhone, iPad, Android, an insecure address) the page explains why and disables Connect. It decides by looking for Web Serial, not by guessing the device, so any browser that offers it can connect.


3. Rebuilding the installer with new firmware

State of the folder: app/flashka-flasher.html is the release build (npm run build:release): it embeds

Board Image Size SHA-256
FlashKa v0.7 firmware/dist/flashka-fw-v0.7.0-merged.bin 535,328 bytes 6554028f31b3e86c958335a1b03b4fd6f2a95000f727d752f4cb891318e79f96
FlashKa v0.6 firmware/dist/flashka-fw-v0.6.0-merged.bin 534,736 bytes e21058791bab990439835fbd977eecfed385af5837405e36ae923cb6083a8fdd

The page is about 1.6 MB. app/flashka-flasher.html is generated. It contains copies of both images, so it must be rebuilt every time firmware/build.sh produces a new one (BOARD=v07 for the v0.7, BOARD=v06 or nothing for the v0.6).

cd tools/web
npm install                 # once; needs Node.js 18 or newer and the npm registry (esptool-js, esbuild, test tools)
node build_flasher.mjs      # the same as: npm run build

The script prints what it embedded, for example

Wrote .../app/flashka-flasher.html
  page: 1.60 MB  (script 202 KB, esptool-js 0.7.0, minified)
  FlashKa v0.7: flashka-fw-v0.7.0-merged.bin  v0.7.0  523 KB (535328 bytes)  SHA-256 6554028f...
  FlashKa v0.6: flashka-fw-v0.6.0-merged.bin  v0.6.0  522 KB (534736 bytes)  SHA-256 e2105879...

Compare the SHA-256 values with sha256sum firmware/dist/*-merged.bin; the page shows the value of the chosen board in step 4. The output depends only on its inputs (no time stamps): building twice gives byte-identical pages. After a rebuild, upload the new flashka-flasher.html again (section 4).

Option Meaning
--bin-v07 <file> FlashKa v0.7 image. Default firmware/dist/flashka-fw-v0.7.0-merged.bin. Pass it when the version changes.
--bin-v06 <file> FlashKa v0.6 image. Default firmware/dist/flashka-fw-v0.6.0-merged.bin.
--out <file> Page to write. Default app/flashka-flasher.html.
--require-firmware Fail instead of building the placeholder when the image is missing (npm run build:release).
--placeholder Build the placeholder page even if the image exists (npm run build:placeholder).
--allow-unmerged Embed an image that does not look like a merged bootloader + partition table + app image.
--allow-board-mismatch Embed an image whose app descriptor names the other board (tests only; the page still refuses to install it).
--no-minify Readable JavaScript in the page, for debugging.
--help List the options.

The version shown for each board is read from the image’s app descriptor (--bin and --version of earlier versions are gone).

No firmware yet? If an image does not exist the script still builds the page, but says so loudly (a box of asterisks “PLACEHOLDER BUILD - NO FIRMWARE IS EMBEDDED …” on the console). That board then says “No firmware for it in this page” in step 1; with no image at all the page shows a yellow banner, keeps its Install button disabled and still lets a person pick a .bin under Advanced. Nothing is embedded and no hash is shown for a missing image. Run the command again once the firmware exists.

The build refuses an image that would not work at address 0x0: too small, not an ESP image, made for another chip, or not a complete merged image (unless --allow-unmerged), and an image of the other board (unless --allow-board-mismatch). It bundles only the ESP32-C3 helper program of esptool-js, not the other 13 chips, and keeps esptool-js’s error class names (the page decides on them).


4. Free hosting on GitHub Pages, step by step

GitHub Pages serves a folder of files as a web site at an https:// address, for free. You need the three files from the app/ folder: index.html, flashka-app.html, flashka-flasher.html. (The screenshots folder is only for the user guide; do not upload it.) GitHub’s screens change now and then; if a button has another name, look for the closest one.

What to know first. On the free plan the repository must be public, so everybody can see its files, including the firmware that is inside flashka-flasher.html. The pages contain no secrets and no accounts. The flashlight itself has no pairing: whoever is near it while its Bluetooth window is open can use the app.

  1. Create a GitHub account. Go to github.com, click Sign up, enter an e-mail address, a password and a user name (the name becomes part of the web address, for example maria-k), solve the puzzle, enter the code that is mailed to you. Choose the free plan. An unconfirmed e-mail address stops Pages from working, so confirm it.
  2. Create a repository. Click the + at the top right, then New repository. Repository name: for example flashka-keychain (letters, digits and hyphens, no spaces). Choose Public. Tick Add a README file. Click Create repository.
  3. Upload the pages. In the new repository click Add file, then Upload files. Drag the three files (index.html, flashka-app.html, flashka-flasher.html) from the app folder into the window (or click “choose your files”). They must end up at the top level of the repository, not in a sub-folder. Wait until all three are listed, scroll down and click Commit changes.
  4. Switch Pages on. Click the Settings tab of the repository, then Pages in the list on the left. Under “Build and deployment” set Source to Deploy from a branch. Under Branch choose main and the folder / (root), then click Save.
  5. Wait one or two minutes, then reload the Pages settings page. A box says “Your site is live at https://USER.github.io/REPOSITORY/”. (The Actions tab shows the progress: a yellow dot while it works, a green check when done.)
  6. Open it. With the example names:
    • start page: https://maria-k.github.io/flashka-keychain/
    • control app: https://maria-k.github.io/flashka-keychain/flashka-app.html
    • firmware installer: https://maria-k.github.io/flashka-keychain/flashka-flasher.html

    The user name in the address is written in lower case. Send these addresses to anyone who needs them. On an Android phone open the app address in Chrome (menu, “Add to Home screen” gives an icon); on an iPhone open it in Bluefy.

  7. Update a page later, for example after rebuilding the installer: open the repository, Add file, Upload files, drag the new file with the same name, Commit changes. The site updates after a minute or two. If the old version still shows, reload with Ctrl+Shift+R (Cmd+Shift+R on a Mac).
  8. Take it down: Settings, Pages, switch the source off, or delete the repository (Settings, Danger zone).

Any other static web host that serves https works the same way: upload the same three files.


5. Why https, and opening the files directly

Web Bluetooth and Web Serial let a web page talk to hardware. Browsers allow that only in a secure context: a page served over https://, from http://localhost on the same computer, or opened as a local file in Chrome or Edge. On a plain http:// address the browser removes navigator.bluetooth and navigator.serial, so the pages would only say that the browser cannot do it. GitHub Pages serves everything over https automatically (Settings, Pages, “Enforce HTTPS” should be ticked; it is the default).

Opening the file directly works too, on a computer in Chrome or Edge: download the file, then double-click it or drag it into a browser window; the address bar shows file:///.... Chrome counts such a page as secure, so Connect works for the control app (the Demo button works in any browser). The installer should behave the same way; that was only tested with a pretend serial port in headless Chromium (section 8). Phones make opening downloaded files awkward: use the GitHub address there, with Chrome on Android or Bluefy on iPhone. Opening a file does not work in Firefox or Safari because they have neither API.


6. Where the protocol document leaves room

ble_protocol.md is implemented exactly. These are the places where it was ambiguous or silent, and what the app does. docs/firmware.md section 3 lists how the firmware decided the same points; there is no conflict.

Topic Document App
KEEPALIVE argument “u8 seconds” but “max 600 s” (a byte holds 255). Sends the literal one-byte form with 255 s first (about 4 minutes, which the firmware accepts). Only if the flashlight refuses that as invalid does it try a 3-byte form with a u16 of 600 s.
LEVEL and OUTPUT_MA while the light is off Silent whether they switch the light on. The level buttons send LEVEL, then ON, then read Status and repeat LEVEL if the light came up at another level (switch-on uses the remembered level). The slider sends OUTPUT_MA and shows what Status reports. The firmware switches on for all three.
Turbo as remembered level Silent. Turbo is never the level used at the next switch-on (the firmware does the same).
Built-in profiles No flag bits; Endurance turbo “none”. Extreme and Balanced: turbo allowed, remember level, battery on RGB. Endurance: no turbo flag, turbo = High, 0 s, step-down = High. Custom starts as Balanced. Same values as firmware/flashka-core/src/profile.rs (on a FlashKa v0.7 its board-v07 tables, compared by a unit test).
Board Not named by the protocol. Read from the Info text (section 1): FlashKa fw 0.7.x = FlashKa v0.7, 0.6.x = v0.6.
When “turbo” exists “turbo time 0 = turbo = High”. Turbo exists when the flag is set, the time is above 0 and the turbo current is above High.
ATT error codes “returned to the write”. Browsers differ in how they report an ATT application error, and often not as a number. The app looks for 0x80 to 0x83 in numeric error properties and in the error text (hex, or decimal after a word like “error”), and otherwise infers the reason from the last Status (lockout, app lock, temperature, battery); inferred reasons are worded as guesses.
Status interval “at least every 2 s”. The app does not rely on it: countdowns are computed locally between packets, and Status is read when none arrived for 6 s. (The firmware sends on change and every 2 s.)
Presenter commands PRESENTER (0x0A) and FORGET_HOSTS (0x0B) are listed in the specification; their errors are not. After 0x0A 0x01 the app does not wait for a Status packet: the link ends. Refusals are shown with the usual messages (0x83 lockout, 0x82 protection). It treats the end of the link right after the command as the expected result, not as a lost connection.
Presenter byte 3 and undefined flag bits “reserved”. Read, kept and written back unchanged.
Device name prefix FlashKa-XXXX. The chooser also lists Aurora- names so that a v0.5 light can still be used with this app.
Presenter mode and the app “The phone app cannot connect while presenter mode is active.” Explained in the card and in the banner; Reconnect gives a hint if it fails.
Battery level Read and notify. Used when present; Status byte 8 is the fallback if the Battery Service is missing (tested).

The cells are two LP601646 in parallel, about 800 mAh, on the v0.6 (v0.4 had one 102050 pouch of about 1000 mAh) and one Vapcell INR14500 F15, 1500 mAh (1400 mAh in the firmware’s estimates), on the v0.7; the app only shows what the firmware reports.


7. Tests and screenshots

cd tools/web
npm install
npm run typecheck           # TypeScript checks the installer's JavaScript (JSDoc types), no output = clean
npm test                    # Node unit tests, about 2 minutes
npm run test:browser        # headless Chromium: both pages, writes the screenshots

The browser tests need a Chromium that Playwright can find. Where one is preinstalled set PLAYWRIGHT_BROWSERS_PATH (e.g. /opt/pw-browsers); otherwise run npx playwright install chromium once. npm install itself downloads no browser. Run only one page with node browser/run-app-tests.mjs or node browser/run-flasher-tests.mjs; add --no-shots to skip the screenshots. npm run test:all runs everything.

Node unit tests (test/, node --test):

Browser tests (browser/, Playwright with headless Chromium):

Screenshots (app/screenshots/, 390x844 phone at 2x for the app, 1x for the installer, and 1280x800 desktop; dark in the name means the dark theme):

File Shows
index-390.png, index-1280.png Start page
app-start-*.png, app-dark-start-*.png Start screen of the control app (Connect, Demo)
app-unsupported-*.png Explanation shown by a browser without Web Bluetooth
app-demo-off-*.png, app-demo-on-*.png, app-demo-on-390-full.png Demo flashlight off, on at High (also dark variants, and the whole page on a phone)
app-light-controls-390.png, app-profiles-390.png, app-settings-390.png Light card, profile cards, Settings card (phone, also dark)
app-turbo-*.png Turbo running with the countdown bar
app-locked-*.png “App locked for N s - the button has priority”
app-editor-*.png, app-editor-buttons-390.png, app-editor-error-*.png Custom profile editor, its buttons, a validation message
app-lost-*.png Connection lost, Reconnect
app-presenter-*.png, app-dark-presenter-*.png Presenter card (key map, start button, settings)
app-presenter-help-390.png “How to pair a computer or phone” opened
app-presenter-mode-*.png, app-dark-presenter-mode-*.png After “Start presenter mode”: the app has let go (demo helpers shown in the demo)
app-presenter-forget-*.png Confirmation before forgetting the paired computers
app-v07-demo-on-*.png, app-v07-profiles-*.png Demo light “FlashKa v0.7”: High with the estimated lumens, the v0.7 profile cards and the lumens note
flasher-start-*.png, flasher-start-390-full.png Installer, steps 1 to 5 and the troubleshooting box
flasher-board-*.png Step 1, the board choice with the two pictures
flasher-mismatch-*.png A FlashKa v0.6 firmware found on the flashlight after choosing the v0.7: the warning and the confirmation tick
flasher-connected-*.png Flashlight found, firmware card, factory-reset box, Install button
flasher-installing-*.png, flasher-done-*.png Progress bar, finished installation
flasher-error-*.png Flashlight does not answer: the download-mode steps
flasher-unsupported-*.png Installer in a browser without Web Serial (Firefox)
flasher-placeholder-*.png Placeholder build without firmware
flasher-advanced-*.png “Use a different .bin file” with a file that needs a tick
flasher-dark-start-*.png, flasher-dark-installing-*.png, flasher-dark-done-*.png Dark theme

8. What could not be tested

No flashlight, serial port or Bluetooth adapter was available. Everything below is therefore untested and needs one real run before the pages are given to people:


9. Files

app/
  index.html                start page
  flashka-app.html           control app (hand-written, single file; protocol and demo flashlight are inline <script> blocks)
  flashka-flasher.html       firmware installer (BUILT by tools/web/build_flasher.mjs, contains the firmware of both boards)
  screenshots/              pictures made by the browser tests (not part of the web site)
tools/web/
  build_flasher.mjs         builds app/flashka-flasher.html (section 3)
  flasher/                  sources of the installer
    flasher.template.html     page, CSS and the two placeholders the build fills in
    main.js                   the page's logic (steps, progress, errors)
    backend.js                connect and install with esptool-js (the part that talks to the chip)
    friendly.js               error text for people
    image.js, md5.js          image checks, the two boards and the app descriptor, MD5 and SHA-256 helpers
    board-v07.webp, board-v06.webp   the pictures of step 1 (renders of the two lights)
    stubs.js                  replaces esptool-js's list of helper programs by the ESP32-C3's
  test/                     Node unit tests and helpers (a pretend ESP32-C3, synthetic images)
  browser/                  Playwright test runners, pretend navigator.bluetooth / navigator.serial
  package.json, package-lock.json, tsconfig.json
  .test-output/, node_modules/   generated, ignored by git

Versions used: esptool-js 0.7.0, esbuild 0.28.2, Playwright 1.56.0, TypeScript 5.9.3, Node.js 22.