Files
brewpi/web/index.html
jensandClaude Sonnet 5 f20e617d81 feat: add connect/disconnect status for real heater/stirrer hardware
Heater and Stirrer can be real serial hardware (hendi, Pololu1376), but
there was no connection concept at all - the constructors opened the
port and crashed the whole server if the device was missing, with no
way to see connection status or firmware version and no way to
reconnect without a restart.

Adds an observable Connectable mixin (components/connectable.py) shared
by AHeater/AStirrer; real devices defer opening the serial port to an
explicit connect(), auto-connect on server startup, and surface
Connected/FirmwareVersion/Simulated plus manual Connect/Disconnect over
the web GUI. Heating/stirring and Sud Start are all gated on connection
state, and a disconnect mid-brew force-stops the run via the same path
as a manual Stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
2026-07-03 19:18:59 +02:00

308 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BrewPi</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header id="connection-bar">
<div id="conn-row">
<label>Host <input id="host" type="text"></label>
<button id="btn-connect">Connect</button>
<span id="conn-status" class="status-disconnected">Disconnected</span>
<span id="sud-controls">
<button id="btn-sud-start" class="transport-btn transport-start" title="Start / Resume">&#9654;</button>
<button id="btn-sud-pause" class="transport-btn transport-pause" title="Pause">&#9646;&#9646;</button>
<button id="btn-sud-stop" class="transport-btn transport-stop" title="Stop">&#9632;</button>
<span id="header-countdown" class="hidden">
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">total</span><span id="hdr-total-remaining">-:--:--</span></span>
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">ramp</span><span id="hdr-step-remaining">-:--:--</span></span>
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">hold</span><span id="hdr-hold-remaining">-:--:--</span></span>
</span>
</span>
</div>
<div id="file-row">
<button id="btn-sud-new">New</button>
<button id="btn-sud-load">Load&hellip;</button>
<input type="file" id="sud-file-input" accept="application/json" class="hidden">
<button id="btn-sud-save">Save&hellip;</button>
</div>
</header>
<main id="layout">
<section id="col-manual">
<button id="btn-manual-toggle" title="Collapse manual panel">&#9664;</button>
<div id="manual-panels">
<div class="panel">
<h3>Controller</h3>
<label>Temperature [&deg;C]
<div class="slider-row">
<input type="range" id="temp-soll" min="0" max="100" step="1">
<span id="temp-soll-readout">--</span>
</div>
</label>
<label>Heat rate [&deg;C/min]
<input type="number" id="heatrate-soll" min="0" max="3" step="0.1">
</label>
</div>
<div class="panel">
<h3>Heater</h3>
<div class="device-status-row">
<span id="heater-status" class="status-disconnected">Disconnected</span>
<span id="heater-firmware" class="device-firmware"></span>
<button id="btn-heater-connect">Connect</button>
<button id="btn-heater-disconnect" disabled>Disconnect</button>
</div>
<label class="checkbox-label">
<input type="checkbox" id="closed-loop" checked> Closed-loop
</label>
<label>Power [W]
<div class="slider-row">
<input type="range" id="heater-power" min="0" max="100" step="1">
<span id="heater-power-readout">--</span>
</div>
</label>
</div>
<div class="panel">
<h3>Stirrer</h3>
<div class="device-status-row">
<span id="stirrer-status" class="status-disconnected">Disconnected</span>
<span id="stirrer-firmware" class="device-firmware"></span>
<button id="btn-stirrer-connect">Connect</button>
<button id="btn-stirrer-disconnect" disabled>Disconnect</button>
</div>
<label>Speed [%]
<div class="slider-row">
<input type="range" id="stirrer-speed" min="0" max="100" step="1">
<span id="stirrer-speed-readout">--</span>
</div>
</label>
</div>
<div class="panel">
<h3>Environment</h3>
<label>Ambient [&deg;C]
<input type="number" id="ambient-temp" step="0.1">
</label>
<div id="pot-reset-row" class="hidden">
<label>Pot [&deg;C]
<input type="number" id="pot-temp" step="0.1">
</label>
<button id="btn-pot-reset">Reset</button>
</div>
</div>
</div>
</section>
<div id="col-center">
<div id="lcd-pot-row">
<div id="lcd-panel">
<div class="lcd-row">
<div class="lcd-label">Temperature</div>
<div class="lcd" id="lcd-temp-ist">--</div>
<div class="lcd" id="lcd-temp-soll">--</div>
<div class="lcd-unit">&deg;C</div>
</div>
<div class="lcd-row">
<div class="lcd-label">Heat rate</div>
<div class="lcd" id="lcd-rate-ist">--</div>
<div class="lcd" id="lcd-rate-soll">--</div>
<div class="lcd-unit">&deg;C/min</div>
</div>
<div class="lcd-row">
<div class="lcd-label">Power</div>
<div class="lcd" id="lcd-power-ist">--</div>
<div class="lcd" id="lcd-power-soll">--</div>
<div class="lcd-unit">W</div>
</div>
</div>
<div id="pot-view">
<svg id="pot-svg" viewBox="0 0 290 255" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="pot-clip">
<rect x="63" y="30" width="114" height="142"/>
</clipPath>
<clipPath id="tube-clip">
<rect x="215" y="28" width="8" height="146"/>
</clipPath>
<clipPath id="water-clip">
<rect id="water-clip-rect" x="63" y="172" width="114" height="0"/>
</clipPath>
<clipPath id="fire-clip">
<rect x="55" y="175" width="140" height="78"/>
</clipPath>
<!-- Flame gradients: solid at base (y=1), transparent at tip (y=0) -->
<linearGradient id="fg-red" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#ff2200" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#ff5500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="fg-orange" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#ff7700" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#ffaa00" stop-opacity="0"/>
</linearGradient>
<linearGradient id="fg-yellow" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#ffe000" stop-opacity="0.85"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Fire (shown when heater is on, JS controls scale+opacity) -->
<g id="fire-group" clip-path="url(#fire-clip)">
<!-- Outer red flame -->
<path id="flame-outer" class="flame"
d="M76,248 C79,215 92,192 101,208 C106,192 113,177 120,180 C127,177 134,192 139,208 C148,192 161,215 164,248 Z"
fill="url(#fg-red)"/>
<!-- Middle orange flame -->
<path id="flame-mid" class="flame"
d="M88,248 C91,222 101,204 109,217 C113,204 118,191 122,195 C126,191 131,204 135,217 C143,204 153,222 156,248 Z"
fill="url(#fg-orange)"/>
<!-- Inner yellow flame -->
<path id="flame-inner" class="flame"
d="M100,248 C102,230 110,215 116,224 C118,215 121,205 124,208 C127,205 130,215 132,224 C138,215 147,230 148,248 Z"
fill="url(#fg-yellow)"/>
</g>
<!-- Power soll label (below flame area, updated by JS) -->
<text id="svg-power-soll" x="120" y="244" font-size="10"
fill="#ffe0b2" text-anchor="middle" dominant-baseline="auto"></text>
<!-- Water fill (behind pot walls) -->
<rect id="water-fill" x="63" y="172" width="114" height="0"
fill="#1a4a72" clip-path="url(#pot-clip)"/>
<path id="water-wave" d=""
fill="none" stroke="#4a9ed8" stroke-width="2"
clip-path="url(#pot-clip)"/>
<!-- Grain particles (shown when grain_mass > 0, clipped to water area) -->
<g id="grain-particles" clip-path="url(#water-clip)" opacity="0">
<circle class="grain-p" cx="80" cy="165" r="2.5" style="--fp-dur:3.2s;--fp-del:-0.5s"/>
<circle class="grain-p" cx="155" cy="158" r="2" style="--fp-dur:4.0s;--fp-del:-1.2s"/>
<circle class="grain-p" cx="105" cy="150" r="2.5" style="--fp-dur:2.8s;--fp-del:-2.1s"/>
<circle class="grain-p" cx="140" cy="143" r="2" style="--fp-dur:3.6s;--fp-del:-0.8s"/>
<circle class="grain-p" cx="75" cy="135" r="2.5" style="--fp-dur:4.2s;--fp-del:-1.5s"/>
<circle class="grain-p" cx="125" cy="125" r="2" style="--fp-dur:3.0s;--fp-del:-2.8s"/>
<circle class="grain-p" cx="90" cy="115" r="2.5" style="--fp-dur:3.8s;--fp-del:-0.3s"/>
<circle class="grain-p" cx="160" cy="105" r="2" style="--fp-dur:2.6s;--fp-del:-1.9s"/>
<circle class="grain-p" cx="110" cy="90" r="2.5" style="--fp-dur:3.4s;--fp-del:-1.1s"/>
<circle class="grain-p" cx="82" cy="75" r="2" style="--fp-dur:4.1s;--fp-del:-2.4s"/>
<circle class="grain-p" cx="148" cy="68" r="2.5" style="--fp-dur:2.9s;--fp-del:-0.7s"/>
<circle class="grain-p" cx="118" cy="55" r="2" style="--fp-dur:3.5s;--fp-del:-3.0s"/>
<circle class="grain-p" cx="72" cy="48" r="2.5" style="--fp-dur:4.3s;--fp-del:-1.6s"/>
<circle class="grain-p" cx="163" cy="40" r="2" style="--fp-dur:3.1s;--fp-del:-2.2s"/>
<circle class="grain-p" cx="118" cy="168" r="2" style="--fp-dur:3.7s;--fp-del:-0.9s"/>
<circle class="grain-p" cx="170" cy="162" r="2.5" style="--fp-dur:2.7s;--fp-del:-2.5s"/>
<circle class="grain-p" cx="68" cy="155" r="2" style="--fp-dur:4.4s;--fp-del:-1.0s"/>
<circle class="grain-p" cx="133" cy="148" r="2.5" style="--fp-dur:3.3s;--fp-del:-2.9s"/>
<circle class="grain-p" cx="95" cy="140" r="2" style="--fp-dur:2.5s;--fp-del:-0.4s"/>
<circle class="grain-p" cx="165" cy="132" r="2.5" style="--fp-dur:3.9s;--fp-del:-1.7s"/>
<circle class="grain-p" cx="78" cy="122" r="2" style="--fp-dur:4.5s;--fp-del:-2.3s"/>
<circle class="grain-p" cx="145" cy="112" r="2.5" style="--fp-dur:3.0s;--fp-del:-0.6s"/>
<circle class="grain-p" cx="100" cy="100" r="2" style="--fp-dur:2.7s;--fp-del:-1.4s"/>
<circle class="grain-p" cx="170" cy="88" r="2.5" style="--fp-dur:4.2s;--fp-del:-2.7s"/>
<circle class="grain-p" cx="68" cy="80" r="2" style="--fp-dur:3.6s;--fp-del:-0.2s"/>
<circle class="grain-p" cx="133" cy="70" r="2.5" style="--fp-dur:2.8s;--fp-del:-1.8s"/>
<circle class="grain-p" cx="95" cy="60" r="2" style="--fp-dur:4.0s;--fp-del:-2.6s"/>
<circle class="grain-p" cx="165" cy="52" r="2.5" style="--fp-dur:3.2s;--fp-del:-1.3s"/>
<circle class="grain-p" cx="78" cy="42" r="2" style="--fp-dur:2.6s;--fp-del:-3.1s"/>
<circle class="grain-p" cx="145" cy="35" r="2.5" style="--fp-dur:3.8s;--fp-del:-0.8s"/>
<circle class="grain-p" cx="112" cy="130" r="2" style="--fp-dur:4.1s;--fp-del:-2.0s"/>
<circle class="grain-p" cx="88" cy="97" r="2.5" style="--fp-dur:3.4s;--fp-del:-1.5s"/>
<circle class="grain-p" cx="155" cy="78" r="2" style="--fp-dur:2.9s;--fp-del:-0.1s"/>
<circle class="grain-p" cx="70" cy="62" r="2.5" style="--fp-dur:4.3s;--fp-del:-2.4s"/>
<circle class="grain-p" cx="138" cy="45" r="2" style="--fp-dur:3.5s;--fp-del:-1.0s"/>
</g>
<!-- Pot outline (transparent walls) -->
<line x1="60" y1="28" x2="60" y2="174" stroke="#bbb" stroke-width="4" stroke-linecap="round"/>
<line x1="180" y1="28" x2="180" y2="174" stroke="#bbb" stroke-width="4" stroke-linecap="round"/>
<line x1="60" y1="174" x2="180" y2="174" stroke="#bbb" stroke-width="4" stroke-linecap="round"/>
<line x1="60" y1="28" x2="180" y2="28" stroke="#bbb" stroke-width="4" stroke-linecap="round"/>
<!-- Utilized volume level indicator (JS-positioned) -->
<line id="level-tick" x1="52" y1="172" x2="60" y2="172"
stroke="#4a9ed8" stroke-width="1.5" stroke-linecap="round" style="display:none"/>
<text id="level-label" x="50" y="172" font-size="8" fill="#4a9ed8"
dominant-baseline="middle" text-anchor="end" style="display:none">0.0 L</text>
<!-- Stirrer blades (inside pot) -->
<g id="stirrer-blades" clip-path="url(#pot-clip)">
<rect x="87" y="133" width="31" height="5" fill="#666" rx="1"/>
<rect x="122" y="133" width="31" height="5" fill="#666" rx="1"/>
</g>
<!-- Lid -->
<rect x="56" y="20" width="128" height="10" fill="#444" rx="2"/>
<rect x="56" y="20" width="128" height="3" fill="#555"/>
<!-- Stirrer shaft (over lid) -->
<rect x="118" y="2" width="4" height="134" fill="#777"/>
<!-- Motor housing -->
<rect x="106" y="2" width="28" height="20" fill="#333" rx="3"/>
<rect x="110" y="6" width="10" height="8" fill="#4a4a4a" rx="1"/>
<circle cx="129" cy="11" r="3" fill="#cc2222"/>
<!-- Stirrer RPM label (updated by JS) -->
<text id="svg-stirrer-rpm" x="137" y="12" font-size="9"
fill="#9e9e9e" text-anchor="start" dominant-baseline="middle"></text>
<!-- Thermometer tube -->
<rect x="212" y="28" width="14" height="146" rx="7"
fill="#1e1e1e" stroke="#aaa" stroke-width="1.5"/>
<!-- Thermometer bulb -->
<circle cx="219" cy="178" r="13" fill="#1e1e1e" stroke="#aaa" stroke-width="1.5"/>
<!-- Mercury: bulb always red -->
<circle cx="219" cy="178" r="10" fill="#e0190a"/>
<!-- Mercury column (JS sets y and height) -->
<rect id="thermo-mercury" x="215" y="174" width="8" height="0"
fill="#e0190a" clip-path="url(#tube-clip)"/>
<!-- Soll temperature marker (left side, orange, JS-positioned) -->
<line id="thermo-soll-tick" x1="203" y1="174" x2="212" y2="174"
stroke="#ff7043" stroke-width="2" stroke-linecap="round"/>
<text id="thermo-soll-label" x="201" y="174" font-size="9"
fill="#ff7043" text-anchor="end" dominant-baseline="middle"></text>
<!-- Scale ticks -->
<line x1="227" y1="28" x2="236" y2="28" stroke="#9e9e9e" stroke-width="1.5"/>
<text x="239" y="32" font-size="9" fill="#9e9e9e">100</text>
<line x1="227" y1="65" x2="232" y2="65" stroke="#9e9e9e" stroke-width="1"/>
<line x1="227" y1="101" x2="236" y2="101" stroke="#9e9e9e" stroke-width="1.5"/>
<text x="239" y="105" font-size="9" fill="#9e9e9e">50</text>
<line x1="227" y1="137" x2="232" y2="137" stroke="#9e9e9e" stroke-width="1"/>
<line x1="227" y1="174" x2="236" y2="174" stroke="#9e9e9e" stroke-width="1.5"/>
<text x="239" y="178" font-size="9" fill="#9e9e9e">0 °C</text>
<!-- Current temperature label -->
<text id="thermo-temp-label" x="219" y="201" font-size="10"
fill="#e0e0e0" text-anchor="middle">--</text>
</svg>
</div>
</div><!-- #lcd-pot-row -->
</div><!-- #col-center -->
<section id="col-progress">
<div id="step-plates"></div>
</section>
</main>
<dialog id="sud-message-dialog">
<p id="sud-message-text"></p>
<form>
<button id="sud-message-ok" type="button">OK</button>
</form>
</dialog>
<footer>
<div id="sud-status-line"></div>
<div id="env-status-line"></div>
</footer>
<script src="app.js"></script>
</body>
</html>