Add a browser client (web/) alongside the desktop GUI
A new HTML/CSS/JS client, added next to client/brewpi_gui.py rather than replacing it, speaking the exact same WebSocket pub/sub protocol - no server-side protocol changes needed. server/brewpi.py gains a --http-port (default 8080) stdlib http.server.ThreadingHTTPServer, in its own daemon thread and deliberately decoupled from the existing asyncio WebSocket server, serving web/ statically. v1 covers the Manual tab (direct heater/stirrer/controller control) and the new Progress tab - the two most actionable surfaces, neither needing a charting library. web/app.js ports the relevant logic from brewpi_gui.py directly: components/sud.py's _build_step()/ _merge_defaults() for resolving the raw schedule doc, and the StepPlate/_update_step_plates()/update_status_step_label() logic behind the Progress tab and status line. Deliberately deferred rather than stubbed (see web/TODO.md for the full parity backlog against the Qt GUI): Sud control actions (Start/ Pause/Stop/Confirm), the Automatic tab's forecast plot and the Plot tab's live strip charts, Sud file management, and auth (matching the WebSocket server's own existing lack of it). No new Python dependencies - functools/threading/http.server are all stdlib. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019qvu5giu7gvRCyEWzf2Vpx
This commit is contained in:
@@ -98,6 +98,14 @@ config.json.templ Configuration template - the "sim" component
|
||||
simulation; point Stirrer/Heater at real
|
||||
serial ports and plant_name at a real
|
||||
backend to switch to hardware.
|
||||
|
||||
web/ Browser client - static HTML/CSS/JS (no build
|
||||
step, no dependencies), speaking the exact
|
||||
same WebSocket protocol as client/brewpi_gui.py
|
||||
(see "Browser client" below). Served by
|
||||
server/brewpi.py itself over plain HTTP, so a
|
||||
browser anywhere on the network can reach the
|
||||
rig with nothing installed beyond the browser.
|
||||
```
|
||||
|
||||
### Data flow
|
||||
@@ -165,7 +173,9 @@ no-op rather than an empty dialog.
|
||||
- Server (`server/requirements.txt`): `numpy`, `scipy`, `matplotlib` (only
|
||||
for `scripts/demos/*`, not the running server itself), `websockets`,
|
||||
`dpath`, and `pyserial`/`spidev` if using real hardware backends.
|
||||
- Client (`client/requirements.txt`): `PyQt5`.
|
||||
- Desktop client (`client/requirements.txt`): `PyQt5`.
|
||||
- Browser client (`web/`): nothing - just a browser. Served by
|
||||
`server/brewpi.py` itself.
|
||||
|
||||
Install with:
|
||||
|
||||
@@ -190,23 +200,28 @@ pip install -r client/requirements.txt
|
||||
./brewpi.py
|
||||
```
|
||||
|
||||
This serves the WebSocket on `ws://0.0.0.0:8765`, ticking in real time
|
||||
(1 simulated second per tick, no speedup) by default. `--dt` (simulated
|
||||
seconds/tick) and `--sim-warp-factor` (how many of those fit into one
|
||||
real second) are run-mode knobs, not `config.json` material - a
|
||||
plant/hardware description shouldn't change just because of how a
|
||||
This serves the WebSocket on `ws://0.0.0.0:8765` and the browser client
|
||||
(see "Browser client" below) on `http://0.0.0.0:8080`, ticking in real
|
||||
time (1 simulated second per tick, no speedup) by default. `--dt`
|
||||
(simulated seconds/tick) and `--sim-warp-factor` (how many of those fit
|
||||
into one real second) are run-mode knobs, not `config.json` material -
|
||||
a plant/hardware description shouldn't change just because of how a
|
||||
particular run happens to be invoked - so they're CLI-only:
|
||||
|
||||
```bash
|
||||
./brewpi.py --sim-warp-factor 50 # 50x speedup, e.g. for dev/testing
|
||||
./brewpi.py --http-port 8888 # change the browser client's port
|
||||
```
|
||||
3. Start the GUI client and connect to the server's URI:
|
||||
3. Either connect with the desktop GUI:
|
||||
|
||||
```bash
|
||||
cd client
|
||||
./brewpi_gui.py
|
||||
```
|
||||
|
||||
or open `http://<server-host>:8080/` in any browser (same machine or
|
||||
anywhere else on the network) for the browser client.
|
||||
|
||||
`server/brewpi.sh` shows how this is wired up to run under a `virtualenvwrapper`
|
||||
environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment.
|
||||
|
||||
@@ -502,6 +517,40 @@ one, since energy is banked per step rather than accumulated as a grand
|
||||
total - summed client-side instead, from every finished step's own Wh
|
||||
total plus whatever the active one has used so far).
|
||||
|
||||
### Browser client
|
||||
|
||||
`web/` is a second, independent client - plain HTML/CSS/JS, no build step,
|
||||
no dependencies - for the same server, added so the rig is reachable from
|
||||
any browser on the network rather than only from a machine with the PyQt5
|
||||
desktop app installed. It speaks the exact same WebSocket pub/sub protocol
|
||||
`client/brewpi_gui.py` does (same subscribe-on-connect handshake, same
|
||||
message shapes on every channel), so nothing about the protocol or
|
||||
`server/brewpi.py`'s data flow needed to change for it to exist - only a
|
||||
plain static-file HTTP server (`http.server.ThreadingHTTPServer`, stdlib,
|
||||
in its own daemon thread, `--http-port`, default `8080`) needed adding,
|
||||
deliberately independent of the existing `websockets`-based asyncio server
|
||||
so neither can affect the other's behavior or availability.
|
||||
|
||||
v1 covers only the Manual tab (direct heater/stirrer/controller control)
|
||||
and the Progress tab (above) - the two most actionable surfaces, and
|
||||
neither needs a charting library. `web/app.js` ports the relevant logic
|
||||
from `client/brewpi_gui.py` directly: `components/sud.py`'s
|
||||
`_build_step()`/`_merge_defaults()` (the raw doc from `Sud.Json` is
|
||||
unresolved - every step still needs `default.step` merged in), and the
|
||||
`StepPlate`/`_update_step_plates()`/`update_status_step_label()` logic
|
||||
behind the Progress tab and status line. Deliberately deferred rather than
|
||||
stubbed: the Automatic tab's forecast-vs-actual plot and the live Plot
|
||||
tab's strip charts (both need a charting approach - revisit once Manual +
|
||||
Progress are validated), and Sud file management (New/Load/Save dialogs -
|
||||
v1 only *displays* whatever schedule the server already has loaded, fetched
|
||||
via the same `{"Sud": {"Save": true}}` request `Window.connect()` sends).
|
||||
|
||||
No authentication - matching the existing WebSocket server's own complete
|
||||
lack of it, not a new gap. Worth keeping in mind regardless: "browser,
|
||||
reachable from anywhere on the network" is a meaningfully wider exposure
|
||||
than "desktop app, reachable from wherever its process happens to be
|
||||
running" if this is ever reachable off a trusted LAN.
|
||||
|
||||
## Logging & analysis
|
||||
|
||||
`tasks/sud_log.py`'s `SudLogTask` records every Sud run's measured data -
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from utils import ChangedFloat
|
||||
from ws.message import MessageDispatcher
|
||||
from ws.server.ws_server_multi_user import WsServerMultiUser
|
||||
@@ -49,6 +52,13 @@ if __name__ == '__main__':
|
||||
# otherwise - e.g. a much higher --sim-warp-factor for dev/testing.
|
||||
parser.add_argument("--dt", type=float, default=1.0)
|
||||
parser.add_argument("--sim-warp-factor", type=float, default=1.0)
|
||||
# Serves web/ (the browser client - see web/README or the project
|
||||
# README's "Browser client" section) so a plain browser can reach this
|
||||
# same rig remotely, no desktop GUI required. A stdlib HTTP server in
|
||||
# its own thread, deliberately independent of the asyncio WebSocket
|
||||
# server below (ws/server/ws_server.py) - neither can affect the
|
||||
# other's behavior or availability.
|
||||
parser.add_argument("--http-port", type=int, default=8080)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -173,4 +183,11 @@ if __name__ == '__main__':
|
||||
h_dispatcher = taskmgr.start()
|
||||
h_server = server.listen("0.0.0.0", 8765)
|
||||
asyncio.gather(h_dispatcher, h_server)
|
||||
|
||||
web_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "web")
|
||||
http_handler = functools.partial(SimpleHTTPRequestHandler, directory=web_dir)
|
||||
http_server = ThreadingHTTPServer(("0.0.0.0", args.http_port), http_handler)
|
||||
threading.Thread(target=http_server.serve_forever, daemon=True).start()
|
||||
print("Serving {} on http://0.0.0.0:{}".format(web_dir, args.http_port))
|
||||
|
||||
server.run_forever()
|
||||
|
||||
@@ -3,3 +3,5 @@ scipy
|
||||
matplotlib
|
||||
websockets==10.4
|
||||
dpath
|
||||
# web/ (the browser client) needs none of these - it's static HTML/JS
|
||||
# served straight from the stdlib's http.server, no new dependency.
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
# web/ (browser client) vs. client/brewpi_gui.py parity backlog
|
||||
|
||||
`web/app.js` ported the Manual tab's direct controls and the Progress tab
|
||||
wholesale from `client/brewpi_gui.py` (see README.md's "Browser client").
|
||||
Everything below is what that v1 pass deliberately left out - either
|
||||
flagged as deferred in the plan, or only noticed once the port was done
|
||||
and checked against the Qt source side by side.
|
||||
|
||||
- [ ] **No way to actually run a brew.** There's no Start/Pause/Stop/
|
||||
Confirm anywhere in `web/index.html` - `on_action_sud_start()`/
|
||||
`on_action_sud_pause()`/`on_action_sud_stop()` (`client/brewpi_gui.py:
|
||||
868-875`) are one-line `{"Sud": {"Start"/"Pause"/"Stop": true}}` sends,
|
||||
trivial to add as buttons; `{"Sud": {"Confirm": true}}` already needs
|
||||
`sudUserMessage`/`sudState` (next item) to know *when* it's actually
|
||||
waiting on one. Until this exists, the Progress tab can only watch a
|
||||
run started from the desktop GUI (or a raw `Sud.Start` send from the
|
||||
browser console) - it's read-only.
|
||||
|
||||
- [ ] **`WAIT_USER` never surfaces to the user.** `sudUserMessage` is
|
||||
tracked in `app.js` but nothing ever shows it - `show_user_message()`
|
||||
(`client/brewpi_gui.py:877-882`) pops a modal the instant `WAIT_USER` is
|
||||
newly entered (`prev_state != WAIT_USER`) and sends `Confirm` itself
|
||||
when the user dismisses it. Needs the same "newly entered" edge
|
||||
detection in `onSudChanged()`'s `State` branch (the `wasRunning`
|
||||
tracking there is close, but checks running-vs-not, not specifically
|
||||
the `WAIT_USER` transition) plus a modal (a plain `<dialog>` works,
|
||||
no library needed) that sends `Confirm` on close.
|
||||
|
||||
- [ ] **No schedule management.** Matches README.md's "Browser client"
|
||||
section, called out there as deliberately deferred rather than missing
|
||||
by oversight - `on_action_sud_new/_save/_load()`
|
||||
(`client/brewpi_gui.py:884-912`) use native file dialogs `web/` has no
|
||||
equivalent of yet. Load maps to `<input type="file">` +
|
||||
`FileReader`+`{"Sud": {"Load": ...}}`; Save to a `Blob`+`<a download>`
|
||||
of whatever the next `Json` push contains; New is just the same
|
||||
hardcoded empty-doc `Load` Qt sends, no dialog needed at all.
|
||||
|
||||
- [ ] **No temperature-controller state/cooldown indicator.** Qt shows
|
||||
the raw FSM state (`label_state.setText(msg['State'])` - e.g.
|
||||
`"States.HOLD"`) and a separate blinking "Cool down" label specifically
|
||||
for `States.COOL` (`set_tc_cooling()`, `client/brewpi_gui.py:820-829`,
|
||||
driven by `on_tempctrl_changed()`'s `'State'` branch). `app.js`'s
|
||||
`onTempCtrlChanged()` never reads `msg.State` at all right now - worth
|
||||
a small label in the Controller panel, reusing the same 500ms
|
||||
blink-timer pattern the Progress tab's LEDs already have.
|
||||
|
||||
- [ ] **No transient status notifications.** Qt's status bar shows
|
||||
short-lived messages on certain events - e.g. `"Sud schedule updated:
|
||||
{name}"` for 5s on every `Name` push (`client/brewpi_gui.py:1043-1045`).
|
||||
`web/`'s status line only ever shows the persistent Sud/env summaries;
|
||||
there's nowhere for one-shot events to surface at all.
|
||||
|
||||
- [ ] **Nothing persists across reloads.** `client/user_config.py` keeps
|
||||
the last ambient temperature and Sud file-dialog directory in
|
||||
`~/.config/brewpi/gui.json` across restarts. `web/`'s ambient-temp
|
||||
field always starts blank until the first `System.AmbientTemp` push;
|
||||
`localStorage` would be the natural equivalent (and the host field
|
||||
could remember the last-used server too, which the desktop GUI doesn't
|
||||
even need since it's one app per machine).
|
||||
|
||||
- [ ] **No reconnect handling.** A dropped WebSocket (`ws.onclose`) just
|
||||
flips the UI to "Disconnected" and stops - the user has to click
|
||||
Connect again by hand. `WsClient` (`ws/client/ws_client.py`, used by
|
||||
the desktop GUI) - check whether it already retries and mirror that, or
|
||||
add a simple backoff retry loop in `connect()`.
|
||||
|
||||
- [ ] **Automatic tab (forecast-vs-actual plot) and Plot tab (live strip
|
||||
charts) don't exist.** Flagged in the original plan and in README.md's
|
||||
"Browser client" section as deferred pending a charting decision, not
|
||||
an oversight - listed here too so this file alone is a complete picture
|
||||
of the gap. `SudForecastPlot`/`RealtimePlot`
|
||||
(`client/brewpi_gui.py:121-258`) are the reference for what each needs
|
||||
to show.
|
||||
|
||||
- [ ] **No authentication.** Also already called out in README.md - matches
|
||||
the WebSocket server's own complete lack of it, not a new gap, but
|
||||
worth a line here too since "browser, reachable from anywhere on the
|
||||
network" is a wider exposure than the desktop app ever was.
|
||||
+599
@@ -0,0 +1,599 @@
|
||||
// BrewPi browser client (v1: Manual + Progress tabs only - see README.md's
|
||||
// "Browser client" section). Talks the exact same WebSocket pub/sub
|
||||
// protocol as client/brewpi_gui.py - this file mirrors that client's logic
|
||||
// directly wherever the two overlap, so see brewpi_gui.py's own comments
|
||||
// for the *why* behind anything that looks non-obvious here.
|
||||
|
||||
const CHANNELS = ['Pot', 'Sensor', 'Heater', 'Stirrer', 'TempCtrl', 'Sud', 'System'];
|
||||
// str(SudState.X) values (as sent in the 'State' message) that count as
|
||||
// "running" - mirrors client/brewpi_gui.py's SUD_RUNNING_STATES.
|
||||
const RUNNING_STATES = new Set(['SudState.RAMPING', 'SudState.HOLDING', 'SudState.WAIT_USER']);
|
||||
|
||||
let ws = null;
|
||||
let connected = false;
|
||||
|
||||
// Live Manual-tab readouts (TempCtrl/Heater/Stirrer channels).
|
||||
let plotTempIst = null;
|
||||
let stirrerSpeedIst = 0;
|
||||
// Only the very first authoritative read after connecting is used to set
|
||||
// these particular sliders - thereafter they're purely user-controlled.
|
||||
// Without this, a slider mid-drag would keep getting snapped back by the
|
||||
// server's own *actual* (not commanded) readback - e.g. the heater's
|
||||
// discrete power steps/duty-cycling mean 'Power' rarely matches whatever
|
||||
// was just commanded exactly. Mirrors brewpi_gui.py's *_initial_update
|
||||
// flags (there, needed to avoid echoing a command right back as the same
|
||||
// value via Qt's signal/slot machinery; here the DOM itself never echoes
|
||||
// a plain .value assignment as an 'input'/'change' event, but the
|
||||
// stale-overwrite risk is the same).
|
||||
let initialPowerSync = true;
|
||||
let initialSpeedSync = true;
|
||||
let initialHeatrateSync = true;
|
||||
let initialTempSollSync = true;
|
||||
let initialHeaterActivateSync = true;
|
||||
let initialStirrerActivateSync = true;
|
||||
|
||||
let ambientTemp = null;
|
||||
let warpFactor = 1.0;
|
||||
|
||||
// Sud state - mirrors the sud_* fields on client/brewpi_gui.py's Window.
|
||||
let sudSchedule = [];
|
||||
let sudName = '';
|
||||
let sudPotMass = 0;
|
||||
let sudGrainMass = 0;
|
||||
let sudWaterMass = 0;
|
||||
let sudEmpty = true;
|
||||
let sudLastLoadedDoc = null;
|
||||
let sudState = null;
|
||||
let wasRunning = false;
|
||||
let sudStepIndex = null;
|
||||
let sudStepDescr = null;
|
||||
let sudStepType = null;
|
||||
let sudHoldRemaining = 0;
|
||||
let sudUserMessage = null;
|
||||
// Never reset back to "unknown" just because a run finishes - see
|
||||
// client/brewpi_gui.py's sud_elapsed_last for why (sud_elapsed_seconds
|
||||
// there gates a forecast-plot view this client doesn't have, so that
|
||||
// distinction doesn't apply here - this is simply always "the last real
|
||||
// Elapsed value").
|
||||
let elapsed = null;
|
||||
let stepActualFrozen = {};
|
||||
let forecastStepStarts = {};
|
||||
let forecastFinished = false;
|
||||
let forecastFinalT = null;
|
||||
let energyByStep = {};
|
||||
let energyCurrent = 0;
|
||||
|
||||
let stepPlates = [];
|
||||
let blinkOn = true;
|
||||
|
||||
function sendMsg(channel, payload) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({[channel]: payload}));
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
seconds = Math.max(0, Math.floor(seconds));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
if (hours) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// --- Sud schedule resolution - ports components/sud.py's _merge_defaults()/
|
||||
// _build_step() verbatim, since the raw doc from 'Json' is unresolved. ---
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeDefaults(defaults, override) {
|
||||
const merged = Object.assign({}, defaults);
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
if (isPlainObject(merged[key]) && isPlainObject(value)) {
|
||||
merged[key] = mergeDefaults(merged[key], value);
|
||||
} else {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function buildStep(number, defaults, rawStep) {
|
||||
const step = {number: number};
|
||||
for (const key of ['descr', 'user_message', 'user_wait_for_continue', 'grain_mass', 'water_mass']) {
|
||||
step[key] = (key in rawStep) ? rawStep[key] : defaults[key];
|
||||
}
|
||||
// Deliberately not defaulted from default.step - a step omitting it
|
||||
// has no new target of its own and keeps whatever the previous step
|
||||
// left running (see components/sud.py's _build_step()).
|
||||
step.temperature = ('temperature' in rawStep) ? rawStep.temperature : null;
|
||||
step.ramp = mergeDefaults(defaults.ramp || {}, rawStep.ramp || {});
|
||||
if ('hold' in rawStep) {
|
||||
step.hold = mergeDefaults(defaults.hold || {}, rawStep.hold || {});
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
function parseSudDoc(doc) {
|
||||
const stepDefaults = (doc.default && doc.default.step) || {};
|
||||
const schedule = (doc.steps || []).map((raw, i) => buildStep(i + 1, stepDefaults, raw));
|
||||
return {
|
||||
name: doc.Name || '',
|
||||
schedule: schedule,
|
||||
potMass: doc.pot_mass || 0,
|
||||
grainMass: doc.grain_mass || 0,
|
||||
waterMass: doc.water_mass || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Progress tab: one step-plate per schedule step - mirrors
|
||||
// client/brewpi_gui.py's StepPlate/_rebuild_step_plates()/
|
||||
// _update_step_plates(). ---
|
||||
|
||||
function createStepPlate(index, step, resolvedTemp) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'step-plate';
|
||||
|
||||
const led = document.createElement('div');
|
||||
led.className = 'led';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'title';
|
||||
title.textContent = `Step ${index + 1}: ${step.descr || ''}`;
|
||||
const remaining = document.createElement('div');
|
||||
remaining.className = 'remaining';
|
||||
remaining.textContent = '--:--';
|
||||
const target = document.createElement('div');
|
||||
target.className = 'field span2';
|
||||
const actual = document.createElement('div');
|
||||
actual.className = 'field';
|
||||
const masses = document.createElement('div');
|
||||
masses.className = 'field span2';
|
||||
const energy = document.createElement('div');
|
||||
energy.className = 'field';
|
||||
const rate = document.createElement('div');
|
||||
rate.className = 'field span2';
|
||||
const stirrer = document.createElement('div');
|
||||
stirrer.className = 'field';
|
||||
el.append(led, title, remaining, target, actual, masses, energy, rate, stirrer);
|
||||
|
||||
const grain = step.grain_mass || 0;
|
||||
const water = step.water_mass || 0;
|
||||
masses.textContent = `Grain ${grain.toFixed(2)} kg / Water ${water.toFixed(2)} kg`;
|
||||
rate.textContent = `Rate ${((step.ramp && step.ramp.rate) || 0).toFixed(2)} °/min`;
|
||||
target.textContent = `Target ${resolvedTemp !== null ? resolvedTemp.toFixed(1) + ' °C' : '—'}`;
|
||||
// The hold phase's speed, not the ramp phase's, as the "configured"
|
||||
// fallback shown for an inactive step - see StepPlate.set_step()'s
|
||||
// own comment.
|
||||
const holdStirrer = (step.hold && step.hold.stirrer) || {};
|
||||
const rampStirrer = (step.ramp && step.ramp.stirrer) || {};
|
||||
const configuredSpeed = ('speed' in holdStirrer) ? holdStirrer.speed : (rampStirrer.speed || 0);
|
||||
|
||||
return {el, led, remaining, target, actual, masses, energy, rate, stirrer, ledMode: 'off', configuredSpeed};
|
||||
}
|
||||
|
||||
function rebuildStepPlates() {
|
||||
const container = document.getElementById('step-plates');
|
||||
container.innerHTML = '';
|
||||
stepPlates = [];
|
||||
let resolvedTemp = null;
|
||||
sudSchedule.forEach((step, index) => {
|
||||
if (step.temperature !== null && step.temperature !== undefined) {
|
||||
resolvedTemp = step.temperature;
|
||||
}
|
||||
const plate = createStepPlate(index, step, resolvedTemp);
|
||||
stepPlates.push(plate);
|
||||
container.appendChild(plate.el);
|
||||
});
|
||||
updateStepPlates();
|
||||
}
|
||||
|
||||
function applyLedColor(plate) {
|
||||
if (plate.ledMode === 'done') {
|
||||
plate.led.style.background = 'var(--led-green)';
|
||||
} else if (plate.ledMode === 'ramp') {
|
||||
plate.led.style.background = blinkOn ? 'var(--led-orange)' : 'var(--led-orange-dim)';
|
||||
} else if (plate.ledMode === 'hold') {
|
||||
plate.led.style.background = blinkOn ? 'var(--led-green)' : 'var(--led-green-dim)';
|
||||
} else {
|
||||
plate.led.style.background = 'var(--led-off)';
|
||||
}
|
||||
}
|
||||
|
||||
function setLed(plate, mode) {
|
||||
plate.ledMode = mode;
|
||||
applyLedColor(plate);
|
||||
}
|
||||
|
||||
function updateStepPlates() {
|
||||
if (stepPlates.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentElapsed = (elapsed !== null) ? elapsed : 0;
|
||||
const n = stepPlates.length;
|
||||
stepPlates.forEach((plate, i) => {
|
||||
const startT = forecastStepStarts[i];
|
||||
let endT = forecastStepStarts[i + 1];
|
||||
if (endT === undefined && i === n - 1 && forecastFinished) {
|
||||
endT = forecastFinalT;
|
||||
}
|
||||
if (startT !== undefined && endT !== undefined) {
|
||||
const total = Math.max(0, endT - startT);
|
||||
plate.remaining.textContent = formatDuration(Math.max(0, Math.min(total, endT - currentElapsed)));
|
||||
} else {
|
||||
plate.remaining.textContent = '--:--';
|
||||
}
|
||||
|
||||
if (sudStepIndex === null || i > sudStepIndex) {
|
||||
setLed(plate, 'off');
|
||||
} else if (i < sudStepIndex) {
|
||||
setLed(plate, 'done');
|
||||
} else {
|
||||
// WAIT_USER/PAUSED don't change sudStepType - it stays
|
||||
// whatever phase was actually last active, which is exactly
|
||||
// the color this step's LED should keep flashing.
|
||||
setLed(plate, (sudStepType === 'ramp' || sudStepType === 'hold') ? sudStepType : 'hold');
|
||||
}
|
||||
|
||||
if (sudStepIndex !== null && i === sudStepIndex) {
|
||||
plate.actual.textContent = `Actual ${plotTempIst !== null ? plotTempIst.toFixed(1) + ' °C' : '—'}`;
|
||||
plate.stirrer.textContent = `Stirrer ${stirrerSpeedIst.toFixed(0)} rpm`;
|
||||
plate.energy.textContent = `Energy ${energyCurrent.toFixed(0)} Wh`;
|
||||
} else if (i in stepActualFrozen) {
|
||||
plate.actual.textContent = `Actual ${stepActualFrozen[i].toFixed(1)} °C`;
|
||||
plate.stirrer.textContent = `Stirrer ${plate.configuredSpeed.toFixed(0)} rpm (cfg)`;
|
||||
plate.energy.textContent = (i in energyByStep) ? `Energy ${energyByStep[i].toFixed(0)} Wh` : 'Energy —';
|
||||
} else {
|
||||
plate.actual.textContent = 'Actual —';
|
||||
plate.stirrer.textContent = `Stirrer ${plate.configuredSpeed.toFixed(0)} rpm (cfg)`;
|
||||
plate.energy.textContent = (i in energyByStep) ? `Energy ${energyByStep[i].toFixed(0)} Wh` : 'Energy —';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Status line - mirrors update_status_step_label(). ---
|
||||
|
||||
function updateStatusLine() {
|
||||
const el = document.getElementById('sud-status-line');
|
||||
if (sudSchedule.length === 0) {
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
let text = '';
|
||||
let grain = sudGrainMass, water = sudWaterMass;
|
||||
if (sudStepDescr) {
|
||||
text = `Step ${sudStepIndex}: ${sudStepDescr}`;
|
||||
if (sudStepType === 'hold') {
|
||||
const remaining = Math.max(sudHoldRemaining, 0);
|
||||
text += ` (${Math.floor(remaining / 60)}:${String(Math.floor(remaining % 60)).padStart(2, '0')} remaining)`;
|
||||
}
|
||||
if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) {
|
||||
const step = sudSchedule[sudStepIndex];
|
||||
grain = step.grain_mass || 0;
|
||||
water = step.water_mass || 0;
|
||||
}
|
||||
}
|
||||
const pot = sudPotMass;
|
||||
const massText = `Pot: ${pot.toFixed(2)} kg, Water: ${water.toFixed(2)} kg, Grain ${grain.toFixed(2)} kg, total ${(pot + water + grain).toFixed(2)} kg`;
|
||||
text = text ? `${text} ${massText}` : massText;
|
||||
const elapsedText = `Elapsed ${elapsed !== null ? formatDuration(elapsed) : '--:--'}`;
|
||||
const totalEnergyKwh = (Object.values(energyByStep).reduce((a, b) => a + b, 0) + energyCurrent) / 1000.0;
|
||||
const energyText = `Energy ${totalEnergyKwh.toFixed(2)} kWh`;
|
||||
el.textContent = `${text} ${elapsedText} ${energyText}`;
|
||||
}
|
||||
|
||||
function updateEnvStatusLine() {
|
||||
const el = document.getElementById('env-status-line');
|
||||
const ambient = ambientTemp !== null ? `${ambientTemp.toFixed(1)}°C` : '-';
|
||||
el.textContent = `Ambient: ${ambient} Warp: ${warpFactor.toFixed(1)}x`;
|
||||
}
|
||||
|
||||
function updateSudForecast(doc) {
|
||||
const parsed = parseSudDoc(doc);
|
||||
// A genuinely different schedule resets run-progress state; a
|
||||
// standalone re-fetch of the *same* running one (e.g. the Save sent
|
||||
// on every connect) must not - see client/brewpi_gui.py's
|
||||
// update_sud_forecast() for the bug this avoids.
|
||||
const isNewSchedule = JSON.stringify(doc) !== JSON.stringify(sudLastLoadedDoc);
|
||||
sudLastLoadedDoc = doc;
|
||||
sudName = parsed.name;
|
||||
sudSchedule = parsed.schedule;
|
||||
sudPotMass = parsed.potMass;
|
||||
sudGrainMass = parsed.grainMass;
|
||||
sudWaterMass = parsed.waterMass;
|
||||
sudEmpty = sudSchedule.length === 0;
|
||||
if (isNewSchedule) {
|
||||
sudStepDescr = null;
|
||||
sudStepIndex = null;
|
||||
sudStepType = null;
|
||||
sudHoldRemaining = 0;
|
||||
forecastStepStarts = {};
|
||||
forecastFinished = false;
|
||||
forecastFinalT = null;
|
||||
stepActualFrozen = {};
|
||||
energyByStep = {};
|
||||
energyCurrent = 0;
|
||||
elapsed = null;
|
||||
}
|
||||
rebuildStepPlates();
|
||||
updateStatusLine();
|
||||
document.title = sudName ? `BrewPi - ${sudName}` : 'BrewPi';
|
||||
}
|
||||
|
||||
// --- Channel handlers - mirror client/brewpi_gui.py's on_*_changed(). ---
|
||||
|
||||
function onHeaterChanged(msg) {
|
||||
for (const key of Object.keys(msg)) {
|
||||
if (key === 'Activate') {
|
||||
if (initialHeaterActivateSync) {
|
||||
document.getElementById('heater-activate').checked = !!msg.Activate;
|
||||
initialHeaterActivateSync = false;
|
||||
}
|
||||
} else if (key === 'PowerSet') {
|
||||
document.getElementById('lcd-power-soll').textContent = msg.PowerSet;
|
||||
} else if (key === 'Power') {
|
||||
document.getElementById('lcd-power-ist').textContent = msg.Power;
|
||||
if (initialPowerSync) {
|
||||
document.getElementById('heater-power').value = msg.Power;
|
||||
document.getElementById('heater-power-readout').textContent = msg.Power;
|
||||
initialPowerSync = false;
|
||||
}
|
||||
} else if (key === 'Capabilities') {
|
||||
const power = msg.Capabilities.Power;
|
||||
const slider = document.getElementById('heater-power');
|
||||
slider.min = power.Min;
|
||||
slider.max = power.Max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onStirrerChanged(msg) {
|
||||
for (const key of Object.keys(msg)) {
|
||||
if (key === 'Activate') {
|
||||
if (initialStirrerActivateSync) {
|
||||
document.getElementById('stirrer-activate').checked = !!msg.Activate;
|
||||
initialStirrerActivateSync = false;
|
||||
}
|
||||
} else if (key === 'Speed') {
|
||||
stirrerSpeedIst = msg.Speed;
|
||||
if (initialSpeedSync) {
|
||||
document.getElementById('stirrer-speed').value = msg.Speed;
|
||||
document.getElementById('stirrer-speed-readout').textContent = msg.Speed;
|
||||
initialSpeedSync = false;
|
||||
}
|
||||
updateStepPlates();
|
||||
} else if (key === 'Capabilities') {
|
||||
const power = msg.Capabilities.Power;
|
||||
const slider = document.getElementById('stirrer-speed');
|
||||
slider.min = power.Min;
|
||||
slider.max = power.Max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onTempCtrlChanged(msg) {
|
||||
for (const key of Object.keys(msg)) {
|
||||
if (key === 'Soll') {
|
||||
const soll = msg.Soll;
|
||||
if ('Temp' in soll) {
|
||||
document.getElementById('lcd-temp-soll').textContent = soll.Temp;
|
||||
if (initialTempSollSync) {
|
||||
document.getElementById('temp-soll').value = Math.round(soll.Temp);
|
||||
initialTempSollSync = false;
|
||||
}
|
||||
document.getElementById('temp-soll-readout').textContent = document.getElementById('temp-soll').value;
|
||||
}
|
||||
if ('Rate' in soll) {
|
||||
const rate = soll.Rate;
|
||||
if ('Current' in rate) {
|
||||
document.getElementById('lcd-rate-soll').textContent = rate.Current;
|
||||
}
|
||||
if ('Set' in rate && initialHeatrateSync) {
|
||||
document.getElementById('heatrate-soll').value = rate.Set;
|
||||
initialHeatrateSync = false;
|
||||
}
|
||||
}
|
||||
} else if (key === 'Enabled') {
|
||||
const enabled = msg.Enabled;
|
||||
document.getElementById('tc-enabled').checked = enabled;
|
||||
document.getElementById('temp-soll').disabled = !enabled;
|
||||
document.getElementById('heatrate-soll').disabled = !enabled;
|
||||
}
|
||||
if (key === 'Ist') {
|
||||
const ist = msg.Ist;
|
||||
if ('Temp' in ist) {
|
||||
plotTempIst = ist.Temp;
|
||||
document.getElementById('lcd-temp-ist').textContent = ist.Temp;
|
||||
updateStepPlates();
|
||||
}
|
||||
if ('Rate' in ist) {
|
||||
document.getElementById('lcd-rate-ist').textContent = ist.Rate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onSudChanged(msg) {
|
||||
for (const key of Object.keys(msg)) {
|
||||
if (key === 'Json') {
|
||||
updateSudForecast(msg.Json);
|
||||
} else if (key === 'State') {
|
||||
const newState = msg.State;
|
||||
const isRunningNow = RUNNING_STATES.has(newState);
|
||||
if (isRunningNow && !wasRunning) {
|
||||
elapsed = 0;
|
||||
energyByStep = {};
|
||||
energyCurrent = 0;
|
||||
}
|
||||
wasRunning = isRunningNow;
|
||||
sudState = newState;
|
||||
updateStepPlates();
|
||||
} else if (key === 'UserMessage') {
|
||||
sudUserMessage = msg.UserMessage;
|
||||
} else if (key === 'Step') {
|
||||
const step = msg.Step;
|
||||
const newIndex = step.Index;
|
||||
if (sudStepIndex !== null && newIndex !== null && newIndex > sudStepIndex) {
|
||||
for (let i = sudStepIndex; i < newIndex; i++) {
|
||||
stepActualFrozen[i] = plotTempIst;
|
||||
}
|
||||
}
|
||||
sudStepIndex = newIndex;
|
||||
sudStepDescr = step.Descr;
|
||||
sudStepType = step.Type;
|
||||
updateStatusLine();
|
||||
updateStepPlates();
|
||||
} else if (key === 'HoldRemaining') {
|
||||
sudHoldRemaining = msg.HoldRemaining;
|
||||
updateStatusLine();
|
||||
} else if (key === 'Elapsed') {
|
||||
elapsed = msg.Elapsed;
|
||||
updateStepPlates();
|
||||
updateStatusLine();
|
||||
} else if (key === 'Forecast') {
|
||||
const forecast = msg.Forecast;
|
||||
forecastStepStarts = Object.fromEntries(forecast.StepStarts);
|
||||
forecastFinished = forecast.Finished;
|
||||
forecastFinalT = forecast.T.length ? forecast.T[forecast.T.length - 1] : null;
|
||||
updateStepPlates();
|
||||
} else if (key === 'Energy') {
|
||||
energyByStep = Object.fromEntries(msg.Energy.StepEnergy);
|
||||
energyCurrent = msg.Energy.Current;
|
||||
updateStepPlates();
|
||||
updateStatusLine();
|
||||
} else if (key === 'Error') {
|
||||
if (msg.Error) {
|
||||
alert(msg.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onSystemChanged(msg) {
|
||||
if ('AmbientTemp' in msg) {
|
||||
ambientTemp = msg.AmbientTemp;
|
||||
if (document.activeElement !== document.getElementById('ambient-temp')) {
|
||||
document.getElementById('ambient-temp').value = msg.AmbientTemp;
|
||||
}
|
||||
updateEnvStatusLine();
|
||||
}
|
||||
if ('WarpFactor' in msg) {
|
||||
warpFactor = msg.WarpFactor;
|
||||
updateEnvStatusLine();
|
||||
}
|
||||
if ('PlantSim' in msg) {
|
||||
document.getElementById('pot-reset-row').classList.toggle('hidden', !msg.PlantSim);
|
||||
}
|
||||
}
|
||||
|
||||
const HANDLERS = {
|
||||
Pot: () => {},
|
||||
Sensor: () => {},
|
||||
Heater: onHeaterChanged,
|
||||
Stirrer: onStirrerChanged,
|
||||
TempCtrl: onTempCtrlChanged,
|
||||
Sud: onSudChanged,
|
||||
System: onSystemChanged,
|
||||
};
|
||||
|
||||
// --- Connection management. ---
|
||||
|
||||
function setConnected(isConnected) {
|
||||
connected = isConnected;
|
||||
document.getElementById('btn-connect').textContent = isConnected ? 'Disconnect' : 'Connect';
|
||||
const statusEl = document.getElementById('conn-status');
|
||||
statusEl.textContent = isConnected ? 'Connected' : 'Disconnected';
|
||||
statusEl.className = isConnected ? 'status-connected' : 'status-disconnected';
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const host = document.getElementById('host').value;
|
||||
ws = new WebSocket(host);
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
for (const channel of CHANNELS) {
|
||||
ws.send(JSON.stringify({'+': channel}));
|
||||
}
|
||||
// Fetches whatever schedule is already loaded server-side, exactly
|
||||
// like client/brewpi_gui.py's Window.connect() - the resulting
|
||||
// Json/Forecast pair is handled the same way a real Load is
|
||||
// (see updateSudForecast()'s isNewSchedule check).
|
||||
sendMsg('Sud', {Save: true});
|
||||
};
|
||||
ws.onclose = () => setConnected(false);
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
for (const channel of Object.keys(msg)) {
|
||||
const handler = HANDLERS[channel];
|
||||
if (handler) {
|
||||
handler(msg[channel]);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
// --- DOM wiring. ---
|
||||
|
||||
document.getElementById('host').value = `ws://${location.hostname}:8765`;
|
||||
|
||||
document.getElementById('btn-connect').addEventListener('click', () => {
|
||||
if (connected) {
|
||||
disconnect();
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('heater-power').addEventListener('input', (e) => {
|
||||
document.getElementById('heater-power-readout').textContent = e.target.value;
|
||||
sendMsg('Heater', {Power: Number(e.target.value)});
|
||||
});
|
||||
document.getElementById('heater-activate').addEventListener('change', (e) => {
|
||||
sendMsg('Heater', {Activate: e.target.checked ? 1 : 0});
|
||||
});
|
||||
document.getElementById('stirrer-speed').addEventListener('input', (e) => {
|
||||
document.getElementById('stirrer-speed-readout').textContent = e.target.value;
|
||||
sendMsg('Stirrer', {Speed: Number(e.target.value)});
|
||||
});
|
||||
document.getElementById('stirrer-activate').addEventListener('change', (e) => {
|
||||
sendMsg('Stirrer', {Activate: e.target.checked ? 1 : 0});
|
||||
});
|
||||
document.getElementById('temp-soll').addEventListener('input', (e) => {
|
||||
document.getElementById('temp-soll-readout').textContent = e.target.value;
|
||||
sendMsg('TempCtrl', {Soll: {Temp: Number(e.target.value)}});
|
||||
});
|
||||
document.getElementById('heatrate-soll').addEventListener('change', (e) => {
|
||||
sendMsg('TempCtrl', {Soll: {Rate: Number(e.target.value)}});
|
||||
});
|
||||
document.getElementById('tc-enabled').addEventListener('change', (e) => {
|
||||
sendMsg('TempCtrl', {Enable: e.target.checked});
|
||||
});
|
||||
document.getElementById('ambient-temp').addEventListener('change', (e) => {
|
||||
sendMsg('System', {AmbientTemp: Number(e.target.value)});
|
||||
});
|
||||
document.getElementById('btn-pot-reset').addEventListener('click', () => {
|
||||
sendMsg('Pot', {Reset: Number(document.getElementById('pot-temp').value)});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach((c) => c.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
blinkOn = !blinkOn;
|
||||
stepPlates.forEach(applyLedColor);
|
||||
}, 500);
|
||||
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>BrewPi</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header id="connection-bar">
|
||||
<label>Host <input id="host" type="text"></label>
|
||||
<button id="btn-connect">Connect</button>
|
||||
<span id="conn-status" class="status-disconnected">Disconnected</span>
|
||||
</header>
|
||||
|
||||
<div id="lcd-panel">
|
||||
<div class="lcd-row lcd-header">
|
||||
<div class="lcd-label"></div>
|
||||
<div>Ist</div>
|
||||
<div>Soll</div>
|
||||
</div>
|
||||
<div class="lcd-row">
|
||||
<div class="lcd-label">Temperature [°C]</div>
|
||||
<div class="lcd" id="lcd-temp-ist">--</div>
|
||||
<div class="lcd" id="lcd-temp-soll">--</div>
|
||||
</div>
|
||||
<div class="lcd-row">
|
||||
<div class="lcd-label">Heat rate [°C/min]</div>
|
||||
<div class="lcd" id="lcd-rate-ist">--</div>
|
||||
<div class="lcd" id="lcd-rate-soll">--</div>
|
||||
</div>
|
||||
<div class="lcd-row">
|
||||
<div class="lcd-label">Power [W]</div>
|
||||
<div class="lcd" id="lcd-power-ist">--</div>
|
||||
<div class="lcd" id="lcd-power-soll">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="tabs">
|
||||
<button class="tab-btn active" data-tab="manual">Manual</button>
|
||||
<button class="tab-btn" data-tab="progress">Progress</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section id="tab-manual" class="tab-content active">
|
||||
<div class="panel">
|
||||
<h3>Controller</h3>
|
||||
<label><input type="checkbox" id="tc-enabled"> Enabled</label>
|
||||
<label>Temperature [°C]
|
||||
<input type="range" id="temp-soll" min="0" max="100" step="1" disabled>
|
||||
<span id="temp-soll-readout">--</span>
|
||||
</label>
|
||||
<label>Heat rate [°C/min]
|
||||
<input type="number" id="heatrate-soll" min="0" max="3" step="0.1" disabled>
|
||||
</label>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Heater</h3>
|
||||
<label><input type="checkbox" id="heater-activate"> Activate</label>
|
||||
<label>Power [W]
|
||||
<input type="range" id="heater-power" min="0" max="100" step="1">
|
||||
<span id="heater-power-readout">--</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Stirrer</h3>
|
||||
<label><input type="checkbox" id="stirrer-activate"> Activate</label>
|
||||
<label>Speed [%]
|
||||
<input type="range" id="stirrer-speed" min="0" max="100" step="1">
|
||||
<span id="stirrer-speed-readout">--</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Environment</h3>
|
||||
<label>Ambient [°C]
|
||||
<input type="number" id="ambient-temp" step="0.1">
|
||||
</label>
|
||||
<div id="pot-reset-row" class="hidden">
|
||||
<label>Pot [°C]
|
||||
<input type="number" id="pot-temp" step="0.1">
|
||||
</label>
|
||||
<button id="btn-pot-reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-progress" class="tab-content">
|
||||
<div id="step-plates"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div id="sud-status-line"></div>
|
||||
<div id="env-status-line"></div>
|
||||
</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
:root {
|
||||
--led-off: #555555;
|
||||
--led-green: #2ecc71;
|
||||
--led-green-dim: #1b5e36;
|
||||
--led-orange: #e67e22;
|
||||
--led-orange-dim: #7a4111;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
padding: 0.5em 1em 4em 1em;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
header#connection-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
padding-bottom: 0.5em;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.status-connected { color: #2ecc71; font-weight: bold; }
|
||||
.status-disconnected { color: #999; }
|
||||
|
||||
#lcd-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 10em 6em 6em;
|
||||
gap: 0.2em 1em;
|
||||
margin: 1em 0;
|
||||
max-width: 30em;
|
||||
}
|
||||
|
||||
.lcd-row { display: contents; }
|
||||
.lcd-header { font-weight: bold; }
|
||||
.lcd-label { color: #555; }
|
||||
.lcd {
|
||||
font-family: monospace;
|
||||
font-size: 1.1em;
|
||||
background: #fff;
|
||||
border: 1px solid #2ecc71;
|
||||
padding: 0.1em 0.3em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
nav#tabs {
|
||||
margin-top: 1em;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
border-bottom: none;
|
||||
padding: 0.4em 1em;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.tab-btn.active {
|
||||
background: #fff;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #fff;
|
||||
}
|
||||
|
||||
.tab-content { display: none; padding: 1em 0; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
.panel {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 0.6em 1em;
|
||||
margin-bottom: 0.8em;
|
||||
max-width: 30em;
|
||||
}
|
||||
.panel h3 { margin: 0 0 0.4em 0; }
|
||||
.panel label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
.panel input[type="range"] { flex: 1; }
|
||||
|
||||
.hidden { display: none; }
|
||||
|
||||
#step-plates { display: flex; flex-direction: column; gap: 0.5em; max-width: 36em; }
|
||||
|
||||
.step-plate {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 0.5em 0.8em;
|
||||
display: grid;
|
||||
grid-template-columns: 1.4em 1fr auto;
|
||||
gap: 0.2em 0.6em;
|
||||
align-items: center;
|
||||
}
|
||||
.step-plate .led {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--led-off);
|
||||
border: 1px solid #222;
|
||||
}
|
||||
.step-plate .title {
|
||||
font-weight: bold;
|
||||
grid-column: 2;
|
||||
}
|
||||
.step-plate .remaining {
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
}
|
||||
.step-plate .field {
|
||||
color: #555;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.step-plate .span2 { grid-column: span 2; }
|
||||
|
||||
footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #f0f0f0;
|
||||
border-top: 1px solid #ccc;
|
||||
padding: 0.3em 1em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
Reference in New Issue
Block a user