Erster Teil der Ablosung von restricted/gartenbewaesserung/auto_watering.py.
Das Skript holt heute stuendlich den Regen, vergleicht ihn mit Schwellen
aus einer ZONES-Tabelle im Quelltext und schaltet selbst. Kuenftig
entscheidet der AutoAction-Runner, und die Schwellen stehen im Editor.
Dafuer legt ein neues Discovery-Modul drei Geraete an. Es sucht nichts,
sondern schreibt sie fest hin - wie das Logic-Modul auch:
* Die beiden ESP32-Ventilsteuerungen sprechen zwar MQTT, aber ohne
Home-Assistant-Discovery; das MQTT-Modul findet sie deshalb nicht.
* "Regen" ist gar kein Geraet, sondern das, was gatherRainData.py im
SolarManager von Open-Meteo holt und nach Wetter/Regen legt.
Je Steuerung entsteht ein Kommando "Automatik" mit dem Modus als
Parameter - ein Kommando ohne Parameter schickt im Runner eine leere
Nutzlast, der Klartext ("Hoch", "Trog", "Vorn") muss also der Parameter
sein. Dazu je Zone zwei Messwerte:
Bewaessert heute ersetzt die Zwoelf-Stunden-Sperre des Skripts;
"heute noch nicht" heisst hier "= 0". Die
Steuerung setzt den Wert um Mitternacht zurueck,
der Runner kann den mitgelieferten "wateringDay"
naemlich nicht pruefen.
Tage seit Bewaesserung ersetzt max_dry_days. Ein Zeitstempel nuetzte
nichts: der Runner vergleicht Datumsangaben nur
gegen einen festen Wert, "laenger als fuenf Tage
her" ist damit nicht formulierbar. Die Firmware
liefert deshalb gleich die Anzahl Tage.
Die Regensummen kommen als tage1 bis tage7 herein; welche davon als
Messwert auftauchen, entscheidet allein dieses Modul. Der Sammler
veroeffentlicht immer alle sieben, damit sich die beiden Repositorien
nicht ueber eine Fensterliste einigen muessen.
Die Logseite kennt rainOutput.log jetzt ebenfalls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
10 KiB
PHP
282 lines
10 KiB
PHP
<?php
|
|
/**
|
|
* Welche Logdateien es gibt und wie man hineinschaut.
|
|
*
|
|
* Die Hintergrundprozesse liegen seit dem Umzug im SolarManager-Verzeichnis
|
|
* und schreiben ihre Ausgabe jeweils in eine eigene Datei. Diese Seite liest
|
|
* sie nur - geschrieben wird hier nichts.
|
|
*
|
|
* Warum Dateien und nicht die Datenbank: ein Log muss genau dann noch
|
|
* funktionieren, wenn die Datenbank es nicht tut. Ein Handler, der seine
|
|
* Zeilen nach MySQL schreibt, verschluckt ausgerechnet die Meldung
|
|
* "Datenbank nicht erreichbar" - also die, wegen der man nachsieht. Was
|
|
* strukturiert ausgewertet werden soll, steht ohnehin schon in einer Tabelle:
|
|
* automation_log fuer die Automatiken.
|
|
*
|
|
* Gelesen wird immer vom Dateiende her, blockweise. wattpilotshell.log ist
|
|
* dreistellig megabytegross; ein file() darauf wuerde den PHP-Prozess
|
|
* umbringen.
|
|
*/
|
|
|
|
// Wo die Hintergrundprozesse liegen. Steht hier als Konstante und nicht in
|
|
// einer Konfigurationsdatei, weil es genau einen solchen Rechner gibt - und
|
|
// weil ein falscher Pfad hier sofort auffaellt statt still ins Leere zu
|
|
// laufen.
|
|
const LOG_BASIS = "/volume1/homes/wagner/SolarManager";
|
|
|
|
// Wie weit hoechstens zurueckgelesen wird, wenn gefiltert ist. Ohne Grenze
|
|
// wuerde die Suche nach einem Wort, das nirgends vorkommt, die komplette
|
|
// Datei durch den Speicher schieben.
|
|
const LOG_MAX_BYTES = 4 * 1024 * 1024;
|
|
|
|
/**
|
|
* Die bekannten Logdateien. Der Schluessel steht in der URL - der Pfad kommt
|
|
* ausschliesslich von hier und nie aus der Anfrage.
|
|
*/
|
|
function logDateien()
|
|
{
|
|
return [
|
|
"solar" => [
|
|
"titel" => "SolarManager",
|
|
"datei" => "solarOutput.log",
|
|
"symbol" => "bi-sun",
|
|
"hinweis" => "Messwerte einsammeln, Wallbox und Heizung regeln.",
|
|
],
|
|
"runner" => [
|
|
"titel" => "AutoAction-Runner",
|
|
"datei" => "autoActions.log",
|
|
"symbol" => "bi-diagram-3",
|
|
"hinweis" => "Wertet die Automatiken aus und schickt die Kommandos.",
|
|
],
|
|
"regen" => [
|
|
"titel" => "Regen",
|
|
"datei" => "rainOutput.log",
|
|
"symbol" => "bi-cloud-rain",
|
|
"hinweis" => "Holt die Niederschlagsdaten von Open-Meteo und legt "
|
|
. "sie nach Wetter/Regen, damit die Bewässerungs-"
|
|
. "Automatiken sie als Messwert bekommen.",
|
|
],
|
|
"wattpilot" => [
|
|
"titel" => "Wattpilot",
|
|
"datei" => "wattpilotshell.log",
|
|
"symbol" => "bi-ev-station",
|
|
"hinweis" => "Bruecke zur Wallbox.",
|
|
],
|
|
"mqtt" => [
|
|
"titel" => "MQTT-Brücke",
|
|
"datei" => "wsMQTTbridge.log",
|
|
"symbol" => "bi-broadcast",
|
|
"hinweis" => "Hört den Websocket der Wetterstation ab und reicht "
|
|
. "die Messwerte an den MQTT-Broker weiter "
|
|
. "(Topic weatherStation).",
|
|
],
|
|
"wecker" => [
|
|
"titel" => "Wecker",
|
|
"datei" => "wecker.log",
|
|
"symbol" => "bi-alarm",
|
|
"hinweis" => "Stellt morgens die Rollläden und das Licht.",
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Beschreibung einer Datei samt Zustand: Groesse, Alter, ob lesbar.
|
|
*
|
|
* Die Seite zeigt das an, damit man eine stehengebliebene Datei erkennt,
|
|
* ohne sie erst zu oeffnen - ein Prozess, der seit Tagen nichts geschrieben
|
|
* hat, ist ein Befund fuer sich.
|
|
*/
|
|
function logZustand($key)
|
|
{
|
|
$dateien = logDateien();
|
|
if (!isset($dateien[$key])) {
|
|
throw new InvalidArgumentException("Unbekannte Logdatei: " . $key);
|
|
}
|
|
$eintrag = $dateien[$key];
|
|
$pfad = LOG_BASIS . "/" . $eintrag["datei"];
|
|
|
|
$zustand = [
|
|
"key" => $key,
|
|
"titel" => $eintrag["titel"],
|
|
"datei" => $eintrag["datei"],
|
|
"symbol" => $eintrag["symbol"],
|
|
"hinweis" => $eintrag["hinweis"],
|
|
"da" => false,
|
|
"lesbar" => false,
|
|
"groesse" => 0,
|
|
"stand" => null,
|
|
];
|
|
if (!is_file($pfad)) {
|
|
return $zustand;
|
|
}
|
|
$zustand["da"] = true;
|
|
$zustand["lesbar"] = is_readable($pfad);
|
|
$zustand["groesse"] = (int) filesize($pfad);
|
|
$zustand["stand"] = date("Y-m-d H:i:s", filemtime($pfad));
|
|
return $zustand;
|
|
}
|
|
|
|
/**
|
|
* Level einer Zeile, oder "" wenn keines drinsteht.
|
|
*
|
|
* Die beiden Prozesse formatieren unterschiedlich - der Runner schreibt
|
|
* "2026-09-03 18:34:25,108 - INFO - Text", der SolarManager stellt das Level
|
|
* nach vorn. Deshalb wird es gesucht und nicht an einer festen Stelle
|
|
* erwartet. Zeilen ohne Level sind meist Fortsetzungen: die zweite bis
|
|
* letzte Zeile eines Tracebacks.
|
|
*/
|
|
function logLevel($zeile)
|
|
{
|
|
if (preg_match('/\b(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL|FATAL)\b/', $zeile, $t)) {
|
|
return $t[1] === "WARN" ? "WARNING" : ($t[1] === "FATAL" ? "CRITICAL" : $t[1]);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Eine Rohzeile in Zeitstempel, Level und Text zerlegen.
|
|
*
|
|
* Was nicht erkannt wird, bleibt im Text stehen - lieber eine Zeile, die
|
|
* roh aussieht, als eine, von der die Haelfte fehlt, weil ein Muster nicht
|
|
* gepasst hat.
|
|
*/
|
|
function logZerlegen($zeile)
|
|
{
|
|
$zeit = null;
|
|
$text = $zeile;
|
|
|
|
// Der SolarManager schreibt "INFO 2026-09-03 18:29:01,123 /langer/pfad/x.py x.py - Text".
|
|
// Der Pfad davor steht zweimal da und ist im Browser nur Ballast; der
|
|
// blosse Dateiname sagt dasselbe.
|
|
$text = preg_replace('#(\s)/\S+/(\S+\.py)(\s+\2\s+-\s)#', '$1$2 - ', $text, 1);
|
|
|
|
if (preg_match('/(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?)/', $text, $t)) {
|
|
$zeit = str_replace(",", ".", $t[1]);
|
|
// Nur den Zeitstempel entfernen, nicht alles davor: beim
|
|
// SolarManager steht links davon das Level.
|
|
$text = trim(str_replace($t[1], "", $text));
|
|
}
|
|
$level = logLevel($text);
|
|
if ($level !== "") {
|
|
// Das Level und die Trennzeichen drumherum wegnehmen, aber nur am
|
|
// Anfang - ein "ERROR" mitten im Satz bleibt stehen.
|
|
$text = preg_replace('/^[\s\-:]*' . preg_quote($level, '/') . '[\s\-:]*/', '', $text, 1);
|
|
}
|
|
return ["t" => $zeit, "l" => $level, "m" => $text];
|
|
}
|
|
|
|
/**
|
|
* Text so zurechtruecken, dass json_encode ihn nicht verweigert.
|
|
*
|
|
* In einem Log steht irgendwann eine Zeile, die kein gueltiges UTF-8 ist -
|
|
* ein abgeschnittener Umlaut, eine Fremdbibliothek in Latin-1. json_encode
|
|
* gibt dann fuer die ganze Antwort false zurueck, und die Seite bliebe leer.
|
|
*/
|
|
function logText($s)
|
|
{
|
|
return mb_check_encoding($s, "UTF-8") ? $s : mb_convert_encoding($s, "UTF-8", "ISO-8859-1");
|
|
}
|
|
|
|
/**
|
|
* Die letzten Zeilen einer Logdatei, optional gefiltert.
|
|
*
|
|
* Gelesen wird rueckwaerts in Bloecken: erst der letzte, dann bei Bedarf der
|
|
* davor. So kostet ein Blick in eine 300-MB-Datei so viel wie einer in eine
|
|
* kleine, solange genug Treffer im letzten Stueck stehen.
|
|
*
|
|
* Innerhalb eines Blocks wird vorwaerts gearbeitet, damit Zeilen ohne
|
|
* eigenes Level - Tracebacks - das Level der Zeile darueber erben. Sonst
|
|
* verschwaende der Filter "nur Fehler" genau die Zeilen, die den Fehler
|
|
* erklaeren.
|
|
*
|
|
* @param string $key Schluessel aus logDateien()
|
|
* @param int $anzahl gewuenschte Zeilen
|
|
* @param string $suche Text, der vorkommen muss (leer = alles)
|
|
* @param string $level Mindestlevel: "", "INFO", "WARNING", "ERROR"
|
|
*/
|
|
function logLesen($key, $anzahl = 300, $suche = "", $level = "")
|
|
{
|
|
$zustand = logZustand($key);
|
|
$pfad = LOG_BASIS . "/" . $zustand["datei"];
|
|
|
|
if (!$zustand["da"]) {
|
|
throw new RuntimeException("Die Datei " . $zustand["datei"] . " gibt es (noch) nicht.");
|
|
}
|
|
if (!$zustand["lesbar"]) {
|
|
throw new RuntimeException("Die Datei " . $zustand["datei"] . " ist für den Webserver nicht lesbar.");
|
|
}
|
|
|
|
$anzahl = max(1, min(5000, (int) $anzahl));
|
|
$rang = ["DEBUG" => 0, "" => 0, "INFO" => 1, "WARNING" => 2, "ERROR" => 3, "CRITICAL" => 4];
|
|
$mindest = $rang[$level] ?? 0;
|
|
|
|
$fh = fopen($pfad, "rb");
|
|
if (!$fh) {
|
|
throw new RuntimeException("Die Datei " . $zustand["datei"] . " ließ sich nicht öffnen.");
|
|
}
|
|
|
|
$groesse = $zustand["groesse"];
|
|
$block = 128 * 1024;
|
|
$gelesen = 0;
|
|
$rest = ""; // angefangene Zeile am Blockanfang
|
|
$treffer = []; // gesammelt, neueste zuerst
|
|
$abgeschnitten = false;
|
|
|
|
while ($gelesen < $groesse && count($treffer) < $anzahl) {
|
|
$laenge = (int) min($block, $groesse - $gelesen);
|
|
fseek($fh, $groesse - $gelesen - $laenge, SEEK_SET);
|
|
$roh = fread($fh, $laenge) . $rest;
|
|
$gelesen += $laenge;
|
|
|
|
$zeilen = explode("\n", $roh);
|
|
// Die erste Zeile des Blocks ist angeschnitten, solange noch etwas
|
|
// davor liegt - sie wandert in den naechsten Durchgang.
|
|
$rest = ($gelesen < $groesse) ? array_shift($zeilen) : "";
|
|
|
|
// Vorwaerts: Level fortschreiben, damit Fortsetzungszeilen eins erben.
|
|
$vorbereitet = [];
|
|
$letztesLevel = "";
|
|
foreach ($zeilen as $zeile) {
|
|
$zeile = rtrim($zeile, "\r");
|
|
if ($zeile === "") {
|
|
continue;
|
|
}
|
|
$teil = logZerlegen(logText($zeile));
|
|
if ($teil["l"] === "") {
|
|
$teil["l"] = $letztesLevel;
|
|
$teil["fortsetzung"] = true;
|
|
} else {
|
|
$letztesLevel = $teil["l"];
|
|
$teil["fortsetzung"] = false;
|
|
}
|
|
$vorbereitet[] = $teil;
|
|
}
|
|
|
|
// Rueckwaerts einsammeln: die juengsten Zeilen zuerst.
|
|
for ($i = count($vorbereitet) - 1; $i >= 0 && count($treffer) < $anzahl; $i--) {
|
|
$teil = $vorbereitet[$i];
|
|
if (($rang[$teil["l"]] ?? 0) < $mindest) {
|
|
continue;
|
|
}
|
|
if ($suche !== "" && mb_stripos($teil["m"], $suche) === false
|
|
&& mb_stripos((string) $teil["t"], $suche) === false) {
|
|
continue;
|
|
}
|
|
$treffer[] = $teil;
|
|
}
|
|
|
|
if ($gelesen >= LOG_MAX_BYTES && count($treffer) < $anzahl && $gelesen < $groesse) {
|
|
$abgeschnitten = true;
|
|
break;
|
|
}
|
|
}
|
|
fclose($fh);
|
|
|
|
return [
|
|
"zustand" => $zustand,
|
|
"zeilen" => array_reverse($treffer),
|
|
"abgeschnitten" => $abgeschnitten,
|
|
"durchsucht" => $gelesen,
|
|
];
|
|
}
|