Parcourir la source

dev: temperatuuri aukude täitmine (taidetud) + graafikul tähistamine

- collector: tühja tunni korral kirjuta rida eelmise väärtusega (taidetud=true); viimane väärtus seemendatakse DB-st
- CH: uus67.temperatuur + veerg taidetud Bool DEFAULT false
- nutikodu-web: /api/temperatuur tagastab taidetud; graafikul tühjad ringid + tooltip filter
- LOEMIND + README uuendatud
ardo il y a 1 mois
Parent
commit
c629f69f7b

+ 5 - 1
dev/LOEMIND.md

@@ -69,6 +69,9 @@ Valvurile ei lähe andmebaasi kredentiaale.
 - **Temperatuur** (`MQTT_TOPIC`): mõõtmised kogutakse mälus täistunni kaupa ja
   kirjutatakse iga täistunni järel üks rida (`temperature`/`humidity` = tunni
   keskmine). Andur raporteerib u. iga 10 min → tunnis mitu mõõtmist.
+  **Kui tunnis mõõtmisi ei ole** (andur kättesaamatu), kirjutatakse ikkagi rida
+  eelmise teadaoleva väärtusega ja `taidetud = true` — nii ei teki aegreas auke.
+  Viimane väärtus seemendatakse käivitusel andmebaasist.
 - **Uksed** (`DOOR_TOPICS`): salvestatakse **ainult olekumuutused**. Perioodilised
   ~2 h südamelöögid (sama `contact`) jäetakse vahele. Käivitusel seemendatakse
   viimane teadaolev olek andmebaasist; esimene nähtud olek salvestatakse baasreana.
@@ -85,7 +88,8 @@ CREATE TABLE uus67.temperatuur (
     hour          DateTime('Europe/Tallinn'),
     device        LowCardinality(String),
     temperature   Nullable(Float32),   -- tunni keskmine
-    humidity      Nullable(Float32)    -- tunni keskmine
+    humidity      Nullable(Float32),   -- tunni keskmine
+    taidetud      Bool DEFAULT false   -- true = eelmise väärtusega täidetud tund
 ) ENGINE = MergeTree
 PARTITION BY toYYYYMM(hour)
 ORDER BY (device, hour);

+ 3 - 0
dev/nutikodu-web/README.md

@@ -16,6 +16,9 @@ tume/hele teema.
 - Temperatuuri ja niiskuse ajatelg (kahe teljega), interaktiivne zoom (pinch/pan).
 - Perioodivalik: 24 h / 7 p / 30 p / 90 p / 1 a / Kõik + kohandatud vahemik.
 - Agregeerimine: tund / päev.
+- **Täidetud tunnid** (kui andur ei olnud kättesaadav) on koguja poolt eelmise
+  väärtusega asendatud (`taidetud = true`) ja kuvatakse graafikul **tühjade
+  ringidena** — aegreas ei teki auke.
 - Viimaste väärtuste kaardid + lahtiste häirete ülevaade (60 s värskendus).
 - PWA-manifest (installitav telefonis), `noindex`.
 

+ 10 - 3
dev/nutikodu-web/app.py

@@ -145,11 +145,15 @@ async def temperatuur(
     if agg == "day":
         select = (
             "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, device, "
-            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity"
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "max(taidetud) AS taidetud"
         )
         group = "GROUP BY t, device"
     else:
-        select = "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, temperature, humidity"
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, device, "
+            "temperature, humidity, taidetud"
+        )
         group = ""
     rows = ch_query(
         f"SELECT {select} FROM {CH_DB}.temperatuur WHERE {where} {group} ORDER BY t",
@@ -158,10 +162,13 @@ async def temperatuur(
 
     series: dict[str, dict] = {}
     for row in rows:
-        s = series.setdefault(row["device"], {"t": [], "temperature": [], "humidity": []})
+        s = series.setdefault(
+            row["device"], {"t": [], "temperature": [], "humidity": [], "taidetud": []}
+        )
         s["t"].append(int(row["t"]))
         s["temperature"].append(row["temperature"])
         s["humidity"].append(row["humidity"])
+        s["taidetud"].append(bool(row.get("taidetud")))
     return {"from": from_s, "to": to_s, "agg": agg, "series": series}
 
 

+ 20 - 3
dev/nutikodu-web/static/app.js

@@ -77,6 +77,20 @@ function buildOption(data) {
         emphasis: { focus: "series" },
         data: s.t.map((t, i) => [t, s.temperature[i]]),
       });
+      if (Array.isArray(s.taidetud) && s.taidetud.some(Boolean)) {
+        series.push({
+          name: `${dev} · täidetud`,
+          type: "scatter",
+          yAxisIndex: 0,
+          symbol: "emptyCircle",
+          symbolSize: 9,
+          itemStyle: { color: tempColor, borderWidth: 2 },
+          z: 3,
+          data: s.t
+            .map((t, i) => (s.taidetud[i] ? [t, s.temperature[i]] : null))
+            .filter(Boolean),
+        });
+      }
     }
     if (state.showHum) {
       series.push({
@@ -112,10 +126,13 @@ function buildOption(data) {
       axisPointer: { type: "cross", label: { backgroundColor: muted } },
       valueFormatter: null,
       formatter: (params) => {
-        if (!params || !params.length) return "";
-        const d = new Date(params[0].value[0]);
+        const items = (params || []).filter(
+          (p) => !String(p.seriesName).endsWith("· täidetud")
+        );
+        if (!items.length) return "";
+        const d = new Date(items[0].value[0]);
         const head = `${d.toLocaleDateString("et-EE")} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
-        const lines = params.map((p) => {
+        const lines = items.map((p) => {
           const v = p.value[1];
           const unit = p.seriesName.endsWith("°C") ? " °C" : " %";
           return `<div style="display:flex;justify-content:space-between;gap:14px">

+ 60 - 4
dev/zigbee-collector/collector.py

@@ -5,7 +5,9 @@ Kolm voogu:
 
 1. Temperatuur/niiskus (``MQTT_TOPIC``): mõõtmised koondatakse mälus täistunni
    kaupa ja kirjutatakse iga täistunni järel üks agregeeritud rida
-   (``CH_TABLE``, vaikimisi ``uus67.temperatuur``).
+   (``CH_TABLE``, vaikimisi ``uus67.temperatuur``). Kui tunnis mõõtmisi ei ole
+   (andur kättesaamatu), kirjutatakse ikkagi rida eelmise teadaoleva väärtusega
+   ja märgitakse ``taidetud = true`` — nii ei teki aegreas auke.
 2. Kontaktandurid (``DOOR_TOPICS``): salvestatakse **ainult olekumuutused**
    (``CH_DOOR_TABLE``, vaikimisi ``uus67.ukseandur``).
 3. Häired (``CH_FAULT_TABLE``, vaikimisi ``uus67.vead``): **ainult muutused**
@@ -138,15 +140,29 @@ class HourBucket:
     def empty(self) -> bool:
         return not self.temps and not self.hums
 
-    def row(self) -> dict[str, Any]:
+    def row(self, fallback: dict[str, float | None] | None = None) -> dict[str, Any]:
+        """Ühe tunni rida.
+
+        Kui tunnis mõõtmisi ei ole ja ``fallback`` on antud, kasutatakse
+        eelmise teadaoleva väärtuse andmeid ja märgitakse ``taidetud = true``.
+        """
         def avg(values: list[float]) -> float | None:
             return round(sum(values) / len(values), 2) if values else None
 
+        if self.empty and fallback is not None:
+            return {
+                "hour": self.hour.strftime("%Y-%m-%d %H:%M:%S"),
+                "device": DEVICE,
+                "temperature": fallback.get("temperature"),
+                "humidity": fallback.get("humidity"),
+                "taidetud": True,
+            }
         return {
             "hour": self.hour.strftime("%Y-%m-%d %H:%M:%S"),
             "device": DEVICE,
             "temperature": avg(self.temps),
             "humidity": avg(self.hums),
+            "taidetud": False,
         }
 
 
@@ -380,6 +396,27 @@ def load_last_seen() -> dict[str, datetime]:
         return {}
 
 
+def load_last_temp() -> dict[str, float | None]:
+    """Loeb andmebaasist viimase teadaoleva temperatuuri/niiskuse (täitmise baas)."""
+    query = (
+        f"SELECT temperature, humidity FROM {CH_DB}.{CH_TABLE} "
+        "ORDER BY hour DESC LIMIT 1 FORMAT JSONEachRow"
+    )
+    try:
+        text = _ch_query(query).strip()
+        if text:
+            obj = json.loads(text.splitlines()[0])
+            values = {
+                "temperature": _as_float(obj.get("temperature")),
+                "humidity": _as_float(obj.get("humidity")),
+            }
+            LOG.info("Seemendatud viimane väärtus: %s", values)
+            return values
+    except Exception:
+        LOG.exception("Viimase väärtuse laadimine ebaõnnestus, alustan tühjalt")
+    return {"temperature": None, "humidity": None}
+
+
 def main() -> int:
     lock = threading.Lock()
     stop = threading.Event()
@@ -389,6 +426,7 @@ def main() -> int:
     fault_writer = ClickHouseWriter(CH_FAULT_TABLE)
     door_tracker = DoorTracker(load_last_states())
     fault_tracker = FaultTracker(load_active_faults())
+    last_values: dict[str, float | None] = load_last_temp()
 
     start = datetime.now(TZ)
     last_seen: dict[str, datetime] = {}
@@ -449,6 +487,12 @@ def main() -> int:
             if msg.topic == MQTT_TOPIC:
                 bucket.add(payload)
                 last_seen[DEVICE] = now
+                temp = _as_float(payload.get("temperature"))
+                hum = _as_float(payload.get("humidity"))
+                if temp is not None:
+                    last_values["temperature"] = temp
+                if hum is not None:
+                    last_values["humidity"] = hum
                 fault_writer.enqueue_many(fault_tracker.on_device_message(DEVICE, payload, now))
             elif msg.topic in DOOR_TOPICS:
                 device = msg.topic.rsplit("/", 1)[-1]
@@ -510,8 +554,20 @@ def main() -> int:
                 bucket = HourBucket(current_hour(now))
             else:
                 finished = None
-            if finished is not None and not finished.empty:
-                temp_writer.enqueue(finished.row())
+            if finished is not None:
+                if not finished.empty:
+                    temp_writer.enqueue(finished.row())
+                elif (
+                    last_values.get("temperature") is not None
+                    or last_values.get("humidity") is not None
+                ):
+                    LOG.info(
+                        "Tund %s ilma mõõtmisteta — täidan eelmise väärtusega (%s / %s)",
+                        finished.hour.strftime("%Y-%m-%d %H:%M"),
+                        last_values.get("temperature"),
+                        last_values.get("humidity"),
+                    )
+                    temp_writer.enqueue(finished.row(fallback=last_values))
             for device in sorted(MONITORED_DEVICES):
                 seen = last_seen.get(device, start)
                 timeout = timedelta(minutes=offline_timeout(device))