Examples
Telemetry
About
Live telemetry: devices send readings over UDP, pages see them as they come.
quillo run telemetry.qwe --watch then open http://localhost:8080
quillo run sensors.qwe (in another terminal: four sensors)
Each reading is a UDP packet of JSON, {"id": "boiler", "v": 71.2}, sent to
port 9000 (any device can: echo '{"id":"x","v":1}' | nc -u -w0 127.0.0.1 9000).
The server keeps the last minute of each sensor and broadcasts every reading
on the channel "readings". Pages open the channel, and get the readings over
WebTransport, as UDP datagrams too: a reading lost on the way is simply
replaced by the next one, and nothing waits for it.
Run it
$ quillo run telemetry.qwe --watchtelemetry.qwe
telemetry.qwe
let KEEP = 120 // readings kept per sensor (a minute, at two a second)let sensors = {} // id -> { id, last, history: [values], at }let received = 0udp.listen(9000, (p) => { let r = json.parse(p.text ?? "") if is_err(r) || type_of(r.id) != "str" || type_of(r.v) == "null" { return } received += 1 let before = sensors[r.id].history ?? [] let history = before + [r.v] let s = { id: r.id, last: r.v, at: p.at, history: len(history) > KEEP ? drop(history, len(history) - KEEP) : history } sensors = set(sensors, r.id, s) channel.broadcast("readings", { id: r.id, v: r.v, at: p.at })})// What a page starts with; the channel brings the rest.server fn snapshot() -> list = values(sensors) | sort_by((s) => s.id)fn spark(history: list, w: int, h: int) -> str { if len(history) < 2 { return "" } let lo = min(history) let hi = max(history) let span = hi - lo == 0 ? 1 : hi - lo let step = w / (KEEP - 1) let start = KEEP - len(history) let points = for [i, v] in enumerate(history) => `${(start + i) * step},${h - (v - lo) / span * h}` join(points, " ")}component Dashboard() { state sensors = snapshot() state rate = 0 state count = 0 channel.open("readings", (r) => { count += 1 let found = find(sensors, (s) => s.id == r.id) let history = (found.history ?? []) + [r.v] let s = { id: r.id, last: r.v, at: r.at, history: len(history) > KEEP ? drop(history, len(history) - KEEP) : history } sensors = found == null ? sort_by(sensors + [s], (x) => x.id) : for x in sensors => x.id == r.id ? s : x }) // Readings a second, worked out from the count every second. every(1000, () => { rate = count count = 0 }) <section.dash> <p.stats><b>{rate}</b> readings a second · <b>{len(sensors)}</b> sensors</p> {len(sensors) == 0 ? <p.empty>Waiting for readings on UDP port 9000. Run <code>quillo run sensors.qwe</code>.</p> : <div.cards> {for s in sensors => <article.card key={s.id}> <header><span.name>{s.id}</span><span.value>{round(s.last, 1)}</span></header> <svg viewBox={`0 0 240 48`} preserveAspectRatio="none"> <polyline points={spark(s.history, 240, 48)}/> </svg> </article>} </div>} </section>}let style = css`* { box-sizing: border-box; }body { font: 15px/1.4 system-ui, sans-serif; max-width: 56rem; margin: 2rem auto; padding: 0 1rem; color: #1f2328; background: #f6f8fa; }h1 { font-size: 1.4rem; margin: 0 0 .25rem; }.stats { color: #57606a; } .stats b { color: #1f2328; font-variant-numeric: tabular-nums; }.empty { color: #57606a; } code { background: #eaeef2; padding: .1rem .35rem; border-radius: 4px; }.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: 1rem; }.card { background: #fff; border: 1px solid #d0d7de; border-radius: 10px; padding: .9rem 1rem .6rem; }.card header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .4rem; }.name { color: #57606a; } .value { font-size: 1.6rem; font-weight: 600; font-variant-numeric: tabular-nums; }svg { width: 100%; height: 48px; overflow: visible; }polyline { fill: none; stroke: #0969da; stroke-width: 1.5; vector-effect: non-scaling-stroke; }@media (prefers-color-scheme: dark) { body { background: #0d1117; color: #e6edf3; } .stats, .name, .empty { color: #8b949e; } .stats b { color: #e6edf3; } .card { background: #161b22; border-color: #30363d; } code { background: #21262d; } polyline { stroke: #58a6ff; }}`route GET / = <html> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <title>Telemetry</title> <style>{style}</style> </head> <body> <h1>Telemetry</h1> <Dashboard/> </body> </html>route GET /stats = { received, sensors: len(keys(sensors)) }sensors.qwe
sensors.qwe
// Four pretend sensors, sending readings to telemetry.qwe over UDP.//// quillo run sensors.qwe//// Each sends a JSON reading twice a second: a slow wave, some noise, and now// and then a spike. TELEMETRY_HOST and TELEMETRY_PORT say where (127.0.0.1// and 9000 unless set).let host = env("TELEMETRY_HOST") ?? "127.0.0.1"let port = int(env("TELEMETRY_PORT") ?? "9000")let devices = [ { id: "boiler", base: 70, swing: 6, period: 40 }, { id: "cold-room", base: 3, swing: 1.5, period: 90 }, { id: "office", base: 21, swing: 1, period: 120 }, { id: "server-rack", base: 34, swing: 4, period: 25 },]print(`sending readings to ${host}:${port} (Ctrl-C to stop)`)for tick in range(1, 10000000) { // about two months, at two a second for d in devices { let wave = math.sin(tick / d.period * 2 * math.pi) * d.swing let noise = (random() - 0.5) * d.swing * 0.3 let spike = random() < 0.01 ? d.swing * 2 : 0 udp.send(host, port, { id: d.id, v: round(d.base + wave + noise + spike, 2) }) } sleep(500)}Thread
Thread 0 posts
On Telemetry: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.