- WiFi CSI-based indoor positioning system for self-hosted home environments - docs/plan/plan.md: full 9-phase implementation plan (65 gaps closed by analysis) - docs/research/: CSI fundamentals, physics, algorithms, signal processing, mesh topology, accuracy limits, literature - docs/notes/: recovery mechanisms, simulation testing, UX visualization - .marathon/instruction.md: per-iteration marathon instructions with detailed commit format - .marathon/start.sh: GLM-5 tmux launcher via ZAI proxy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
193 lines
6.2 KiB
Markdown
193 lines
6.2 KiB
Markdown
# Simulated ESP32 Installation Testing
|
||
|
||
The goal is to be able to develop and test the full installation flow — web installer, provisioning, OTA — without requiring a physical ESP32-S3 connected via USB.
|
||
|
||
---
|
||
|
||
## 1. Simulating the Web Installer (ESP Web Tools)
|
||
|
||
ESP Web Tools uses the browser's **Web Serial API**, which only activates on actual serial port connections. There is no official simulation mode. Three approaches:
|
||
|
||
### Option A: Virtual Serial Port (Linux)
|
||
|
||
Create a virtual serial port pair using `socat`:
|
||
|
||
```bash
|
||
socat -d -d pty,raw,echo=0 pty,raw,echo=0
|
||
# Creates: /dev/pts/3 <---> /dev/pts/4
|
||
```
|
||
|
||
Then write a Python script that mimics the ESP32 bootloader's ROM command protocol on one end of the pty. The installer's `esptool-js` library communicates using the same SLIP-framed protocol as `esptool.py`.
|
||
|
||
Key bootloader commands to emulate:
|
||
- `SYNC` (0x08) — ROM sync, 36-byte sequence
|
||
- `READ_REG` (0x0A) — returns chip registers including chip magic number
|
||
- `SPI_FLASH_MD5` — used for verification
|
||
|
||
The chip magic number determines the detected chip family:
|
||
```python
|
||
CHIP_MAGIC = {
|
||
0x6F51306F: "ESP32-S3", # Returns this to identify as S3
|
||
0x00F01D83: "ESP32",
|
||
0x000007C6: "ESP32-S2",
|
||
0x6921506F: "ESP32-C3",
|
||
}
|
||
```
|
||
|
||
Reference: `esptool-js` source at `github.com/espressif/esptool-js`.
|
||
|
||
### Option B: Chrome DevTools Protocol Mock
|
||
|
||
Use Playwright or Puppeteer to intercept Web Serial API calls and mock responses. More complex but allows end-to-end testing of the web UI without any serial hardware:
|
||
|
||
```javascript
|
||
// In Playwright test:
|
||
await page.exposeFunction('__mockSerial', async (command) => {
|
||
if (command === 'sync') return { success: true, chipFamily: 'ESP32-S3' };
|
||
if (command === 'flash') return { success: true };
|
||
});
|
||
```
|
||
|
||
This approach tests the UI logic (chip detection display, progress bars, error states) independently of hardware.
|
||
|
||
### Option C: Physical Target on Development Machine
|
||
|
||
The simplest approach: keep one physical ESP32-S3 permanently connected to the development machine via USB. Use it as the integration test target. Flash, verify detection works, then proceed to automated testing for everything above the hardware layer.
|
||
|
||
**Recommended for Spaxel:** Option C for hardware-level validation, Option B for CI pipeline testing of the UI.
|
||
|
||
---
|
||
|
||
## 2. Simulating the Provisioning Portal
|
||
|
||
The captive portal (SoftAP + HTTP server on the ESP32) can be tested by running the HTML form served by `provisioning.c` directly in a browser, pointing form submissions at a local Go server that mocks the NVS save:
|
||
|
||
```bash
|
||
# Run a mock provisioning endpoint:
|
||
go run ./cmd/mock-prov -port 80
|
||
|
||
# Open in browser: http://localhost/
|
||
# Submit form → check NVS keys were received correctly
|
||
```
|
||
|
||
The form HTML in `provisioning.c` is self-contained and renders in any browser. Test:
|
||
- Valid SSID + IP → 200 + "Rebooting" message
|
||
- Empty SSID → 400 Bad Request
|
||
- Very long strings → truncation handling
|
||
|
||
---
|
||
|
||
## 3. Simulating CSI Data (Node-Level)
|
||
|
||
For testing the mothership pipeline without physical nodes, write a CSI packet generator that produces valid UDP packets in Spaxel's wire format:
|
||
|
||
```go
|
||
// tools/csi-sim/main.go
|
||
package main
|
||
|
||
import (
|
||
"math"
|
||
"math/rand"
|
||
"net"
|
||
"time"
|
||
"encoding/binary"
|
||
)
|
||
|
||
func main() {
|
||
conn, _ := net.Dial("udp", "localhost:4210")
|
||
txMAC := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x01}
|
||
rxMAC := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x02}
|
||
|
||
for seq := uint32(0); ; seq++ {
|
||
pkt := buildCSIPacket(txMAC, rxMAC, seq, simulateHuman(seq))
|
||
conn.Write(pkt)
|
||
time.Sleep(10 * time.Millisecond) // 100 Hz
|
||
}
|
||
}
|
||
|
||
// simulateHuman generates amplitude perturbation pattern for a person
|
||
// walking across the Fresnel zone between tx and rx.
|
||
func simulateHuman(tick uint32) []float64 {
|
||
amp := make([]float64, 52)
|
||
t := float64(tick) * 0.01 // seconds
|
||
personX := 2.5 + 1.5*math.Sin(0.3*t) // oscillates across room
|
||
|
||
for k := range amp {
|
||
// Static background
|
||
amp[k] = 30.0 + float64(k)*0.1 + rand.NormFloat64()*2.0
|
||
// Person perturbation: Fresnel zone crossing produces cosine variation
|
||
amp[k] += 8.0 * math.Cos(2*math.Pi*personX/0.125 + float64(k)*0.2)
|
||
}
|
||
return amp
|
||
}
|
||
```
|
||
|
||
Scenarios to simulate:
|
||
- **Empty room**: baseline noise only
|
||
- **Walking person**: sinusoidal Fresnel zone crossing
|
||
- **Stationary person**: static offset above baseline (3–8 dB per affected link)
|
||
- **Two people**: superposition of two independent perturbation patterns
|
||
- **Breathing**: 0.25 Hz amplitude modulation on top of static offset
|
||
|
||
---
|
||
|
||
## 4. End-to-End CI Pipeline
|
||
|
||
For automated testing in CI (no hardware available):
|
||
|
||
```yaml
|
||
# .github/workflows/test.yml
|
||
jobs:
|
||
test-server:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Start mothership stack
|
||
run: docker-compose up -d broker spaxel
|
||
|
||
- name: Run CSI simulator
|
||
run: go run ./tools/csi-sim -duration 10s -nodes 4 -scenario walking
|
||
|
||
- name: Assert blobs detected
|
||
run: |
|
||
sleep 5 # let positioning engine accumulate
|
||
curl http://localhost/api/blobs | jq '.[] | select(.confidence > 0.5)' | wc -l | grep -v '^0$'
|
||
|
||
test-firmware-build:
|
||
runs-on: ubuntu-latest
|
||
container: espressif/idf:v5.3
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
- name: Build firmware
|
||
working-directory: node/
|
||
run: idf.py build
|
||
- name: Verify binary exists
|
||
run: test -f node/build/spaxel-node.bin
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Simulating OTA
|
||
|
||
Test the full OTA cycle without flashing real hardware:
|
||
|
||
1. Publish a firmware version via the API: `POST /api/ota/publish`
|
||
2. Run a mock node client that calls `GET /api/ota/check?version=0.0.1`
|
||
3. Verify it receives the update URL
|
||
4. Verify the binary is downloadable: `GET /api/ota/firmware/0.0.2/firmware.bin`
|
||
|
||
```bash
|
||
# Publish test firmware
|
||
curl -X POST http://localhost/api/ota/publish \
|
||
-F "version=0.0.2" \
|
||
-F "firmware=@node/build/spaxel-node.bin"
|
||
|
||
# Check OTA as a node running 0.0.1
|
||
curl "http://localhost/api/ota/check?version=0.0.1"
|
||
# Expected: {"version":"0.0.2","url":"/api/ota/firmware/0.0.2/firmware.bin"}
|
||
|
||
# Check OTA as a node already on latest
|
||
curl "http://localhost/api/ota/check?version=0.0.2"
|
||
# Expected: 204 No Content
|
||
```
|