feat(bridge): add wifi/serial bridge runtime and UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 00:38:21 +12:00
parent 2cf019079e
commit 78dc8ffc77
92 changed files with 5679 additions and 1790 deletions

View File

@@ -18,7 +18,7 @@
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
</div>
<div id="audio-top-indicator" class="audio-top-indicator">
<button type="button" id="audio-top-beat-sync" class="audio-top-beat-sync" disabled title="Sync step to music (S)">
<button type="button" id="audio-top-beat-sync" class="audio-beat-sync-btn audio-top-beat-sync" disabled title="Sync step to music (S)">
<span class="audio-top-indicator-label">BPM</span>
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
<span id="audio-top-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
@@ -38,10 +38,8 @@
<button class="btn btn-secondary edit-mode-only" id="sequences-btn">Sequences</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
<button class="btn btn-secondary" id="audio-btn">Audio</button>
<button type="button" class="btn btn-secondary" id="audio-nav-reset-btn" hidden title="Clear stuck BPM / beat tracking">Reset detector</button>
<button class="btn btn-secondary edit-mode-only" id="settings-btn">Settings</button>
<button class="btn btn-secondary" id="help-btn">Help</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div>
@@ -68,10 +66,8 @@
<button type="button" class="edit-mode-only" data-target="sequences-btn">Sequences</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
<button type="button" data-target="audio-btn">Audio</button>
<button type="button" id="audio-nav-reset-mobile" data-target="audio-nav-reset-btn" hidden>Reset detector</button>
<button type="button" class="edit-mode-only" data-target="settings-btn">Settings</button>
<button type="button" data-target="help-btn">Help</button>
</div>
</div>
@@ -171,7 +167,12 @@
<div class="modal-content">
<h2>Devices</h2>
<div id="devices-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<div class="modal-actions" style="align-items:center;gap:0.75rem;flex-wrap:wrap;">
<button type="button" class="btn btn-secondary" id="devices-ping-btn" title="ESP-NOW broadcast ping (3 s)">Ping drivers</button>
<span id="devices-ping-dot" class="device-status-dot device-status-dot--unknown" role="img" title="Not pinged yet" aria-label="Not pinged yet"></span>
<span id="devices-ping-status" class="muted-text" aria-live="polite"></span>
<button type="button" class="btn btn-secondary" id="devices-update-groups-btn" title="Push group membership from Device groups to all ESP-NOW drivers">Update groups</button>
<span id="devices-groups-status" class="muted-text" aria-live="polite"></span>
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
</div>
</div>
@@ -607,11 +608,11 @@
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
</ul>
<h3>What led-tool does</h3>
<h3>LED Tool (Settings tab)</h3>
<ul>
<li><strong>USB device setup</strong>: updates <code>settings.json</code> on ESP32 drivers over serial (for example name, pin, LED count, Wi-Fi credentials).</li>
<li><strong>Deploy and maintenance</strong>: uploads driver files, flashes firmware, resets device, and follows serial logs.</li>
<li><strong>Scope</strong>: led-tool configures devices directly; this web UI controls profiles/zones/presets and sends runtime messages.</li>
<li><strong>Scope</strong>: led-tool configures devices directly; this web UI controls profiles/zones/presets and sends runtime messages. Open <strong>Settings → LED Tool</strong> in Edit mode.</li>
</ul>
<div class="modal-actions">
@@ -622,138 +623,146 @@
<!-- Audio Modal -->
<div id="audio-modal" class="modal">
<div class="modal-content">
<div class="modal-content audio-modal-content">
<h2>Audio Beat Detection</h2>
<p class="muted-text">Select an input device and start beat detection.</p>
<div class="form-group">
<div class="form-group audio-device-block">
<label for="audio-device-select">Input device</label>
<div class="profiles-actions">
<select id="audio-device-select" style="flex: 1;">
<option value="">Default input</option>
<div class="profiles-actions audio-device-select-row">
<select id="audio-device-select">
<option value="">System default input</option>
</select>
<button type="button" class="btn btn-secondary" id="audio-refresh-btn">Refresh</button>
<button type="button" class="btn btn-secondary btn-small" id="audio-refresh-btn" title="Refresh device list">Refresh</button>
</div>
<small>Tip: for Pulse/pipewire playback capture, use a source containing <code>monitor</code>.</small>
<small class="muted-text">Same sources as PulseAudio volume control. Pick a <strong>monitor</strong> source to follow playback.</small>
</div>
<div class="form-group">
<label for="audio-device-override">Manual device override (optional)</label>
<input type="text" id="audio-device-override" placeholder='e.g. 3 or "alsa_output....monitor"'>
</div>
<div class="form-group">
<label>Current BPM</label>
<div class="audio-bpm-row">
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
</div>
<label>Beat indicators</label>
<button type="button" id="audio-modal-beat-sync" class="audio-beat-sync-btn audio-modal-beat-sync" disabled title="Start beat detection">
<span class="audio-top-indicator-label">BPM</span>
<span id="audio-bpm-value" class="audio-top-indicator-value">--</span>
<span id="audio-modal-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
<span id="audio-bar-phase-value" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
</button>
<small class="muted-text">Flashes on each beat (same as the header). Tap on a downbeat while a sequence is playing to sync (<kbd>S</kbd>).</small>
</div>
<div class="form-group">
<label>Detected hit type</label>
<div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div>
</div>
<div class="form-group">
<label>Bar phase</label>
<div class="audio-bpm-row">
<div id="audio-bar-phase-value" class="audio-bpm-readout" title="Beat in bar (kick hints downbeat)">--</div>
<div class="form-group audio-volume-block">
<div class="audio-volume-header">
<label for="audio-input-volume">Volume</label>
<span id="audio-input-volume-readout" class="audio-volume-readout" aria-live="polite">100% (0.00 dB)</span>
</div>
<small class="muted-text">Bar uses kick-heavy hits (default 4/4). Tap <strong>Sync</strong> on a downbeat to lock bar phase.</small>
<div class="audio-volume-slider-row">
<input type="range" id="audio-input-volume" class="audio-volume-slider" min="0" max="200" value="100" step="1" aria-label="Input gain">
</div>
<div class="audio-volume-scale" aria-hidden="true">
<span class="audio-volume-scale-silence">Silence</span>
<span class="audio-volume-scale-unity">100% (0 dB)</span>
</div>
<div class="audio-input-level-meter" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Live input level">
<div id="audio-input-level-bar" class="audio-input-level-bar"></div>
</div>
<small class="muted-text">Gain before beat detection (saved on the controller). The bar shows live input level while running.</small>
</div>
<div class="form-group">
<label>Flash on beat</label>
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
</div>
<div class="settings-section audio-settings-section">
<h3>Audio settings</h3>
<div class="form-group">
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
<small class="muted-text">Delays beat flashes so they line up with what you hear (saved on the controller).</small>
</div>
<div class="form-group">
<label>Beat sync</label>
<button type="button" id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" disabled title="Sync step to music (S)" aria-live="polite"></button>
<small class="muted-text">While a sequence is playing, tap the BPM/beat button in the header on a downbeat to align the step counter. Shortcut: <kbd>S</kbd>.</small>
</div>
<div class="form-group">
<label>Sequence alignment</label>
<div class="profiles-actions" style="flex-wrap: wrap;">
<button type="button" class="btn btn-secondary" id="audio-sync-pass-btn">Restart pass</button>
</div>
<small class="muted-text"><strong>Restart pass</strong> jumps to step 1 of the sequence (<kbd>Shift+S</kbd>). Use <strong>Reset detector</strong> in the header (while audio is running) to clear stuck BPM/beat tracking without stopping audio.</small>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
<button type="button" class="btn btn-secondary" id="audio-reset-btn" disabled title="Clear stuck BPM / beat tracking">Reset detector</button>
<button type="button" class="btn btn-secondary" id="audio-close-btn">Close</button>
</div>
<div class="form-group" style="margin-top: 0.75rem;">
<label for="audio-devices-debug">Detected devices (Python)</label>
<textarea id="audio-devices-debug" rows="8" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal">
<div class="modal-content">
<h2>Device Settings</h2>
<p class="muted-text">Configure WiFi Access Point and device settings.</p>
<div id="settings-message" class="message"></div>
<!-- Device Name -->
<div class="settings-section">
<h3>Device</h3>
<form id="device-form">
<div class="form-group">
<label for="device-name-input">Device Name</label>
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
</div>
<div class="form-group">
<label for="wifi-channel-input">WiFi channel (ESP-NOW)</label>
<input type="number" id="wifi-channel-input" name="wifi_channel" min="1" max="11" required>
<small>STA channel (111) for LED drivers and the serial bridge. Use the same value everywhere.</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Save device settings</button>
</div>
</form>
<div class="modal-content settings-modal-content">
<h2>Settings</h2>
<div class="settings-tabs" role="tablist" aria-label="Settings sections">
<button type="button" class="settings-tab-btn active" role="tab" id="settings-tab-bridge" data-settings-tab="bridge" aria-selected="true" aria-controls="settings-panel-bridge">Bridge</button>
<button type="button" class="settings-tab-btn" role="tab" id="settings-tab-led-tool" data-settings-tab="led-tool" aria-selected="false" aria-controls="settings-panel-led-tool">LED Tool</button>
</div>
<!-- WiFi Access Point Settings -->
<div class="settings-section ap-settings-section">
<h3>WiFi Access Point</h3>
<div id="settings-panel-bridge" class="settings-tab-panel active" data-settings-panel="bridge" role="tabpanel" aria-labelledby="settings-tab-bridge">
<div class="settings-section">
<div class="profiles-actions" style="align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.5rem;">
<span id="bridge-ws-status" class="muted-text" aria-live="polite"></span>
</div>
<ul id="bridge-connection-details" class="settings-bridge-connection-details muted-text" aria-live="polite"></ul>
<div id="ap-status" class="status-info">
<h4>AP Status</h4>
<p>Loading...</p>
<h3 class="settings-subheading">USB serial</h3>
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi ↔ bridge over USB/UART. The bridge still uses WiFi radio for ESP-NOW only.</p>
<div class="form-group">
<label for="bridge-serial-label">Profile label</label>
<input type="text" id="bridge-serial-label" placeholder="e.g. Pi USB bridge" autocomplete="off">
</div>
<div class="form-group">
<label for="bridge-serial-port">USB serial port</label>
<select id="bridge-serial-port">
<option value="">— select port —</option>
</select>
<button type="button" class="btn btn-secondary btn-small" id="bridge-serial-refresh-btn" style="margin-top:0.5rem;">Refresh ports</button>
</div>
<div class="form-group">
<label for="bridge-serial-baud">Baud rate</label>
<input type="number" id="bridge-serial-baud" value="115200" min="9600" max="3000000" step="1">
</div>
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;margin-bottom:1.25rem;">
<button type="button" class="btn btn-primary btn-small" id="bridge-serial-connect-btn">Connect serial</button>
<button type="button" class="btn btn-secondary btn-small" id="bridge-serial-save-profile-btn">Save serial profile</button>
</div>
<form id="ap-form">
<div class="form-group">
<label for="ap-ssid">AP SSID (Network Name)</label>
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
<small>The name of the WiFi access point this device creates</small>
<h3 class="settings-subheading">WiFi</h3>
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi joins the bridge access point, then connects to <code>ws://&lt;bridge-ip&gt;/ws</code>.</p>
<div class="form-group">
<label for="bridge-wifi-interface">WiFi adapter</label>
<select id="bridge-wifi-interface">
<option value="">— select adapter —</option>
</select>
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-refresh-interfaces-btn" style="margin-top:0.5rem;">Refresh adapters</button>
</div>
<div class="form-group">
<label for="bridge-wifi-ssid">Bridge SSID</label>
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
<select id="bridge-wifi-ssid" style="flex:1;min-width:12rem;">
<option value="">— scan or type below —</option>
</select>
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-scan-btn">Scan</button>
</div>
<input type="text" id="bridge-wifi-ssid-manual" placeholder="Or type SSID" autocomplete="off" style="margin-top:0.5rem;">
</div>
<div class="form-group">
<label for="bridge-wifi-password">Password</label>
<input type="password" id="bridge-wifi-password" autocomplete="off">
</div>
<div class="form-group">
<label for="bridge-wifi-label">Profile label</label>
<input type="text" id="bridge-wifi-label" placeholder="e.g. Garden bridge" autocomplete="off">
</div>
<div class="form-group" style="display:flex;gap:1rem;flex-wrap:wrap;">
<div style="flex:1;min-width:8rem;">
<label for="bridge-wifi-ap-ip">Bridge IP</label>
<input type="text" id="bridge-wifi-ap-ip" value="192.168.4.1" autocomplete="off">
</div>
<div style="flex:0 0 6rem;">
<label for="bridge-wifi-ws-port">WS port</label>
<input type="number" id="bridge-wifi-ws-port" value="80" min="1" max="65535" step="1">
</div>
</div>
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem;">
<button type="button" class="btn btn-primary btn-small" id="bridge-wifi-connect-btn">Connect WiFi</button>
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-save-profile-btn">Save WiFi profile</button>
</div>
<div class="form-group">
<label for="ap-password">AP Password</label>
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
<small>Leave empty for open network (min 8 characters if set)</small>
</div>
<h3 class="settings-subheading">Saved profiles</h3>
<ul id="bridge-profiles-list" class="settings-bridge-profiles muted-text"></ul>
</div>
</div>
<div class="form-group">
<label for="ap-channel">Channel (1-11)</label>
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
</div>
</form>
<div id="settings-panel-led-tool" class="settings-tab-panel" data-settings-panel="led-tool" role="tabpanel" aria-labelledby="settings-tab-led-tool" hidden>
<p class="muted-text settings-led-tool-intro">USB serial setup for drivers and bridges: device settings, deploy, and firmware.</p>
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" class="settings-led-tool-iframe"></iframe>
</div>
<div class="modal-actions">
@@ -762,22 +771,11 @@
</div>
</div>
<!-- LED Tool Modal (led-tool/static settings editor) -->
<div id="led-tool-modal" class="modal">
<div class="modal-content" style="max-width: 960px; width: 95vw;">
<div class="modal-actions" style="margin-bottom: 0.5rem;">
<h2 style="margin: 0; flex: 1;">LED Tool — device settings</h2>
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
</div>
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" style="width:100%;height:min(75vh,720px);border:1px solid #4a4a4a;border-radius:4px;background:#0b1020;"></iframe>
</div>
</div>
<!-- Styles moved to /static/style.css -->
<script src="/static/zone-devices-panel.js"></script>
<script src="/static/groups.js"></script>
<script src="/static/zones.js"></script>
<script src="/static/help.js"></script>
<script src="/static/led_tool.js"></script>
<script src="/static/color_palette.js"></script>
<script src="/static/bundle_io.js"></script>
<script src="/static/profiles.js"></script>