15. Interface and Application Programming
This week I documented the process of designing a web interface for my Networking and communications project. For Networking, I used the PBC Input and Output devices from those respective weeks. See links below for more details on each project.
Week 15 Group AssignmentTo start the assignment, I asked Claude.ai to generate a response based on the following prompt:
I have 2 xiao esp32c3 microcontrollers. The first is a switch connected to D0 and another switch connected to D1. The other esp32c3 has an 8x8 LED matrix connected to D0. for the first esp32c3, assign the hostname 'esp32c3_1' and the hostname 'esp32c3_2' for the second controller. write an html webapp for the devices to connect to and represent the desired functions. follow the following instructions: When switch D0 on esp32c3_1 is pressed, the LED matrix on esp32c3_2 displays "HI". When switch D1 on esp32c3_1 is pressed, the LED matrix on esp32c3_2 displays "BYE". in the webapp, allow speed, color and text to be customizable. Do not hardcode any messages or animations on esp32c3_2. It should be entirely dependent on input from the esp32c3_1 and the webapp.
Claude generated a data sheet detailing the step to accomplish the objective.
Now let me plan and build this system. I'll create three files:
- ESP32C3_1 firmware (switch input → sends messages to webapp via WebSocket)
- ESP32C3_2 firmware (LED matrix display → listens to webapp via WebSocket)
- Web App (HTML hub connecting both devices, with customization controls)
[esp32c3_1] ──────────────────────────────────┐
D0 switch → "switch_press" {switch:0} │ WebSocket
D1 switch → "switch_press" {switch:1} ▼
[server.js] ←→ [Browser / index.html]
│ ↑ live preview
"display" cmd ↑ send text, color, speed
▼ ↑ configure switch mappings
[esp32c3_2] ──────────────────────────────────┘
receives → scrolls/shows text on LED matrix
This response was good, unfortunately, it depended on libraries and server protocols that were not compatible with the ESP32C3.
Using the first set of codes did not work when I tried to upload them to the microcontrollers in Arduino IDE.
To resolve that issue I copied the Wifi_SSID, Wifi_Password and the Webapp_Host lines from the firmware and asked Claude to consider that information while following this prompt:
change the architecture to exclude the server. define each esp32c3 as a separate wifi device connecting directly to the webapp using the hostnames.
Claude then explained that this would be a good approach but suggested that the micro controllers run their own tiny WebSocket servers that the browser would connect to. This interface would then be a static HTML file.
ws://esp32c3_1.local/ws — browser polls switch state
ws://esp32c3_2.local/ws — browser sends display commands
esp32c3_1 also connects directly to esp32c3_2 to trigger displays on switch press
In this operation the ESP 1 is the input device while ESP 2 is the output.
Browser (index.html at 10.1.10.127)
│
├─ ws://esp32c3_1.local:80/ws ──────► esp32c3_1
│ ↑ receives switch_press events │
│ ↓ sends set_switch_map config │ ws://esp32c3_2.local:82/ws
│ ▼
└─ ws://esp32c3_2.local:82/ws ──────► esp32c3_2
↓ sends display commands LED matrix renders
↑ receives display_ack
This is the updated system Architecture without the server.
At this stage there were no more issues with the HTML Code. I was able to upload it to VS Code to review the instructions and run the app using Google Chrome.
week11index.html — full code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Matrix Hub</title>
<style>
/* ── Tokens ──────────────────────────────────────────────────────
Palette: deep navy-black ground, phosphor-amber as the one
bold accent (chosen instead of the cliché acid-green because
amber is the authentic colour of vintage LED matrix displays),
cool-slate surfaces, hairline ice-blue borders.
Signature element: the live canvas preview rendered to look
exactly like a real MAX7219 module — black PCB, amber LEDs
with per-pixel glow.
──────────────────────────────────────────────────────────────── */
:root{
--bg: #08090d;
--surf: #0e1118;
--surf2: #151923;
--border: #1e2535;
--amber: #ffb300;
--amber-d: #c47f00;
--amber-g: rgba(255,179,0,.18);
--ice: #7eb8d4;
--muted: #3d4f63;
--text: #b8ccd8;
--danger: #e05555;
--ok: #43b88c;
--mono: 'Courier New',Courier,monospace;
--ui: 'Segoe UI',system-ui,sans-serif;
--r: 5px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:14px}
body{
background:var(--bg);
color:var(--text);
font-family:var(--ui);
min-height:100vh;
display:flex;
flex-direction:column;
}
/* ── Header ──────────────────────────────────────────────────── */
header{
display:flex;align-items:center;justify-content:space-between;
padding:12px 22px;
background:var(--surf);
border-bottom:1px solid var(--border);
gap:12px;flex-wrap:wrap;
}
.wordmark{
font-family:var(--mono);
font-size:15px;letter-spacing:3px;font-weight:700;
color:var(--amber);
text-shadow:0 0 18px var(--amber-g);
white-space:nowrap;
}
.badges{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.badge{
display:flex;align-items:center;gap:6px;
font-family:var(--mono);font-size:11px;
padding:4px 10px;border-radius:20px;
border:1px solid var(--border);
background:var(--surf2);color:var(--muted);
transition:color .4s,border-color .4s;
}
.badge .dot{
width:6px;height:6px;border-radius:50%;background:currentColor;
transition:box-shadow .4s;
}
.badge.on{color:var(--ok);border-color:#2a6e55}
.badge.on .dot{box-shadow:0 0 6px var(--ok)}
.badge.err{color:var(--danger);border-color:#6e2a2a}
/* ── Layout ──────────────────────────────────────────────────── */
main{
flex:1;
display:grid;
grid-template-columns:1fr 300px;
grid-template-rows:auto 1fr;
gap:16px;
padding:18px;
max-width:1060px;
width:100%;
margin:0 auto;
}
/* ── Panel shell ─────────────────────────────────────────────── */
.panel{
background:var(--surf);
border:1px solid var(--border);
border-radius:var(--r);
padding:18px;
display:flex;flex-direction:column;gap:14px;
}
.panel-label{
font-family:var(--mono);font-size:10px;
letter-spacing:2px;color:var(--muted);text-transform:uppercase;
}
/* ── Matrix canvas ───────────────────────────────────────────── */
.matrix-wrap{
background:#000;
border-radius:4px;
border:1px solid #1a1a1a;
display:flex;justify-content:center;align-items:center;
padding:20px 12px;
min-height:90px;
position:relative;
}
/* Fake PCB screw holes for authenticity */
.matrix-wrap::before,.matrix-wrap::after{
content:'';position:absolute;width:8px;height:8px;
border-radius:50%;background:#111;border:1px solid #222;
top:6px;
}
.matrix-wrap::before{left:8px}
.matrix-wrap::after{right:8px}
canvas{image-rendering:pixelated;display:block}
/* ── Send controls ───────────────────────────────────────────── */
.row{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap}
.field{display:flex;flex-direction:column;gap:5px}
.field label{
font-family:var(--mono);font-size:10px;
letter-spacing:1.5px;color:var(--muted);text-transform:uppercase;
}
input[type=text],input[type=number]{
background:var(--surf2);border:1px solid var(--border);
border-radius:var(--r);color:var(--text);
font-family:var(--mono);font-size:14px;
padding:7px 10px;outline:none;width:100%;
transition:border-color .2s;
}
input[type=text]:focus,input[type=number]:focus{border-color:var(--amber-d)}
input[type=color]{
width:42px;height:34px;border:1px solid var(--border);
border-radius:var(--r);background:var(--surf2);
cursor:pointer;padding:2px;
}
input[type=range]{width:100%;accent-color:var(--amber)}
.range-row{display:flex;align-items:center;gap:8px}
.range-val{
font-family:var(--mono);font-size:13px;
color:var(--amber);min-width:26px;text-align:right;
}
/* ── Buttons ─────────────────────────────────────────────────── */
button{
cursor:pointer;border:none;border-radius:var(--r);
font-family:var(--mono);font-size:12px;font-weight:700;
letter-spacing:1px;padding:8px 18px;
transition:box-shadow .2s,opacity .2s,background .15s;
}
.btn-amber{background:var(--amber);color:#000}
.btn-amber:hover{box-shadow:0 0 14px var(--amber-g)}
.btn-amber:disabled{opacity:.35;cursor:not-allowed}
.btn-ghost{
background:transparent;border:1px solid var(--border);
color:var(--muted);
}
.btn-ghost:hover{border-color:var(--danger);color:var(--danger)}
.btn-ice{
background:transparent;border:1px solid var(--border);
color:var(--ice);
}
.btn-ice:hover{border-color:var(--ice)}
/* ── Sidebar ─────────────────────────────────────────────────── */
.sidebar{
grid-column:2;grid-row:1/3;
display:flex;flex-direction:column;gap:14px;
}
/* ── Switch cards ────────────────────────────────────────────── */
.sw-header{display:flex;align-items:center;gap:8px}
.sw-chip{
font-family:var(--mono);font-size:10px;
padding:3px 8px;border-radius:3px;
border:1px solid var(--amber-d);color:var(--amber);
background:rgba(255,179,0,.06);
transition:background .15s,color .15s;
}
.sw-chip.flash{background:var(--amber);color:#000}
/* ── Log ─────────────────────────────────────────────────────── */
.log-box{
height:190px;overflow-y:auto;
background:#000;border:1px solid var(--border);
border-radius:4px;padding:8px;
font-family:var(--mono);font-size:11px;line-height:1.65;
display:flex;flex-direction:column-reverse;
scrollbar-width:thin;scrollbar-color:var(--border) transparent;
}
.log-box::-webkit-scrollbar{width:4px}
.log-box::-webkit-scrollbar-thumb{background:var(--border)}
.ll{color:var(--muted)}
.ll.ok{color:var(--ok)}
.ll.warn{color:var(--amber)}
.ll.err{color:var(--danger)}
/* ── Toast ───────────────────────────────────────────────────── */
#toast{
position:fixed;bottom:18px;right:18px;
padding:8px 16px;border-radius:var(--r);
font-family:var(--mono);font-size:12px;
background:var(--surf2);border:1px solid var(--border);
color:var(--text);opacity:0;
transition:opacity .3s;pointer-events:none;z-index:999;
}
#toast.show{opacity:1}
#toast.ok{border-color:var(--ok);color:var(--ok)}
#toast.warn{border-color:var(--amber);color:var(--amber)}
#toast.err{border-color:var(--danger);color:var(--danger)}
@media(max-width:680px){
main{grid-template-columns:1fr;grid-template-rows:auto}
.sidebar{grid-column:1;grid-row:auto}
}
</style>
</head>
<body>
<!-- ── Header ─────────────────────────────────────────────────── -->
<header>
<span class="wordmark">▣ MATRIX HUB</span>
<div class="badges">
<div class="badge" id="b1"><span class="dot"></span>esp32c3_1</div>
<div class="badge" id="b2"><span class="dot"></span>esp32c3_2</div>
</div>
</header>
<!-- ── Main ───────────────────────────────────────────────────── -->
<main>
<!-- Matrix preview -->
<div class="panel" style="grid-column:1;grid-row:1">
<div class="panel-label">Live Matrix Preview — esp32c3_2</div>
<div class="matrix-wrap">
<canvas id="cvs" width="328" height="44"></canvas>
</div>
</div>
<!-- Send controls -->
<div class="panel" style="grid-column:1;grid-row:2">
<div class="panel-label">Direct Send to Matrix</div>
<div class="row">
<div class="field" style="flex:3 1 180px">
<label>Text</label>
<input id="sText" type="text" placeholder="Type anything…" maxlength="64"/>
</div>
<div class="field" style="flex:0 0 44px">
<label>Colour</label>
<input id="sColor" type="color" value="#ffb300"/>
</div>
</div>
<div class="field">
<label>Speed <span id="sSpeedV" style="color:var(--amber)">50</span></label>
<div class="range-row">
<input id="sSpeed" type="range" min="1" max="100" value="50"/>
</div>
</div>
<div class="row">
<button class="btn-amber" onclick="sendDirect()">▶ SEND</button>
<button class="btn-ghost" onclick="clearMatrix()">✕ CLEAR</button>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- Switch 0 -->
<div class="panel">
<div class="sw-header">
<span id="sw0chip" class="sw-chip">D0 SWITCH</span>
<span class="panel-label" style="margin:0">Switch 0 Action</span>
</div>
<div class="field">
<label>Text</label>
<input id="sw0t" type="text" value="HI" maxlength="64"/>
</div>
<div class="field">
<label>Colour</label>
<input id="sw0c" type="color" value="#00ff41"/>
</div>
<div class="field">
<label>Speed <span id="sw0sv" style="color:var(--amber)">60</span></label>
<div class="range-row">
<input id="sw0s" type="range" min="1" max="100" value="60"/>
</div>
</div>
<button class="btn-ice" style="align-self:flex-start" onclick="saveSwitch(0)">SAVE TO DEVICE</button>
</div>
<!-- Switch 1 -->
<div class="panel">
<div class="sw-header">
<span id="sw1chip" class="sw-chip">D1 SWITCH</span>
<span class="panel-label" style="margin:0">Switch 1 Action</span>
</div>
<div class="field">
<label>Text</label>
<input id="sw1t" type="text" value="BYE" maxlength="64"/>
</div>
<div class="field">
<label>Colour</label>
<input id="sw1c" type="color" value="#ff4500"/>
</div>
<div class="field">
<label>Speed <span id="sw1sv" style="color:var(--amber)">40</span></label>
<div class="range-row">
<input id="sw1s" type="range" min="1" max="100" value="40"/>
</div>
</div>
<button class="btn-ice" style="align-self:flex-start" onclick="saveSwitch(1)">SAVE TO DEVICE</button>
</div>
<!-- Log -->
<div class="panel" style="flex:1">
<div class="panel-label">Activity</div>
<div id="logBox" class="log-box"></div>
</div>
</div><!-- /sidebar -->
</main>
<div id="toast"></div>
<script>
// ═══════════════════════════════════════════════════════════════
// Config — each ESP hosts its own WS server
// ═══════════════════════════════════════════════════════════════
const CFG = {
esp1: { host: 'esp32c3_1.local', port: 80, path: '/ws' },
esp2: { host: 'esp32c3_2.local', port: 82, path: '/ws' },
};
// ═══════════════════════════════════════════════════════════════
// WebSocket connections
// ═══════════════════════════════════════════════════════════════
const socks = { esp1: null, esp2: null };
const retryDelay = { esp1: 2000, esp2: 2000 };
function wsUrl(key) {
const c = CFG[key];
return `ws://${c.host}:${c.port}${c.path}`;
}
function connect(key) {
const url = wsUrl(key);
const ws = new WebSocket(url);
socks[key] = ws;
ws.onopen = () => {
retryDelay[key] = 2000;
setBadge(key, 'on');
log(`${key} connected`, 'ok');
};
ws.onmessage = e => {
let msg; try { msg = JSON.parse(e.data); } catch { return; }
handleMsg(key, msg);
};
ws.onerror = () => {}; // onclose will handle
ws.onclose = () => {
setBadge(key, 'err');
log(`${key} disconnected — retry in ${retryDelay[key]/1000}s`, 'warn');
setTimeout(() => connect(key), retryDelay[key]);
retryDelay[key] = Math.min(retryDelay[key] * 1.5, 20000);
};
}
function send(key, obj) {
const ws = socks[key];
if (ws && ws.readyState === WebSocket.OPEN)
ws.send(JSON.stringify(obj));
else
log(`${key} not connected — command dropped`, 'warn');
}
// ═══════════════════════════════════════════════════════════════
// Message handling
// ═══════════════════════════════════════════════════════════════
function handleMsg(key, msg) {
if (key === 'esp1') {
if (msg.type === 'state') {
// Restore switch maps sent by esp32c3_1 on connect
if (Array.isArray(msg.switchMap)) {
msg.switchMap.forEach(m => restoreSwitch(m.index, m));
}
log('esp1 state received', 'ok');
}
if (msg.type === 'switch_press') {
const idx = msg.switch;
flashChip(idx);
log(`Switch D${idx} pressed → "${msg.text}"`, 'ok');
// Mirror in the Send panel for visual feedback
setField('sText', msg.text);
setField('sColor', msg.color || '#ffb300', 'color');
setField('sSpeed', msg.speed || 50, 'range');
document.getElementById('sSpeedV').textContent = msg.speed || 50;
preview(msg.text, msg.color || '#ffb300');
}
if (msg.type === 'map_saved') {
log(`Switch D${msg.switch} mapping saved on device`, 'ok');
toast('Saved ✓', 'ok');
}
}
if (key === 'esp2') {
if (msg.type === 'state') {
if (msg.text) {
setField('sText', msg.text);
preview(msg.text, document.getElementById('sColor').value);
}
log('esp2 state received', 'ok');
}
if (msg.type === 'display_ack') {
preview(msg.text, document.getElementById('sColor').value);
log(`Matrix showing: "${msg.text}"`, 'ok');
}
}
}
// ═══════════════════════════════════════════════════════════════
// Actions
// ═══════════════════════════════════════════════════════════════
function sendDirect() {
const text = document.getElementById('sText').value.trim();
const color = document.getElementById('sColor').value;
const speed = +document.getElementById('sSpeed').value;
send('esp2', { type:'display', text, color, speed });
preview(text, color);
log(`Sent to matrix: "${text}"`, 'ok');
}
function clearMatrix() {
send('esp2', { type:'clear' });
document.getElementById('sText').value = '';
preview('', '#ffb300');
log('Matrix cleared', 'warn');
}
function saveSwitch(idx) {
const text = document.getElementById(`sw${idx}t`).value;
const color = document.getElementById(`sw${idx}c`).value;
const speed = +document.getElementById(`sw${idx}s`).value;
send('esp1', { type:'set_switch_map', switch: idx, text, color, speed });
log(`Saving switch D${idx} → "${text}" on device…`);
}
// ═══════════════════════════════════════════════════════════════
// UI helpers
// ═══════════════════════════════════════════════════════════════
function setBadge(key, state) {
const el = document.getElementById(key === 'esp1' ? 'b1' : 'b2');
el.className = `badge ${state}`;
}
function flashChip(idx) {
const el = document.getElementById(`sw${idx}chip`);
el.classList.add('flash');
setTimeout(() => el.classList.remove('flash'), 500);
}
function restoreSwitch(idx, cfg) {
if (cfg.text !== undefined) document.getElementById(`sw${idx}t`).value = cfg.text;
if (cfg.color !== undefined) document.getElementById(`sw${idx}c`).value = cfg.color;
if (cfg.speed !== undefined) {
document.getElementById(`sw${idx}s`).value = cfg.speed;
document.getElementById(`sw${idx}sv`).textContent = cfg.speed;
}
}
function setField(id, val, type = 'text') {
const el = document.getElementById(id);
if (!el) return;
if (type === 'color') el.value = val;
else if (type === 'range') el.value = val;
else el.value = val;
}
// Range live labels
['s','sw0','sw1'].forEach(p => {
const r = document.getElementById(`${p}Speed` || `${p}s`);
const vid = p === 's' ? 'sSpeedV' : `${p}sv`;
const rid = p === 's' ? 'sSpeed' : `${p}s`;
const ri = document.getElementById(rid);
const vi = document.getElementById(vid);
if (ri && vi) ri.addEventListener('input', () => {
vi.textContent = ri.value;
if (rid === 'sSpeed') preview(document.getElementById('sText').value,
document.getElementById('sColor').value);
});
});
document.getElementById('sText').addEventListener('input',
() => preview(document.getElementById('sText').value,
document.getElementById('sColor').value));
document.getElementById('sColor').addEventListener('input',
() => preview(document.getElementById('sText').value,
document.getElementById('sColor').value));
// ── Activity log ──────────────────────────────────────────────
function log(msg, cls = '') {
const box = document.getElementById('logBox');
const ts = new Date().toLocaleTimeString('en-US',{hour12:false});
const line = document.createElement('div');
line.className = `ll ${cls}`;
line.textContent = `${ts} ${msg}`;
box.prepend(line);
while (box.children.length > 120) box.removeChild(box.lastChild);
}
// ── Toast ─────────────────────────────────────────────────────
let toastTimer;
function toast(msg, cls = '') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = `show ${cls}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.className = ''; }, 2200);
}
// ═══════════════════════════════════════════════════════════════
// Canvas matrix preview — amber phosphor aesthetic
// ═══════════════════════════════════════════════════════════════
const cvs = document.getElementById('cvs');
const ctx = cvs.getContext('2d');
// 5×7 pixel font (uppercase + basic symbols)
const FONT = {
' ':[[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0]],
'A':[[0,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1]],
'B':[[1,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,0]],
'C':[[0,1,1,1,0],[1,0,0,0,1],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,1],[0,1,1,1,0]],
'D':[[1,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,0]],
'E':[[1,1,1,1,1],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,0],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,1]],
'F':[[1,1,1,1,1],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0]],
'G':[[0,1,1,1,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,1,1,1],[1,0,0,0,1],[1,0,0,0,1],[0,1,1,1,0]],
'H':[[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1]],
'I':[[1,1,1,1,1],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[1,1,1,1,1]],
'J':[[0,0,0,0,1],[0,0,0,0,1],[0,0,0,0,1],[0,0,0,0,1],[0,0,0,0,1],[1,0,0,0,1],[0,1,1,1,0]],
'K':[[1,0,0,0,1],[1,0,0,1,0],[1,0,1,0,0],[1,1,0,0,0],[1,0,1,0,0],[1,0,0,1,0],[1,0,0,0,1]],
'L':[[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0],[1,1,1,1,1]],
'M':[[1,0,0,0,1],[1,1,0,1,1],[1,0,1,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1]],
'N':[[1,0,0,0,1],[1,1,0,0,1],[1,0,1,0,1],[1,0,0,1,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1]],
'O':[[0,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[0,1,1,1,0]],
'P':[[1,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,0],[1,0,0,0,0],[1,0,0,0,0],[1,0,0,0,0]],
'Q':[[0,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,1,0,1],[1,0,0,1,0],[0,1,1,0,1]],
'R':[[1,1,1,1,0],[1,0,0,0,1],[1,0,0,0,1],[1,1,1,1,0],[1,0,1,0,0],[1,0,0,1,0],[1,0,0,0,1]],
'S':[[0,1,1,1,0],[1,0,0,0,1],[1,0,0,0,0],[0,1,1,1,0],[0,0,0,0,1],[1,0,0,0,1],[0,1,1,1,0]],
'T':[[1,1,1,1,1],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0]],
'U':[[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[0,1,1,1,0]],
'V':[[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[0,1,0,1,0],[0,1,0,1,0],[0,0,1,0,0]],
'W':[[1,0,0,0,1],[1,0,0,0,1],[1,0,0,0,1],[1,0,1,0,1],[1,0,1,0,1],[1,1,0,1,1],[1,0,0,0,1]],
'X':[[1,0,0,0,1],[0,1,0,1,0],[0,0,1,0,0],[0,0,1,0,0],[0,1,0,1,0],[1,0,0,0,1],[1,0,0,0,1]],
'Y':[[1,0,0,0,1],[1,0,0,0,1],[0,1,0,1,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0]],
'Z':[[1,1,1,1,1],[0,0,0,0,1],[0,0,0,1,0],[0,0,1,0,0],[0,1,0,0,0],[1,0,0,0,0],[1,1,1,1,1]],
'!':[[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,1,0,0],[0,0,0,0,0],[0,0,1,0,0]],
'?':[[0,1,1,1,0],[1,0,0,0,1],[0,0,0,0,1],[0,0,0,1,0],[0,0,1,0,0],[0,0,0,0,0],[0,0,1,0,0]],
'.':[[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[0,0,1,0,0]],
'-':[[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0],[1,1,1,1,1],[0,0,0,0,0],[0,0,0,0,0],[0,0,0,0,0]],
};
function buildCols(text) {
const cols = [];
for (const ch of text.toUpperCase()) {
const g = FONT[ch] || FONT['?'];
for (let c = 0; c < 5; c++) cols.push(g.map(r => r[c]));
cols.push([0,0,0,0,0,0,0]);
}
return cols;
}
let animId = null, scrollX = 0;
function preview(text, color) {
cancelAnimationFrame(animId);
ctx.clearRect(0, 0, cvs.width, cvs.height);
// Draw dim "off" LED grid background
const PX = 5, GAP = 1, STEP = PX + GAP;
const COLS_MAX = Math.floor(cvs.width / STEP);
const ROWS = 7;
const offY = Math.floor((cvs.height - ROWS * STEP) / 2);
ctx.fillStyle = 'rgba(255,255,255,0.04)';
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS_MAX; c++)
ctx.fillRect(c * STEP, offY + r * STEP, PX, PX);
if (!text) return;
const cols = buildCols(text);
const totalW = cols.length * STEP;
function drawCols(ox) {
ctx.fillStyle = color;
for (let c = 0; c < cols.length; c++) {
const x = ox + c * STEP;
if (x + PX < 0 || x > cvs.width) continue;
for (let r = 0; r < 7; r++) {
if (!cols[c][r]) continue;
const y = offY + r * STEP;
// Glow halo
ctx.globalAlpha = 0.18;
ctx.fillRect(x - 2, y - 2, PX + 4, PX + 4);
ctx.globalAlpha = 0.45;
ctx.fillRect(x - 1, y - 1, PX + 2, PX + 2);
ctx.globalAlpha = 1;
ctx.fillRect(x, y, PX, PX);
}
}
ctx.globalAlpha = 1;
}
if (totalW <= cvs.width) {
drawCols(Math.floor((cvs.width - totalW) / 2));
return;
}
// Scroll speed from range control
const speed = +document.getElementById('sSpeed').value;
const pxPerMs = (speed / 100) * 0.14 + 0.02; // ~0.02–0.16 px/ms
let last = null;
scrollX = cvs.width;
function frame(ts) {
if (last !== null) {
const dt = ts - last;
scrollX -= pxPerMs * dt;
if (scrollX < -totalW) scrollX = cvs.width;
}
last = ts;
ctx.clearRect(0, 0, cvs.width, cvs.height);
// off-LED grid
ctx.fillStyle = 'rgba(255,255,255,0.04)';
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS_MAX; c++)
ctx.fillRect(c * STEP, offY + r * STEP, PX, PX);
drawCols(scrollX);
animId = requestAnimationFrame(frame);
}
animId = requestAnimationFrame(frame);
}
// ═══════════════════════════════════════════════════════════════
// Boot
// ═══════════════════════════════════════════════════════════════
connect('esp1');
connect('esp2');
preview('HI', '#ffb300');
log('Connecting to devices…');
</script>
</body>
</html>
The ESP32C3s were still having difficulty connecting to the WebSocket. The reason was that Claude included libraries that were unnecessary and were causing error reports. I asked it to review the error reports taking screenshots of the errors from each of the ESP32C3s.
It gave the following response:
| Library | Author | Notes |
|---|---|---|
| WebSockets | Markus Sattler (Links2004) | ✅ replaces AsyncTCP + ESPAsyncWebServer |
| ArduinoJson | Benoit Blanchon | unchanged |
| MD_Parola | majicDesigns | esp32c3_2 only — already installed |
| MD_MAX72XX | majicDesigns | esp32c3_2 only — already installed |
You can now uninstall (or just ignore) AsyncTCP and ESPAsyncWebServer — they won't be referenced anymore. The ArduinoWebsockets (Gil Maimon) library is also no longer needed. Everything is handled by the single Links2004 library.
It then regenerated the firmware .ino files which worked without any other issues.
To run the app, I would open the HTML in VS Code, go to the Run tab, and run without debugging. I then choose Google Chrome to run the app. Another option would be to download the HTML from Claude, go to the folder it was saved in and open it like a regular file. The option would appear to choose a browser.
In the app, You will notice 2 indicators with the hostnames for the ESP32C3s. If they are red, it means they are not connected. When they are connected, the indicators turn green. These connections are location sensitive which means that they will not connect if the devices are not on the same Wifi network that they were configured for in the firmware. To fix this, you will need to reflash the firmware into the microcontrollers after updating the Wifi_SSID and Wifi_Password.
Other areas of the interface are:
Activity Log, this documents everything that happens in the app. This includes device disconnections, connections and the communications between the 2 devices.
Switch Action 0 and 1. These allow users to customize text inputs, change the text color. The user can also change the text scroll speed and save the instruction to the respective device.
Direct Send to Matrix is another option for sending a message, also adjusting the text, and color.
This video shows the full series of operations working. when a switch is pressed, the interface registers the input and displays the corresponding message with the options to edit the text, color and speed. The message is then sent to the LED Matrix as the output.
Group Project
Arduino and MicroPython represent two distinct approaches to programming microcontrollers like the XIAO ESP32C3. MicroPython, used through an IDE like Thonny, allows for immediate execution via a REPL without needing to compile or install multiple libraries. This makes it excellent for prototyping and quickly testing ideas. The tradeoff is that development can feel tethered to a connected computer, and it carries more memory overhead on constrained devices.
Arduino, while harder to set up initially due to board managers, drivers, and library installations, pays off once configured. The compile-and-flash workflow means code is permanently written to the microcontroller, allowing it to run standalone without any computer connection. This makes it far better suited for finished, deployed devices that need to operate independently and reliably over time.
A useful way to think about the two is that MicroPython is a scratchpad and Arduino is a printing press. Many makers use both in sequence — prototyping logic quickly in MicroPython, then porting the working solution to Arduino C++ for final deployment. For standalone devices especially, Arduino's flash-and-forget behavior is a significant practical advantage, even if the path to get there requires more upfront effort.