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 @@ + + + + + +AirCube - Air Quality Monitor (Web Serial) + + + +
+

AirCube

+ + Disconnected + + + Samples: 0 + +
+ +
+
+
+
+
Temperature
--.-
°C
+
Humidity
--.-
%
+
Air Quality Index
---
AQI
+
eCO2
----
ppm
+
eTVOC
----
ppb
+
+
+ +
+

Sensor history

+ + + +
+ +
+

CSV logging

+
+ + + +
+
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. +

+ +
+ + bytes: 0 +
+
+ + + + + +
+ +
+
Temp (F)--
+
Humidity--
+
eCO2--
+
TVOC--
+
AQI--
+
+
+ + + + diff --git a/scripts/webusb_aircube_test.html b/scripts/webusb_aircube_test.html new file mode 100644 index 0000000..8196e98 --- /dev/null +++ b/scripts/webusb_aircube_test.html @@ -0,0 +1,142 @@ + + + + +AirCube WebUSB test + + + +

AirCube WebUSB test

+

+ 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. +

+ +
+
Temp (F)--
+
Humidity--
+
eCO2--
+
TVOC--
+
AQI--
+
+
+ + + +