> ## Documentation Index
> Fetch the complete documentation index at: https://felimet-hub.jmcores.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Temperature and humidity monitoring system

> Temperature and humidity monitor pulling Tapo H200/T315 sensor data into CrateDB with Python, visualised in Apache Superset and packaged with Docker Compose.

export const StaleValidator = ({lang = "zh"}) => {
  const t = lang === "en" ? {
    lead: "Toggle which signals fail, then see the verdict.",
    thr: "Stale threshold",
    thrUnit: "of 4 must fail",
    failed: "Failed",
    stale: "Judged stale",
    staleBody: "Temperature and humidity are written as NULL and the event is logged to stale.log.",
    valid: "Reading valid",
    validBody: "The reading is written to CrateDB."
  } : {
    lead: "撥動哪幾項訊號失敗，看判定結果。",
    thr: "判定門檻",
    thrUnit: "／ 4 項失敗才算",
    failed: "失敗",
    stale: "判定 stale",
    staleBody: "溫度與濕度寫入 NULL，事件記入 stale.log。",
    valid: "讀值有效",
    validBody: "讀值寫入 CrateDB。"
  };
  const SIGNALS = lang === "en" ? [{
    key: "rssi",
    label: "RSSI threshold",
    hint: "below -75 dBm"
  }, {
    key: "rssiFreeze",
    label: "RSSI freeze",
    hint: "no jitter at all"
  }, {
    key: "thFreeze",
    label: "Temp + humidity freeze",
    hint: "both static too long"
  }, {
    key: "timeFreeze",
    label: "device_time freeze",
    hint: "device clock not advancing"
  }] : [{
    key: "rssi",
    label: "RSSI 門檻",
    hint: "低於 -75 dBm"
  }, {
    key: "rssiFreeze",
    label: "RSSI 凍結",
    hint: "完全沒有 jitter"
  }, {
    key: "thFreeze",
    label: "溫濕度凍結",
    hint: "兩者同時長時間不變"
  }, {
    key: "timeFreeze",
    label: "device_time 凍結",
    hint: "裝置時間不遞增"
  }];
  const [fail, setFail] = useState({});
  const [thr, setThr] = useState(2);
  const failCount = SIGNALS.filter(s => fail[s.key]).length;
  const isStale = failCount >= thr;
  const css = `
  .sv-root{--sv-bg:#FAF8F3;--sv-surface:rgba(0,0,0,0.025);--sv-border:rgba(0,0,0,0.09);--sv-text:#2b2722;--sv-dim:#6f6a62;--sv-faint:#8a8378;--sv-accent:#bf7551;--sv-ok:#5f8a52;--sv-warn:#c0792f;border:1px solid var(--sv-border);border-radius:14px;background:var(--sv-bg);color:var(--sv-text);overflow:hidden;}
  .dark .sv-root{--sv-bg:#1b1a18;--sv-surface:rgba(255,255,255,0.03);--sv-border:rgba(255,255,255,0.08);--sv-text:#e7e3da;--sv-dim:#a8a299;--sv-faint:#8a8378;--sv-accent:#cf8a68;--sv-ok:#86b274;--sv-warn:#d7a043;}
  .sv-head{padding:13px 18px 11px;border-bottom:1px solid var(--sv-border);font-size:12.5px;color:var(--sv-dim);display:flex;align-items:center;gap:8px;}
  .sv-head-ic{color:var(--sv-accent);flex-shrink:0;}
  .sv-grid{display:flex;gap:0;align-items:stretch;flex-wrap:wrap;}
  .sv-left{flex:1 1 280px;min-width:0;padding:14px 16px;display:flex;flex-direction:column;gap:8px;}
  .sv-sig{display:flex;align-items:center;gap:11px;width:100%;text-align:left;background:transparent;border:1px solid var(--sv-border);border-radius:10px;padding:10px 12px;cursor:pointer;color:inherit;font:inherit;transition:border-color .15s,background .15s;}
  .sv-sig:hover{background:var(--sv-surface);}
  .sv-sig-on{border-color:rgba(192,121,47,.5);background:rgba(192,121,47,.07);}
  .sv-sw{width:34px;height:20px;border-radius:11px;background:var(--sv-border);position:relative;flex-shrink:0;transition:background .15s;}
  .sv-sig-on .sv-sw{background:var(--sv-warn);}
  .sv-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;transition:left .15s;}
  .sv-sig-on .sv-knob{left:16px;}
  .sv-sig-tx{flex:1 1 0;min-width:0;}
  .sv-sig-l{font-size:14px;font-weight:550;line-height:1.3;}
  .sv-sig-h{font-size:12px;color:var(--sv-faint);margin-top:1px;}
  .sv-right{width:286px;flex:1 1 240px;border-left:1px solid var(--sv-border);background:var(--sv-surface);padding:16px 18px;display:flex;flex-direction:column;gap:12px;}
  .sv-thr{font-size:12.5px;color:var(--sv-dim);}
  .sv-thr-row{display:flex;align-items:center;gap:8px;margin-top:6px;}
  .sv-step{width:26px;height:26px;border-radius:7px;border:1px solid var(--sv-border);background:transparent;color:var(--sv-dim);cursor:pointer;font-size:15px;line-height:1;display:flex;align-items:center;justify-content:center;}
  .sv-step:hover{background:var(--sv-bg);}
  .sv-thr-n{font-size:18px;font-weight:700;color:var(--sv-accent);min-width:14px;text-align:center;font-variant-numeric:tabular-nums;}
  .sv-count{font-size:13px;color:var(--sv-dim);}
  .sv-count b{color:var(--sv-warn);font-size:15px;}
  .sv-verdict{margin-top:auto;border-radius:10px;padding:13px 14px;border:1px solid;}
  .sv-v-stale{background:rgba(192,121,47,.08);border-color:rgba(192,121,47,.35);}
  .sv-v-ok{background:rgba(95,138,82,.08);border-color:rgba(95,138,82,.32);}
  .sv-v-t{font-size:13.5px;font-weight:700;margin-bottom:5px;}
  .sv-v-stale .sv-v-t{color:var(--sv-warn);}
  .sv-v-ok .sv-v-t{color:var(--sv-ok);}
  .sv-v-b{font-size:12.5px;line-height:1.55;color:var(--sv-dim);}
  @media (max-width:620px){.sv-right{width:auto;border-left:none;border-top:1px solid var(--sv-border);}}
  `;
  return <div className="sv-root">
      <style>{css}</style>
      <div className="sv-head"><svg className="sv-head-ic" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 20h.01" /><path d="M7 20v-4" /><path d="M12 20v-8" /><path d="M17 20V8" /><path d="M22 4v16" /></svg>{t.lead}</div>
      <div className="sv-grid">
        <div className="sv-left">
          {SIGNALS.map(s => <button key={s.key} type="button" className={"sv-sig" + (fail[s.key] ? " sv-sig-on" : "")} onClick={() => setFail(p => ({
    ...p,
    [s.key]: !p[s.key]
  }))}>
              <span className="sv-sw"><span className="sv-knob" /></span>
              <span className="sv-sig-tx">
                <span className="sv-sig-l">{s.label}</span>
                <span className="sv-sig-h">{s.hint}</span>
              </span>
            </button>)}
        </div>
        <div className="sv-right">
          <div>
            <div className="sv-thr">{t.thr}</div>
            <div className="sv-thr-row">
              <button type="button" className="sv-step" onClick={() => setThr(v => Math.max(1, v - 1))}>−</button>
              <span className="sv-thr-n">{thr}</span>
              <button type="button" className="sv-step" onClick={() => setThr(v => Math.min(4, v + 1))}>+</button>
              <span className="sv-thr" style={{
    marginLeft: "2px"
  }}>{t.thrUnit}</span>
            </div>
            <div className="sv-count" style={{
    marginTop: "10px"
  }}>{t.failed} <b>{failCount}</b> / {thr}</div>
          </div>
          <div className={"sv-verdict " + (isStale ? "sv-v-stale" : "sv-v-ok")}>
            <div className="sv-v-t">{isStale ? t.stale : t.valid}</div>
            <div className="sv-v-b">{isStale ? t.staleBody : t.validBody}</div>
          </div>
        </div>
      </div>
    </div>;
};

export const ArchFlow = ({nodes = [], lang = "zh"}) => {
  const t = lang === "en" ? {
    flow: "Data flow",
    hint: "Click any stage to see what it does",
    stage: "Stage"
  } : {
    flow: "資料流",
    hint: "點任一階段看它做什麼",
    stage: "階段"
  };
  const safe = Array.isArray(nodes) ? nodes : [];
  const [active, setActive] = useState(0);
  if (safe.length === 0) return null;
  const idx = Math.min(active, safe.length - 1);
  const current = safe[idx];
  const css = `
  .af-root{--af-bg:#FAF8F3;--af-surface:rgba(191,117,81,0.06);--af-surface2:rgba(0,0,0,0.025);--af-border:rgba(0,0,0,0.09);--af-text:#2b2722;--af-dim:#6f6a62;--af-faint:#8a8378;--af-rail:rgba(0,0,0,0.12);--af-accent:#bf7551;border:1px solid var(--af-border);border-radius:14px;background:var(--af-bg);color:var(--af-text);overflow:hidden;}
  .dark .af-root{--af-bg:#1b1a18;--af-surface:rgba(207,138,104,0.09);--af-surface2:rgba(255,255,255,0.03);--af-border:rgba(255,255,255,0.08);--af-text:#e7e3da;--af-dim:#a8a299;--af-faint:#8a8378;--af-rail:rgba(255,255,255,0.13);--af-accent:#cf8a68;}
  .af-head{padding:13px 18px 11px;display:flex;align-items:center;gap:9px;flex-wrap:wrap;border-bottom:1px solid var(--af-border);}
  .af-head-ic{color:var(--af-accent);flex-shrink:0;}
  .af-head-t{font-size:12px;font-weight:700;letter-spacing:.6px;color:var(--af-dim);text-transform:uppercase;}
  .af-head-h{font-size:12px;color:var(--af-faint);}
  .af-grid{display:flex;gap:0;align-items:stretch;}
  .af-list{flex:1 1 0;min-width:0;padding:12px 12px 16px;}
  .af-node{display:flex;align-items:stretch;gap:10px;width:100%;text-align:left;background:transparent;border:none;cursor:pointer;padding:0;color:inherit;font:inherit;-webkit-tap-highlight-color:transparent;}
  .af-railcol{width:22px;flex-shrink:0;display:flex;flex-direction:column;align-items:center;}
  .af-dot{width:9px;height:9px;border-radius:50%;margin-top:14px;background:var(--af-faint);opacity:.5;flex-shrink:0;transition:background .15s,box-shadow .15s,opacity .15s;}
  .af-line{width:2px;flex:1 1 0;background:var(--af-rail);min-height:10px;}
  .af-body{flex:1 1 0;min-width:0;padding:9px 12px;margin:2px 0;border-radius:9px;border:1px solid transparent;transition:background .15s,border-color .15s;}
  .af-node:hover .af-body{background:var(--af-surface2);}
  .af-node-on .af-body{background:var(--af-surface);border-color:rgba(191,117,81,.30);}
  .af-node-on .af-dot{background:var(--af-accent);opacity:1;box-shadow:0 0 0 4px rgba(191,117,81,.15);}
  .af-label{font-size:14.5px;font-weight:550;line-height:1.45;}
  .af-node-on .af-label{color:var(--af-accent);}
  .af-detail{width:300px;flex-shrink:0;border-left:1px solid var(--af-border);padding:16px 18px;background:var(--af-surface2);}
  .af-detail-k{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--af-accent);margin-bottom:7px;}
  .af-detail-l{font-size:16px;font-weight:600;margin-bottom:9px;line-height:1.35;}
  .af-detail-d{font-size:13.5px;line-height:1.65;color:var(--af-dim);}
  @media (max-width:640px){.af-grid{flex-direction:column;}.af-detail{width:auto;border-left:none;border-top:1px solid var(--af-border);}}
  `;
  return <div className="af-root">
      <style>{css}</style>
      <div className="af-head">
        <svg className="af-head-ic" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="8" height="8" x="3" y="3" rx="2" /><path d="M7 11v4a2 2 0 0 0 2 2h4" /><rect width="8" height="8" x="13" y="13" rx="2" /></svg>
        <span className="af-head-t">{t.flow}</span>
        <span className="af-head-h">{t.hint}</span>
      </div>
      <div className="af-grid">
        <div className="af-list">
          {safe.map((n, i) => <button key={i} type="button" className={"af-node" + (i === idx ? " af-node-on" : "")} onClick={() => setActive(i)} onMouseEnter={() => setActive(i)}>
              <div className="af-railcol">
                <div className="af-dot" />
                {i < safe.length - 1 && <div className="af-line" />}
              </div>
              <div className="af-body">
                <div className="af-label">{n.label}</div>
              </div>
            </button>)}
        </div>
        <div className="af-detail">
          <div className="af-detail-k">{t.stage} {idx + 1} / {safe.length}</div>
          <div className="af-detail-l">{current.label}</div>
          <div className="af-detail-d">{current.detail}</div>
        </div>
      </div>
    </div>;
};

`Python` · `IoT` · `CrateDB` · `Docker` · `Apache Superset`

## Overview

The data source is a TP-Link Tapo H200 hub with T315 temperature and humidity sensors. A Python collector starts up, auto-discovers all T315 sensors registered under the H200, and polls them roughly every 60 seconds, writing readings to a CrateDB time-series database. Apache Superset provides dashboards. The whole stack is packaged as Docker Compose and runs on a Synology NAS or any Docker host.

The H200 hub API gives no online/offline status for child sensors: after a sensor drops, the hub keeps returning the last cached reading. The system cross-checks four signals to detect stale data and writes NULL for temperature and humidity when at least N checks fail, logging the event to `stale.log`.

## Core features

* Auto-discovers all T315 sensors under the H200 on startup, no manual device list required
* Collects temperature, humidity, battery level, and RSSI every \~60 seconds
* Four-signal cross-validation (RSSI threshold, RSSI freeze, simultaneous temperature and humidity freeze, device\_time freeze): stale only when at least N checks fail (default N=2), avoiding single-signal false positives
* Stale readings write NULL to temperature and humidity columns, preserving `is_valid` and `stale_reasons`; events are also written to `stale.log`
* Apache Superset dashboards with pre-built per-minute, hourly, daily, weekly, and monthly aggregate views
* Optional Cloudflare Tunnel for remote access without opening inbound ports
* Automatic reconnect and retry on failure, `restart: unless-stopped`

## Architecture

<ArchFlow
  lang="en"
  nodes={[
{ label: "Tapo T315 sensors", detail: "Measure temperature, humidity, RSSI, and battery level, transmitting readings to the H200 hub over RF." },
{ label: "Tapo H200 gateway", detail: "Aggregates child sensor readings via HTTP API. After a sensor disconnects, it continues returning the last cached value with no online/offline status exposed." },
{ label: "Python Collector", detail: "Auto-discovers all T315 sensors under the hub on startup, then polls roughly every 60 seconds and passes readings to the validator." },
{ label: "validator.py", detail: "Four-signal cross-check (RSSI threshold, RSSI freeze, simultaneous temperature and humidity freeze, device_time freeze): stale only when at least N checks fail." },
{ label: "CrateDB", detail: "Stores valid readings (temperature, humidity, RSSI, battery) or stale readings with NULL temperature and humidity columns; events are also written to stale.log." },
{ label: "Apache Superset", detail: "Connects to CrateDB time-series data and provides per-minute, hourly, daily, weekly, and monthly aggregate dashboards." }
]}
/>

<Tree>
  <Tree.Folder name="climate-monitor" defaultOpen>
    <Tree.Folder name="src/climate_monitor" defaultOpen>
      <Tree.Folder name="core" defaultOpen>
        <Tree.File name="collector.py · main polling loop" />

        <Tree.File name="tapo_client.py · H200 communication" />

        <Tree.File name="validator.py · four-signal validator" />
      </Tree.Folder>

      <Tree.File name="infra/database.py · CrateDB client" />

      <Tree.File name="config.py · environment variable loading" />

      <Tree.File name="main.py" />
    </Tree.Folder>

    <Tree.Folder name="docker" defaultOpen>
      <Tree.File name="docker-compose.pc.yml · local development" />

      <Tree.File name="docker-compose.prod.yml · NAS / production" />
    </Tree.Folder>

    <Tree.File name="scripts/init_db.sql · CrateDB schema initializer" />
  </Tree.Folder>
</Tree>

## Quick start

<Steps>
  <Step title="Set environment variables">
    ```bash theme={null}
    cp .env.example .env
    ```

    Fill in the Tapo account credentials and the H200 LAN IP address.
  </Step>

  <Step title="Start all services (NAS / production)">
    ```bash theme={null}
    cd docker
    docker compose -f docker-compose.prod.yml up -d --build
    ```
  </Step>

  <Step title="Initialize Superset (first run only)">
    ```bash theme={null}
    docker compose -f docker-compose.prod.yml --profile init up superset-init
    ```
  </Step>

  <Step title="Initialize the CrateDB schema">
    Open `http://NAS-IP:4200` in a browser and run `scripts/init_db.sql` in the SQL console. The service does not create tables automatically.
  </Step>

  <Step title="Verify the collector is running">
    ```bash theme={null}
    docker compose -f docker-compose.prod.yml logs -f climate-monitor
    ```

    Sensor names and readings appearing in the log confirm the collector is active.
  </Step>
</Steps>

## Stale-data validation

The H200 API does not expose child device connection state, and the hub continues to return cached values after a sensor disconnects. The system cross-checks four signals:

<StaleValidator lang="en" />

A reading is marked stale only when at least `VALIDATOR_MIN_FAILED_CHECKS` (default 2) checks fail simultaneously. The freeze window is computed from `frozen_window / collection_interval`.

## Notes

<Warning>
  `.env` contains Tapo account credentials; keep it out of version control. Local development (`docker-compose.pc.yml`) and production (`docker-compose.prod.yml`) use different bind-mount paths. Run `scripts/init_db.sql` manually after the first deployment; the service does not auto-create tables.
</Warning>

## In practice

Suitable for any long-term, low-cost multi-point temperature and humidity monitoring: lab environment logging, server room baseline monitoring, home climate management. The stale validation gives NULL values a precise meaning (sensor offline) rather than silently persisting incorrect data, making it straightforward to exclude invalid intervals in downstream analysis.

## Links

* GitHub: [felimet/climate-monitor](https://github.com/felimet/climate-monitor)
* CrateDB docs: [cratedb.com/docs](https://cratedb.com/docs)
* Apache Superset docs: [superset.apache.org/docs](https://superset.apache.org/docs/intro)
