diff --git a/scripts/WEBAPP_README.md b/scripts/WEBAPP_README.md
new file mode 100644
index 0000000..cbcf322
--- /dev/null
+++ b/scripts/WEBAPP_README.md
@@ -0,0 +1,197 @@
+# AirCube Web Serial Monitor
+
+`aircube_webapp.html` is a browser-based replacement for the `aircube_app.py`
+desktop app. It reads the AirCube's live sensor stream over the **Web Serial
+API** and shows current values, scrolling charts, CSV logging, and device
+controls -- all from a single self-contained HTML file (no build step, no
+external libraries, works offline).
+
+It exists because the Python desktop app **cannot run on a Chromebook**. The
+sections below explain why, and document the one non-obvious detail (the DTR/RTS
+handshake) that makes reading work.
+
+---
+
+## Quick start
+
+1. **Un-share the device from Linux.** In ChromeOS Settings -> About ChromeOS ->
+ Linux -> USB preferences, make sure **"USB JTAG/serial debug unit"** is
+ **OFF** (not shared into Crostini). Web Serial reads through the ChromeOS host
+ driver, so the device must stay with the host.
+2. **Serve the page over a secure context.** Web Serial only works over `https`
+ or `http://localhost`. The simplest local option:
+ ```bash
+ cd scripts
+ python3 -m http.server 8000
+ ```
+ If `http://localhost:8000` is not reachable from the ChromeOS browser, add a
+ port-forward for TCP 8000 in ChromeOS Settings -> About ChromeOS -> Linux ->
+ Port forwarding.
+3. **Open it in Chrome** (or Edge): `http://localhost:8000/aircube_webapp.html`
+4. Click **Connect**, choose the AirCube in the picker, and live data appears.
+
+Opening the file directly with a `file://` URL will **not** work -- that is not a
+secure context and Web Serial is disabled there.
+
+---
+
+## Features
+
+Faithful to `aircube_app.py`:
+
+- **Live value cards**: Temperature, Humidity, AQI, eCO2, eTVOC. AQI is color
+ coded with the same bands as the desktop app and the device LED gradient:
+ green (<=50), yellow (<=100), orange (<=200), red (>200).
+- **Three scrolling charts** (canvas, no external chart library):
+ Temperature + Humidity, AQI, and eCO2 + eTVOC, with the x-axis in seconds
+ since the first sample.
+- **History window** control (default 300 points) limits how much the charts
+ show.
+- **CSV logging** with the identical 9-column header
+ (`timestamp, ens210_status, temperature_c, temperature_f, humidity,
+ ens16x_status, etvoc, eco2, aqi`):
+ - **Download CSV (session)** exports everything captured since connecting.
+ - **Start file log...** streams each sample to a chosen file via Chrome's
+ File System Access API (data is committed when you Stop the log).
+- **Sample count** and **connection status** in the header.
+
+Added on top of the desktop app (these use firmware commands the Python app
+never exposed):
+
+- **Temperature unit toggle** (C / F) -- updates the card and the chart live.
+- **Field definition tooltips** -- hover any value label for a plain-language
+ definition.
+- **Device controls**:
+ - **Get config** -> reads current LED intensity and readout period.
+ - **LED intensity** slider -> `set_intensity` (0.0 - 1.0).
+ - **Readout period** -> `set_readout_period` (100 - 10000 ms).
+ - **Request history dump (h)** -> dumps stored history as CSV into the log.
+
+---
+
+## Why the Python app does not work on a Chromebook
+
+This took a full debugging session to pin down. The findings, in order:
+
+The AirCube is an **ESP32-H2** using the chip's **native USB Serial/JTAG**
+peripheral (USB id **`0x303A:0x1001`**). It enumerates as a *vendor-specific*
+(class `0xFF`) device with three interfaces:
+
+| Interface | Endpoints | Purpose |
+|-----------|---------------------|----------------------------------|
+| 0 | interrupt IN `0x82` | CDC comm / control |
+| 1 | bulk OUT `0x01`, IN `0x81` | serial console stream (the JSON) |
+| 2 | bulk OUT/IN | JTAG |
+
+What we tried, and what happened:
+
+1. **pyserial (what `aircube_app.py` uses) sees nothing.** The Crostini
+ (termina) VM kernel has **no `cdc-acm` module** (`modprobe cdc-acm` fails),
+ so no `/dev/ttyACM*` node is ever created. `serial.tools.list_ports.comports()`
+ returns an empty list. (Reproduce with `test_serial_discovery.py`.)
+
+2. **pyusb in Crostini can open the device but cannot read.** It discovers and
+ opens `0x303A:0x1001` and finds endpoint `0x81`, but the device gates its TX
+ until the host marks the port "connected", and the required control transfer
+ (`SET_CONTROL_LINE_STATE`, a class interface-OUT request) **times out** through
+ ChromeOS/Crostini's USB passthrough. Class-IN requests (`GET_LINE_CODING`) and
+ standard control-OUT (`set_configuration`) work -- only class control-OUT is
+ dropped. (Reproduce with `test_pyusb_discovery.py`.)
+
+3. **WebUSB in the browser cannot claim the interface.** The ChromeOS *host*
+ kernel's `cdc-acm` driver owns the interface (`claimInterface` fails with
+ "Unable to claim interface"). That failure is the clue that the host exposes
+ the device as a serial port.
+
+4. **Web Serial works** -- it reads *through* the host `cdc-acm` driver instead
+ of fighting it, and it can assert DTR/RTS natively via `setSignals()`.
+
+### The key detail: DTR = 0, RTS = 1
+
+The ESP32-H2 USB Serial/JTAG only streams once the host marks the port
+"connected". The combination that works is:
+
+```js
+port.setSignals({ dataTerminalReady: false, requestToSend: true }); // DTR=0, RTS=1
+```
+
+Setting **DTR = 1 resets/halts** the chip (the USB Serial/JTAG ROM interprets
+certain DTR/RTS states as a reset-into-download-mode request), which produces
+total silence. This was the root cause of a long "why is there no data" hunt:
+every earlier attempt asserted DTR=1 and got nothing. The desktop app works on a
+normal laptop only because the laptop's `cdc-acm` driver handles this for it.
+
+The firmware streams sensor JSON unconditionally about once per second
+(`serial_send_sensor_data` in `firmware/main/serial_protocol.c`), so once the
+signal handshake is right, data flows immediately.
+
+---
+
+## Serial protocol reference
+
+**Sensor stream** (one newline-terminated JSON object per readout period):
+
+```json
+{"ens210":{"status":0,"temperature_c":22.50,"temperature_f":72.50,"humidity":45.00},
+ "ens16x":{"status":"OK","etvoc":120,"eco2":650,"aqi":95,"aqi_s":100,"aqi_uba":2},
+ "timestamp":123456}
+```
+`timestamp` is milliseconds since boot. `aqi` is the canonical TVOC-derived AQI
+(0-500). `aqi_s` (ScioSense relative score) and `aqi_uba` are USB-serial only.
+
+**Commands** (send as a newline-terminated line):
+
+| Command | Effect | Response |
+|---------|--------|----------|
+| `{"cmd":"get_config"}` | read settings | `{"config":{"intensity":f,"readout_period":ms}}` |
+| `{"cmd":"set_intensity","value":0.0-1.0}` | LED brightness | `{"status":"ok","cmd":"set_intensity","value":f}` |
+| `{"cmd":"set_readout_period","value":100-10000}` | sample period (ms) | `{"status":"ok",...}` |
+| `{"cmd":"get_history_info"}` | history metadata | `{"history_info":{...}}` |
+| `{"cmd":"get_history","start":n,"count":n}` | history page | history rows |
+| `{"cmd":"clear_history"}` | erase stored history | status |
+| `h` | dump full history as CSV | CSV text |
+
+---
+
+## Browser requirements
+
+- **Chrome or Edge** (Web Serial is not in Firefox or Safari).
+- A **secure context**: `https://` or `http://localhost`.
+- **File System Access API** (Chrome/Edge) is needed for "Start file log...";
+ "Download CSV" works everywhere Web Serial does.
+
+---
+
+## Troubleshooting
+
+- **Device not in the Connect picker** -> it is still shared into Linux (Crostini)
+ or held by another tab/app. Un-share it (Quick start step 1) and close other
+ connections.
+- **Connects but no data / byte counter stays at 0** -> the signal handshake is
+ wrong for your situation. The app uses DTR=0 RTS=1; if you forked it, do not
+ assert DTR=1 (it resets the chip). The standalone `webserial_aircube_test.html`
+ has manual signal buttons for experimenting.
+- **"Web Serial unavailable"** -> you opened the file over `file://` or plain
+ `http`. Serve it over `localhost` or `https`.
+- **Garbled characters** -> unrelated terminal/font issue; does not affect the
+ web app.
+
+---
+
+## Files in this folder
+
+| File | Purpose |
+|------|---------|
+| `aircube_webapp.html` | The full web app (this README documents it). |
+| `webserial_aircube_test.html` | Minimal Web Serial signal tester (DTR/RTS buttons, byte counter). |
+| `webusb_aircube_test.html` | Minimal WebUSB probe (shows why WebUSB cannot claim the interface). |
+| `test_serial_discovery.py` | Proves pyserial sees no ports in Crostini. |
+| `test_pyusb_discovery.py` | Proves pyusb can open but not read (DTR control-OUT times out). |
+| `aircube_app.py` | Original PyQt6 desktop app (works on a regular laptop, not a Chromebook). |
+
+---
+
+## License
+
+Apache License 2.0. See the repository `LICENSE` file. The web app carries the
+standard Apache 2.0 header at the top of `aircube_webapp.html`.
diff --git a/scripts/aircube_webapp.html b/scripts/aircube_webapp.html
new file mode 100644
index 0000000..c98b42b
--- /dev/null
+++ b/scripts/aircube_webapp.html
@@ -0,0 +1,454 @@
+
+
+
+
File log streams each sample to a chosen file (committed when you Stop).
+ Download exports everything captured this session.
+
+
+
+
Device controls
+
+
+
+
+
+
+
+ 0.50
+
+
+
+
+ ms
+
+
+
+
+
+
+
+
+
+ Serial log
+
+
+
+
+
+
+
+
diff --git a/scripts/test_pyusb_discovery.py b/scripts/test_pyusb_discovery.py
new file mode 100644
index 0000000..5e0a4a1
--- /dev/null
+++ b/scripts/test_pyusb_discovery.py
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+"""Minimal test: can the AirCube be found and read via pyusb (libusb)?
+
+This is the direct counterpart to test_serial_discovery.py. Instead of relying
+on a /dev/ttyACM* serial node (which Crostini often never creates for the
+ESP32-H2's native USB Serial/JTAG), it talks to the raw USB device through
+libusb. If this script finds the device and reads JSON lines while the serial
+script finds nothing, then switching aircube_app.py to pyusb is the fix.
+
+Run: python3 test_pyusb_discovery.py
+Deps: pyusb (pip install pyusb) + a libusb backend
+ - Debian/Crostini: sudo apt install libusb-1.0-0
+ - You may need a udev rule so the device is readable without root:
+ SUBSYSTEM=="usb", ATTR{idVendor}=="303a", MODE="0666"
+ (/etc/udev/rules.d/99-aircube.rules, then `sudo udevadm trigger`)
+ or just run this script with sudo for a quick test.
+"""
+
+import os
+import sys
+import time
+
+try:
+ import usb.core
+ import usb.util
+except ImportError:
+ sys.exit("pyusb is not installed. Run: pip install pyusb")
+
+# ESP32-H2 USB Serial/JTAG ("USB JTAG/serial debug unit").
+ESPRESSIF_VID = 0x303A
+JTAG_SERIAL_PID = 0x1001
+READ_SECONDS = 12
+
+
+def main():
+ print("=== pyusb discovery (raw libusb, bypasses /dev/ttyACM) ===\n")
+
+ # 1. Enumerate everything so we can see what libusb sees at all.
+ all_devs = list(usb.core.find(find_all=True))
+ if not all_devs:
+ print("libusb found NO usb devices at all.")
+ print("On Crostini, make sure the AirCube is shared into Linux:")
+ print(" ChromeOS Settings > 'USB JTAG/serial debug unit' > toggle into Linux.")
+ return
+
+ print(f"libusb sees {len(all_devs)} USB device(s):")
+ for d in all_devs:
+ print(f" VID:PID = 0x{d.idVendor:04X}:0x{d.idProduct:04X}")
+ print()
+
+ # 2. Find the AirCube specifically.
+ dev = usb.core.find(idVendor=ESPRESSIF_VID, idProduct=JTAG_SERIAL_PID)
+ if dev is None:
+ # Fall back to any Espressif device in case the PID differs.
+ dev = usb.core.find(idVendor=ESPRESSIF_VID)
+ if dev is None:
+ print("--> No Espressif (VID 0x303A) device found via libusb either.")
+ print(" The device is likely not shared into the Linux container yet.")
+ return
+
+ print(f"--> Found AirCube: 0x{dev.idVendor:04X}:0x{dev.idProduct:04X}")
+
+ try:
+ cfg = dev.get_active_configuration()
+ except usb.core.USBError:
+ dev.set_configuration()
+ cfg = dev.get_active_configuration()
+
+ # 3. Dump the real descriptor. The ESP32-H2 USB Serial/JTAG enumerates as a
+ # VENDOR-SPECIFIC device (every interface class 0xFF), not standard CDC --
+ # which is exactly why Crostini's cdc-acm driver never created /dev/ttyACM
+ # and pyserial saw nothing. Layout:
+ # interface 0: interrupt IN -> notifications / control
+ # interface 1: bulk OUT+IN -> the serial console stream (our JSON) <-- want
+ # interface 2: bulk OUT+IN -> JTAG <-- skip
+ # So we pick the FIRST interface that has both a bulk IN and bulk OUT.
+ print("\n --- configuration descriptor ---")
+ data_intf = None # interface object that owns the serial bulk IN
+ ep_in = None
+ comm_intf = 0 # interrupt/control interface = DTR/RTS target
+ for intf in cfg:
+ print(f" interface {intf.bInterfaceNumber} alt {intf.bAlternateSetting} "
+ f"class 0x{intf.bInterfaceClass:02X} subclass 0x{intf.bInterfaceSubClass:02X}")
+ bulk_in = bulk_out = None
+ for ep in intf:
+ direction = "IN" if usb.util.endpoint_direction(ep.bEndpointAddress) == usb.util.ENDPOINT_IN else "OUT"
+ ep_type = {0: "control", 1: "iso", 2: "bulk", 3: "interrupt"}[usb.util.endpoint_type(ep.bmAttributes)]
+ print(f" ep 0x{ep.bEndpointAddress:02X} {direction} {ep_type} maxpkt {ep.wMaxPacketSize}")
+ if usb.util.endpoint_type(ep.bmAttributes) == usb.util.ENDPOINT_TYPE_BULK:
+ if usb.util.endpoint_direction(ep.bEndpointAddress) == usb.util.ENDPOINT_IN:
+ bulk_in = ep
+ else:
+ bulk_out = ep
+ # First interface carrying both bulk IN and bulk OUT = the serial port.
+ if ep_in is None and bulk_in is not None and bulk_out is not None:
+ ep_in = bulk_in
+ data_intf = intf
+ print(" --------------------------------\n")
+
+ if ep_in is None:
+ print("--> No bulk IN endpoint found on any interface. Cannot read.")
+ return
+ print(f" data: bulk IN 0x{ep_in.bEndpointAddress:02X} on interface {data_intf.bInterfaceNumber}")
+ print(f" comm: control interface {comm_intf}")
+
+ # 4. Detach any kernel driver and claim the data interface so our IN tokens
+ # actually pull bytes (an unclaimed interface can let the kernel race us).
+ for n in {data_intf.bInterfaceNumber, comm_intf}:
+ if n is None:
+ continue
+ try:
+ if dev.is_kernel_driver_active(n):
+ dev.detach_kernel_driver(n)
+ print(f" detached kernel driver from interface {n}")
+ except (NotImplementedError, usb.core.USBError):
+ pass
+ for n in (data_intf.bInterfaceNumber, comm_intf):
+ try:
+ usb.util.claim_interface(dev, n)
+ print(f" claimed interface {n}")
+ except usb.core.USBError as e:
+ print(f" warning: could not claim interface {n} ({e})")
+
+ # 5. The ESP32-H2 docs say TX flushes after a newline and the device waits
+ # ~50ms for the host to request data -- i.e. actively reading the IN
+ # endpoint *should* be enough, no DTR needed. So by DEFAULT we do a clean
+ # read-only test and dump RAW HEX of whatever arrives (not just parsed
+ # JSON), to distinguish "no bytes at all" from "bytes that didn't parse".
+ #
+ # The DTR/CDC handshake (which keeps timing out through Crostini) is only
+ # attempted if you set TRY_DTR=1, so it can't wedge the endpoint by default.
+ if os.environ.get("TRY_DTR") == "1":
+ try:
+ coding = dev.ctrl_transfer(0xA1, 0x21, 0, comm_intf, 7, timeout=2000)
+ print(f" GET_LINE_CODING -> {bytes(coding).hex()}")
+ except usb.core.USBError as e:
+ print(f" GET_LINE_CODING failed ({e})")
+ for wValue, label in [(0x0003, "DTR|RTS"), (0x0001, "DTR")]:
+ try:
+ dev.ctrl_transfer(0x21, 0x22, wValue, comm_intf, None, timeout=3000)
+ print(f" SET_CONTROL_LINE_STATE OK [{label}]")
+ break
+ except usb.core.USBError as e:
+ print(f" SET_CONTROL_LINE_STATE failed [{label}]: {e}")
+
+ # Clear any stale halt/toggle on the IN endpoints before reading.
+ in_eps = [ep_in.bEndpointAddress]
+ if ep_in.bEndpointAddress != 0x83:
+ in_eps.append(0x83) # also probe the JTAG IN endpoint, just in case
+ for addr in in_eps:
+ try:
+ dev.clear_halt(addr)
+ except usb.core.USBError:
+ pass
+
+ print(f"\n Reading for {READ_SECONDS}s (move/breathe near the sensor)...")
+ print(f" polling IN endpoints: {', '.join(f'0x{a:02X}' for a in in_eps)}\n")
+ buf = bytearray()
+ total_bytes = 0
+ deadline = time.monotonic() + READ_SECONDS
+ while time.monotonic() < deadline:
+ for addr in in_eps:
+ try:
+ chunk = dev.read(addr, 64, timeout=200)
+ except usb.core.USBError as e:
+ if e.errno == 110: # timeout/NAK, keep polling
+ continue
+ print(f" read error on 0x{addr:02X}: {e}")
+ continue
+ if chunk:
+ raw = chunk.tobytes()
+ total_bytes += len(raw)
+ # Show raw hex so we see ANY traffic, even non-JSON.
+ print(f" RX 0x{addr:02X} [{len(raw)}B]: {raw.hex()}")
+ buf.extend(raw)
+ while b"\n" in buf:
+ line, _, buf = buf.partition(b"\n")
+ text = line.decode(errors="ignore").strip()
+ if text:
+ print(f" parsed: {text}")
+
+ print()
+ if total_bytes:
+ print(f"--> SUCCESS: read {total_bytes} bytes directly via pyusb.")
+ print(" Confirms pyusb is a viable replacement for the serial path.")
+ else:
+ print("--> No bytes on any IN endpoint. Re-run with TRY_DTR=1 to test whether")
+ print(" asserting DTR is required: sudo TRY_DTR=1 .../python .../test_pyusb_discovery.py")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_serial_discovery.py b/scripts/test_serial_discovery.py
new file mode 100644
index 0000000..50c4764
--- /dev/null
+++ b/scripts/test_serial_discovery.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""Minimal test: can the AirCube be found via pyserial?
+
+This mirrors exactly what aircube_app.py does to discover the device:
+it calls serial.tools.list_ports.comports() and lists what it finds.
+
+On a Chromebook (Crostini), the ESP32-H2's native USB Serial/JTAG device
+often does NOT show up here, because no cdc-acm /dev/ttyACM* node is created
+in the Linux container. If this script prints "No serial ports found" (or
+lists ports but none from Espressif), that confirms the pyserial path is the
+problem.
+
+Run: python3 test_serial_discovery.py
+Deps: pyserial (already a dependency of aircube_app.py)
+"""
+
+from serial.tools import list_ports
+
+# ESP32-H2 USB Serial/JTAG = Espressif vendor ID. PID for the
+# "USB JTAG/serial debug unit" is 0x1001.
+ESPRESSIF_VID = 0x303A
+
+
+def main():
+ print("=== pyserial discovery (same call aircube_app.py uses) ===\n")
+ ports = list(list_ports.comports())
+
+ if not ports:
+ print("No serial ports found.")
+ print("\n--> pyserial cannot see ANY device. This is the failure")
+ print(" the AirCube app hits on the Chromebook. Try test_pyusb_discovery.py.")
+ return
+
+ found_esp = False
+ for p in ports:
+ vid = f"0x{p.vid:04X}" if p.vid is not None else "----"
+ pid = f"0x{p.pid:04X}" if p.pid is not None else "----"
+ print(f" {p.device}")
+ print(f" description : {p.description}")
+ print(f" hwid : {p.hwid}")
+ print(f" VID:PID : {vid}:{pid}")
+ if p.vid == ESPRESSIF_VID:
+ found_esp = True
+ print(" ^^^ This is an Espressif device (likely the AirCube).")
+ print()
+
+ if found_esp:
+ print("--> An Espressif serial port WAS found. pyserial should work here.")
+ else:
+ print("--> Ports exist, but NONE are Espressif (VID 0x303A).")
+ print(" The AirCube is not exposed as a serial port. Try test_pyusb_discovery.py.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/webserial_aircube_test.html b/scripts/webserial_aircube_test.html
new file mode 100644
index 0000000..ea90775
--- /dev/null
+++ b/scripts/webserial_aircube_test.html
@@ -0,0 +1,136 @@
+
+
+
+
+AirCube Web Serial signal tester
+
+
+
+
AirCube Web Serial signal tester
+
+ Goal: find the DTR/RTS combination that makes the ESP32-H2 USB Serial/JTAG
+ stream. Open the port (no signals forced), then try buttons one at a time and
+ watch the byte counter. Setting RTS may reset the chip into download mode --
+ if the port disconnects, that is what happened.
+
+ Counterpart to test_pyusb_discovery.py, but via the browser's
+ WebUSB. This bypasses Crostini's USB passthrough (which drops the DTR
+ control-OUT) and talks to the ESP32-H2 USB Serial/JTAG directly. If this
+ reads JSON, a WebUSB-based reader is the way to run AirCube on a Chromebook.
+