<?php
/*
  227 Maple Boiler Dashboard
  Put this file in the web directory on 227.lupton.us.

  CONFIGURE THESE TWO PATHS if your log filenames differ.
*/
$BOILER_LOG = __DIR__ . '/boiler.txt';   // lines containing c0=... (boiler-top DS18B20)
$BME_LOG    = __DIR__ . '/bme280.txt';   // lines containing src=bme280&tC=...&rh=...&p_hpa=...

$REFRESH_SECONDS = 20;
$BOILER_CHANNEL  = 'c0';                 // change to c1/c2 if the boiler-top probe is different

function tail_latest_matching($file, $needle, $maxBytes = 131072) {
    if (!is_readable($file)) return null;
    $size = filesize($file);
    if ($size === false || $size == 0) return null;

    $fh = fopen($file, 'rb');
    if (!$fh) return null;

    $read = min($size, $maxBytes);
    fseek($fh, -$read, SEEK_END);
    $data = fread($fh, $read);
    fclose($fh);

    if ($data === false) return null;
    $lines = preg_split('/\r?\n/', trim($data));

    for ($i = count($lines)-1; $i >= 0; $i--) {
        if (strpos($lines[$i], $needle) !== false) return $lines[$i];
    }
    return null;
}

function parse_query_part($line) {
    // Expected form:
    // epoch,ISO8601,key=value&key=value... IP
    $parts = explode(',', $line, 3);
    if (count($parts) < 3) return null;

    $outerEpoch = intval(trim($parts[0]));
    $iso = trim($parts[1]);
    $rest = trim($parts[2]);

    // Remove trailing source IP after a space.
    $q = preg_split('/\s+/', $rest, 2)[0];
    parse_str($q, $kv);

    return [
        'outer_epoch' => $outerEpoch,
        'iso' => $iso,
        'kv' => $kv
    ];
}

/*
 IAPWS saturation pressure formulation:
 Wagner/Pruss form for water saturation pressure.
 Returns hPa absolute from temperature C.
*/
function water_psat_hpa($tC) {
    $T  = $tC + 273.15;
    $Tc = 647.096;
    $Pc = 22.064e6; // Pa

    if ($T <= 273.16 || $T >= $Tc) return null;

    $tau = 1.0 - ($T / $Tc);
    $a1 = -7.85951783;
    $a2 =  1.84408259;
    $a3 = -11.7866497;
    $a4 =  22.6807411;
    $a5 = -15.9618719;
    $a6 =  1.80122502;

    $lnPr = ($Tc / $T) * (
        $a1 * $tau +
        $a2 * pow($tau, 1.5) +
        $a3 * pow($tau, 3.0) +
        $a4 * pow($tau, 3.5) +
        $a5 * pow($tau, 4.0) +
        $a6 * pow($tau, 7.5)
    );

    $pPa = $Pc * exp($lnPr);
    return $pPa / 100.0;
}

function latest_values($boilerFile, $bmeFile, $boilerChannel) {
    $boilerLine = tail_latest_matching($boilerFile, $boilerChannel . '=');
    $bmeLine    = tail_latest_matching($bmeFile, 'src=bme280');

    $out = [
        'ok' => false,
        'boiler_file' => $boilerFile,
        'bme_file' => $bmeFile,
        'boiler' => null,
        'bme' => null,
        'error' => []
    ];

    if (!$boilerLine) $out['error'][] = "No readable boiler line found in $boilerFile";
    if (!$bmeLine)    $out['error'][] = "No readable BME280 line found in $bmeFile";

    if ($boilerLine) {
        $p = parse_query_part($boilerLine);
        if ($p && isset($p['kv'][$boilerChannel])) {
            $tC = floatval($p['kv'][$boilerChannel]);
            $out['boiler'] = [
                'tC' => $tC,
                'tF' => $tC * 9.0/5.0 + 32.0,
                'epoch' => $p['outer_epoch'],
                'iso' => $p['iso'],
                'raw' => $boilerLine
            ];
        } else {
            $out['error'][] = "Could not parse boiler temperature channel $boilerChannel";
        }
    }

    if ($bmeLine) {
        $p = parse_query_part($bmeLine);
        if ($p && isset($p['kv']['tC'], $p['kv']['rh'], $p['kv']['p_hpa'])) {
            $tC = floatval($p['kv']['tC']);
            $out['bme'] = [
                'tC' => $tC,
                'tF' => $tC * 9.0/5.0 + 32.0,
                'rh' => floatval($p['kv']['rh']),
                'p_hpa' => floatval($p['kv']['p_hpa']),
                'p_inhg' => floatval($p['kv']['p_hpa']) * 0.0295299830714,
                'epoch' => $p['outer_epoch'],
                'iso' => $p['iso'],
                'raw' => $bmeLine
            ];
        } else {
            $out['error'][] = "Could not parse BME280 tC/rh/p_hpa";
        }
    }

    if ($out['boiler'] && $out['bme']) {
        $psat_hpa = water_psat_hpa($out['boiler']['tC']);
        if ($psat_hpa !== null) {
            $gauge_hpa = $psat_hpa - $out['bme']['p_hpa'];
            $gauge_psi = $gauge_hpa * 0.0145037738;

            $out['boiler']['psat_hpa_abs'] = $psat_hpa;
            $out['boiler']['gauge_psi_raw'] = $gauge_psi;
            $out['boiler']['gauge_psi'] = max(0.0, $gauge_psi);
            $out['boiler']['steaming'] = ($gauge_psi > 0.0);
        }
        $out['ok'] = true;
    }

    return $out;
}

if (isset($_GET['json'])) {
    header('Content-Type: application/json; charset=utf-8');
    header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
    echo json_encode(latest_values($BOILER_LOG, $BME_LOG, $BOILER_CHANNEL), JSON_PRETTY_PRINT);
    exit;
}

$initial = latest_values($BOILER_LOG, $BME_LOG, $BOILER_CHANNEL);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta http-equiv="Cache-Control" content="no-store">
<title>227 Maple — Boiler</title>
<style>
:root {
  color-scheme: dark;
  --bg:#0b0d10; --panel:#15191f; --line:#2b313a;
  --text:#f4f7fb; --muted:#aab4c0; --good:#8ee59b;
  --warn:#ffd166; --hot:#ff8f70;
}
* { box-sizing:border-box; }
body {
  margin:0; background:var(--bg); color:var(--text);
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
.wrap { max-width:1100px; margin:0 auto; padding:18px; }
header { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; margin-bottom:16px; }
h1 { margin:0; font-size:clamp(24px,5vw,46px); }
.sub { color:var(--muted); font-size:clamp(13px,2vw,18px); }
.grid2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
.grid3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; margin-top:14px; }
.card {
  background:var(--panel); border:1px solid var(--line); border-radius:18px;
  padding:18px; min-width:0;
}
.label { color:var(--muted); font-size:clamp(14px,2.2vw,20px); letter-spacing:.04em; text-transform:uppercase; }
.big { font-size:clamp(48px,10vw,92px); font-weight:700; line-height:1.0; margin-top:8px; }
.medium { font-size:clamp(32px,6vw,58px); font-weight:700; line-height:1.05; margin-top:8px; }
.unit { font-size:.38em; color:var(--muted); font-weight:600; margin-left:.12em; }
.status {
  margin-top:14px; padding:12px 14px; border-radius:12px; background:#10141a;
  border:1px solid var(--line); color:var(--muted); font-size:15px;
}
.status strong { color:var(--text); }
.good { color:var(--good); }
.warn { color:var(--warn); }
.hot  { color:var(--hot); }
.footer { color:var(--muted); margin-top:14px; font-size:13px; line-height:1.45; }
@media (max-width:700px) {
  .grid2,.grid3 { grid-template-columns:1fr; }
  header { align-items:flex-start; flex-direction:column; }
}
</style>
</head>
<body>
<div class="wrap">
  <header>
    <div>
      <h1>227 Maple — Boiler</h1>
      <div class="sub">20-second live instrument panel</div>
    </div>
    <div class="sub" id="clock"></div>
  </header>

  <section class="grid2">
    <div class="card">
      <div class="label">Boiler top temperature</div>
      <div class="big" id="boilerTemp">--</div>
      <div class="status" id="boilerAge">Waiting for data…</div>
    </div>

    <div class="card">
      <div class="label">Calculated boiler pressure</div>
      <div class="big" id="boilerPsi">--</div>
      <div class="status" id="pressureNote">
        Saturation pressure from boiler-top temperature, corrected by basement BME280 barometric pressure.
      </div>
    </div>
  </section>

  <section class="grid3">
    <div class="card">
      <div class="label">Basement temperature</div>
      <div class="medium" id="roomTemp">--</div>
    </div>
    <div class="card">
      <div class="label">Basement pressure</div>
      <div class="medium" id="roomPressure">--</div>
    </div>
    <div class="card">
      <div class="label">Basement humidity</div>
      <div class="medium" id="roomRh">--</div>
    </div>
  </section>

  <div class="status" id="systemStatus">
    Loading…
  </div>

  <div class="footer">
    Experimental instrumentation only — not a safety control and not a replacement for the boiler's
    pressure gauge, pressuretrol, low-water cutoff, relief valve, or other certified controls.
    During rapid heating/cooling, the boiler-top surface sensor will lag actual steam temperature;
    that latency can be measured and characterized.
  </div>
</div>

<script>
const REFRESH_MS = <?= intval($REFRESH_SECONDS) * 1000 ?>;

function n(v, digits=1) {
  return Number.isFinite(Number(v)) ? Number(v).toFixed(digits) : '--';
}
function ageText(epoch) {
  if (!epoch) return 'unknown age';
  const s = Math.max(0, Math.round(Date.now()/1000 - epoch));
  if (s < 60) return `${s} s old`;
  return `${Math.floor(s/60)} min ${s%60} s old`;
}
function setHTML(id, html) {
  document.getElementById(id).innerHTML = html;
}
function pressureClass(psi) {
  if (!Number.isFinite(psi)) return '';
  if (psi < 0.5) return 'good';
  if (psi <= 2.0) return 'warn';
  return 'hot';
}

async function refresh() {
  try {
    const r = await fetch(location.pathname + '?json=1&_=' + Date.now(), {cache:'no-store'});
    const d = await r.json();

    if (!d.ok || !d.boiler || !d.bme) {
      setHTML('systemStatus', '<strong>DATA ERROR:</strong> ' + (d.error || []).join(' | '));
      return;
    }

    setHTML('boilerTemp',
      `${n(d.boiler.tC,1)}<span class="unit">°C</span><br>` +
      `<span style="font-size:.45em;color:var(--muted)">${n(d.boiler.tF,1)} °F</span>`
    );

    const psi = Number(d.boiler.gauge_psi);
    const pClass = pressureClass(psi);
    setHTML('boilerPsi',
      `<span class="${pClass}">${n(psi,2)}</span><span class="unit">psi g</span>`
    );

    setHTML('boilerAge',
      `<strong>Boiler sample:</strong> ${ageText(d.boiler.epoch)} &nbsp; ` +
      `<strong>BME:</strong> ${ageText(d.bme.epoch)}`
    );

    setHTML('roomTemp',
      `${n(d.bme.tC,1)}<span class="unit">°C</span><br>` +
      `<span style="font-size:.55em;color:var(--muted)">${n(d.bme.tF,1)} °F</span>`
    );
    setHTML('roomPressure',
      `${n(d.bme.p_hpa,1)}<span class="unit">hPa</span><br>` +
      `<span style="font-size:.55em;color:var(--muted)">${n(d.bme.p_inhg,2)} inHg</span>`
    );
    setHTML('roomRh',
      `${n(d.bme.rh,1)}<span class="unit">% RH</span>`
    );

    const rawPsi = Number(d.boiler.gauge_psi_raw);
    let note;
    if (rawPsi <= 0) {
      note = 'Boiler-top temperature is below the local boiling point: calculated steam gauge pressure = 0.';
    } else {
      note = `Temperature-derived saturation pressure minus ${n(d.bme.p_hpa,1)} hPa room pressure.`;
    }
    setHTML('pressureNote', note);

    setHTML('systemStatus',
      `<strong class="good">LIVE</strong> &nbsp; updated ${new Date().toLocaleTimeString()} &nbsp; ` +
      `boiler channel <?= htmlspecialchars($BOILER_CHANNEL, ENT_QUOTES) ?>`
    );

  } catch (e) {
    setHTML('systemStatus', '<strong>UPDATE FAILED:</strong> ' + e.message);
  }
}

function tickClock() {
  document.getElementById('clock').textContent = new Date().toLocaleString();
}
tickClock();
setInterval(tickClock, 1000);
refresh();
setInterval(refresh, REFRESH_MS);
</script>
</body>
</html>
