The EvenHub Simulator (v0.7.0+) ships an HTTP control plane that lets you drive the simulated glasses from any process that can talk to a local socket — Python, Node, a CI script, or your own QA harness. This guide walks through one complete loop: boot the simulator, wait for the app to become responsive, screenshot the framebuffer, send a double_click, and assert that the system exit dialog appeared.
Reference: the full endpoint table lives in Simulator → Headless Automation.
When to reach for this
- Pre-submission checks — assert the rules from App Submission & QA Guidelines (lit pixels in the framebuffer, system exit dialog on root double-tap, no console errors at boot) before uploading a new
.ehpk. - Genesis Day judging automation — score submissions without manually clicking through every entry.
- Internal QA harness — regression-test SDK upgrades by replaying a journey across many apps.
- CI smoke tests — run a tiny "did the app even boot" check on every PR.
Boot the simulator with a control plane
Pass --automation-port when you launch the simulator. Pick any free port:
bash
evenhub-simulator http://localhost:5173 --automation-port 9898
# → control plane on http://127.0.0.1:9898Verify it's up:
bash
curl http://127.0.0.1:9898/api/ping
# pongThe end-to-end loop
The shape of every test is the same:
- Boot the simulator pointing at your dev server (or an
.ehpkURL). - Wait for ready. The simulator silently drops input until your first event-capturing container exists, so poll
GET /api/consolefor an "app ready" log line (or your own readiness signal). Allow ~4 s minimum after launch (SDK init +createStartUpPageContainer). - Snapshot the state —
GET /api/screenshot/glassesfor the framebuffer,GET /api/consolefor log entries. - Send input —
POST /api/inputwith{ "action": "click" | "double_click" | "up" | "down" }. - Snapshot again and assert.
Python example
python
import time
import io
import sys
from urllib.request import Request, urlopen
import json
from PIL import Image
BASE = "http://127.0.0.1:9898"
READY_MARKER = "[my-app] ready" # whatever your app logs once mounted
TIMEOUT_S = 30
def get_json(path: str):
with urlopen(f"{BASE}{path}") as r:
return json.loads(r.read())
def get_png(path: str) -> Image.Image:
with urlopen(f"{BASE}{path}") as r:
return Image.open(io.BytesIO(r.read()))
def post_json(path: str, body: dict):
req = Request(
f"{BASE}{path}",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(req) as r:
return r.read()
def wait_for_ready(timeout: float = TIMEOUT_S):
"""Poll the console buffer until the app prints its ready marker."""
deadline = time.time() + timeout
since_id = 0
while time.time() < deadline:
data = get_json(f"/api/console?since_id={since_id}")
for entry in data.get("entries", []):
since_id = max(since_id, entry["id"])
if READY_MARKER in entry.get("message", ""):
return
time.sleep(0.25)
raise TimeoutError(f"App did not log {READY_MARKER!r} within {timeout}s")
def lit_pixel_count(img: Image.Image) -> int:
"""LVGL framebuffer is RGBA; treat any pixel with alpha > 0 as lit."""
assert img.mode == "RGBA", f"expected RGBA, got {img.mode}"
return sum(1 for px in img.getdata() if px[3] > 0)
def main() -> int:
# 1. Health check
assert get_json("/api/ping") in ("pong", {"message": "pong"}), "simulator not up"
# 2. Wait for the app to mount
wait_for_ready()
# 3. Confirm something is actually drawn
boot = get_png("/api/screenshot/glasses")
assert lit_pixel_count(boot) > 100, "framebuffer is blank after ready"
# 4. Drive the root double-tap → expect the system exit dialog
post_json("/api/input", {"action": "double_click"})
time.sleep(0.5) # let the dialog render
after = get_png("/api/screenshot/glasses")
delta = abs(lit_pixel_count(after) - lit_pixel_count(boot))
assert delta > 50, "framebuffer did not change after double_click — exit dialog missing?"
print("OK — app booted, rendered, and produced an exit dialog on double-tap")
return 0
if __name__ == "__main__":
sys.exit(main())Node example
typescript
const BASE = 'http://127.0.0.1:9898'
const READY_MARKER = '[my-app] ready'
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`)
if (!res.ok) throw new Error(`${path} → ${res.status}`)
return res.json() as Promise<T>
}
async function getPng(path: string): Promise<Uint8Array> {
const res = await fetch(`${BASE}${path}`)
if (!res.ok) throw new Error(`${path} → ${res.status}`)
return new Uint8Array(await res.arrayBuffer())
}
async function postJson(path: string, body: unknown): Promise<void> {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(`${path} → ${res.status}`)
}
interface ConsoleEntry { id: number; message: string }
interface ConsoleResponse { entries: ConsoleEntry[]; total: number }
async function waitForReady(timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs
let sinceId = 0
while (Date.now() < deadline) {
const data = await getJson<ConsoleResponse>(`/api/console?since_id=${sinceId}`)
for (const entry of data.entries) {
sinceId = Math.max(sinceId, entry.id)
if (entry.message.includes(READY_MARKER)) return
}
await new Promise(r => setTimeout(r, 250))
}
throw new Error(`App did not log "${READY_MARKER}" within ${timeoutMs}ms`)
}
async function main(): Promise<void> {
await getJson('/api/ping')
await waitForReady()
const boot = await getPng('/api/screenshot/glasses')
if (boot.byteLength < 1000) throw new Error('framebuffer is suspiciously small')
await postJson('/api/input', { action: 'double_click' })
await new Promise(r => setTimeout(r, 500))
const after = await getPng('/api/screenshot/glasses')
if (Math.abs(after.byteLength - boot.byteLength) < 100) {
throw new Error('framebuffer did not change after double_click — exit dialog missing?')
}
console.log('OK — app booted, rendered, and produced an exit dialog on double-tap')
}
main().catch(err => {
console.error(err)
process.exit(1)
})Tip: the Node example uses byte-length deltas as a coarse "did anything change" check. For real assertions, decode the PNG (e.g. with
sharp) and use the samealpha > 0lit-pixel rule as the Python example.
Patterns and pitfalls
Read startup logs before clearing
Simulator boot logs (SDK init, manifest load, first createStartUpPageContainer result) are emitted exactly once. A common mistake:
python
# DON'T do this
post_json('/api/input', {'action': 'click'}) # implicit assume the app is up
clear_console() # wipes the boot logs you neededAlways poll for the ready marker (or read the log buffer) first, then clear:
python
wait_for_ready()
boot_logs = get_json('/api/console')
post_json('/api/console', {}) # safe to clear now (DELETE works too)Use since_id for incremental polling
Re-reading the entire console buffer on every tick wastes work and risks duplicate-handling. Track the highest id you've seen and pass it back:
python
data = get_json(f'/api/console?since_id={last_seen}')
for entry in data['entries']:
last_seen = max(last_seen, entry['id'])
handle(entry)Keep screenshots in RGBA
/api/screenshot/glasses returns RGBA on purpose. The Even G2 framebuffer renders both background and foreground in pure green; converting to RGB collapses them and your "lit pixel" check stops working. Test with pixel.alpha > 0, not by comparing RGB channels.
Wait for input capture
Posting input before createStartUpPageContainer has run is silently dropped — there is no error. Always wait for your readiness signal first. ~4 s after launch is a reasonable lower bound; keying off a log line is more reliable than sleeping.
Cleaning up
The control plane has no shutdown endpoint — kill the simulator process when you're done. In CI, wrap the simulator launch in a child-process supervisor that you can SIGTERM from your test runner's afterAll hook.
Related
- Simulator → Headless Automation — full endpoint reference
- App Submission & QA Guidelines — what to assert in your tests
- Page Lifecycle — events you can use as readiness / exit signals