Files
Smart-Dashboard/ajax/tile.php
T
adminandClaude Opus 5 d285ec27a0 Einstellungsseite, Skoda-Seite und Karte
Sammelstand der offenen Arbeit im Dashboard.

Einstellungen: eine Eingabemaske fuer die Energiepreise unter
restricted/settings.php, dazu costs.php als Modell und ajax/settings.php
als Endpunkt. Die Preise stehen jetzt in gridCosts, gasCosts und fuelCosts
statt fest im Quelltext von getStats.php; die Jahresstatistik rechnet ueber
LEAD() den jeweils gueltigen Zeitraum aus und schlaegt den anteiligen
Grundpreis auf. Eingegeben wird in gewohnten Einheiten - l/100 km,
kWh/100 km, Euro je Liter -, umgerechnet wird beim Speichern.
solarLog_costs.sql beschreibt die Umstellung der Tabellen.

Skoda: eigene Seite mit Live-Werten ueber MQTT und Historie ueber
ajax/skoda.php, Kommandos ueber ajax/skodaCmd.php.

Karte: Leaflet mit einem eigenen Kachel-Zwischenspeicher (ajax/tile.php),
dessen Ablage unter tiles/ nicht ins Repository gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:27 +02:00

291 lines
9.7 KiB
PHP

<?php
/*
* Kachel-Zwischenspeicher für die Karte.
*
* Aufruf: tile.php?z=16&x=34567&y=22456
*
* Liegt die Kachel im Zwischenspeicher, kommt sie von dort. Fehlt sie, holt
* das NAS sie einmal bei OpenStreetMap, legt sie ab und liefert aus. Danach
* verlässt für diese Kachel nie wieder eine Anfrage das Haus.
*
* Warum überhaupt ein Zwischenspeicher und nicht der direkte Weg vom Browser
* zu tile.openstreetmap.org? Drei Gründe:
*
* 1. Die Kachelrichtlinie der OSM Foundation verlangt Zwischenspeicherung
* von mindestens sieben Tagen. Der direkte Weg erfüllt das nur, soweit
* der Browser mitspielt.
* 2. Ohne Zwischenspeicher ginge die Parkposition des Fahrzeugs - also die
* Wohnadresse - bei jedem Seitenaufruf als Kachelanfrage nach draußen.
* 3. Einmal geholte Gegenden bleiben ohne Internet benutzbar.
*
* Was die Richtlinie ausdrücklich verbietet, ist Vorratsspeicherung: Kacheln
* auf Verdacht holen, Gebiete vorab abgrasen, Offline-Archive anlegen. Dieses
* Skript holt deshalb ausschließlich das, was gerade jemand ansieht, und die
* Bremse unten begrenzt zusätzlich, wie viel das pro Minute sein darf.
*
* Richtlinie: https://operations.osmfoundation.org/policies/tiles/
*/
require_once("../helper.php");
/* ------------------------------------------------------------ Einstellungen */
/** Woher die Kacheln kommen. {z}/{x}/{y} werden ersetzt. */
const KACHEL_QUELLE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
/*
* Die Richtlinie verlangt einen eigenen, kontaktierbaren User-Agent. Vorgaben
* von Bibliotheken werden ohne Vorwarnung geblockt. Die Domain genügt als
* Kontakt; wer lieber eine Mailadresse hinterlegt, ersetzt sie hier.
*/
const KENNUNG = "SmartController-SkodaKarte/1.0 (+https://nas.el-wa.org/smart)";
/*
* Wo die Kacheln liegen.
*
* Bevorzugt neben der Seite unter tiles/ - dort bleiben sie über Neustarts
* erhalten und liegen im Backup. PHP läuft auf dem NAS aber unter einem
* eigenen Benutzer, und DSM vergibt im Web-Verzeichnis ACLs, die ihm das
* Schreiben verwehren. Der POSIX-Modus täuscht: drwxrwxrwx mit einem "+"
* dahinter heisst, dass die ACL entscheidet, nicht die Rechtebits.
*
* Damit die Karte auch ohne Eingriff funktioniert, wird ersatzweise das
* Systemverzeichnis für flüchtige Dateien genommen. Das kostet nur den
* Bestand: es wird bei Gelegenheit geleert, die Kacheln müssen dann erneut
* geholt werden. Dauerhaft wird es mit einem Befehl als root:
*
* synoacltool -add /volume1/web/smart/tiles \
* "user:http:allow:rwxpdDaARWc--:fd--"
*
* Welcher Ort gerade benutzt wird, sagt speicherOrt() im Fehlerprotokoll,
* sobald zum ersten Mal eine Kachel geholt wird.
*/
const SPEICHER_ORTE = [
__DIR__ . "/../tiles", // dauerhaft, bevorzugt
"/var/services/tmp/smart-tiles", // flüchtig, aber immer beschreibbar
];
/** Zoomstufen, die ausgeliefert werden. Feiner als Hausnummern braucht es nicht. */
const ZOOM_MIN = 6;
const ZOOM_MAX = 18;
/** Wie lange der Browser eine Kachel behalten darf (Sekunden). */
const BROWSER_FRIST = 7 * 24 * 3600;
/**
* Nach dieser Zeit wird eine Kachel beim nächsten Ansehen neu geholt. Die
* Karte ändert sich, aber nicht schnell. Scheitert das Nachholen, bleibt die
* alte Kachel - die Karte ist dann veraltet, aber nie kaputt.
*/
const AUFFRISCHEN_NACH = 60 * 24 * 3600;
/*
* Bremse gegen den eigenen Fehler. Ein Kartenfeld dieser Größe braucht rund
* zwölf Kacheln je Ansicht; wer sich durch mehrere Zoomstufen klickt, kommt
* auf einige Dutzend. Alles weit darüber wäre eine Schleife im Frontend - und
* die sähe für OSM wie Vorratsspeicherung aus. Ist die Grenze erreicht, wird
* eine graue Ersatzkachel geliefert statt weiter nach draußen zu gehen.
*/
const HOLEN_JE_MINUTE = 150;
const HOLEN_JE_STUNDE = 2000;
/* ------------------------------------------------------------------ Zugriff */
if (!checkLogin()) {
http_response_code(403);
exit;
}
/* ----------------------------------------------------------------- Prüfung */
$z = filter_input(INPUT_GET, "z", FILTER_VALIDATE_INT);
$x = filter_input(INPUT_GET, "x", FILTER_VALIDATE_INT);
$y = filter_input(INPUT_GET, "y", FILTER_VALIDATE_INT);
// Ohne diese Prüfung liesse sich über z/x/y aus dem Speicherverzeichnis
// heraus schreiben - die Werte gehen schliesslich in einen Dateipfad.
$grenze = ($z === false || $z === null) ? 0 : (1 << $z);
if ($z === false || $z === null || $z < ZOOM_MIN || $z > ZOOM_MAX
|| $x === false || $x === null || $x < 0 || $x >= $grenze
|| $y === false || $y === null || $y < 0 || $y >= $grenze) {
http_response_code(400);
exit;
}
$pfad = speicherOrt() . "/$z/$x/$y.png";
/* -------------------------------------------------------------- Ausliefern */
$vorhanden = is_file($pfad);
$veraltet = $vorhanden && (time() - filemtime($pfad) > AUFFRISCHEN_NACH);
if (!$vorhanden || $veraltet) {
if (holeUndSpeichere($z, $x, $y, $pfad)) {
$vorhanden = true;
}
}
if ($vorhanden) {
liefereDatei($pfad);
} else {
liefereErsatzkachel();
}
exit;
/* ------------------------------------------------------------- Bausteine -- */
/**
* Der erste beschreibbare Ort aus SPEICHER_ORTE, einmal je Aufruf ermittelt.
* Existiert keiner, wird der letzte angelegt - der liegt im Systemverzeichnis
* für flüchtige Dateien und ist immer beschreibbar.
*/
function speicherOrt()
{
static $ort = null;
if ($ort !== null) {
return $ort;
}
foreach (SPEICHER_ORTE as $kandidat) {
if (is_dir($kandidat) && is_writable($kandidat)) {
return $ort = $kandidat;
}
}
$ort = SPEICHER_ORTE[count(SPEICHER_ORTE) - 1];
if (!is_dir($ort)) {
@mkdir($ort, 0755, true);
}
return $ort;
}
/**
* Eine Kachel bei der Quelle holen und ablegen.
* Gibt true zurück, wenn danach eine brauchbare Datei vorliegt.
*/
function holeUndSpeichere($z, $x, $y, $pfad)
{
if (!bremseErlaubt()) {
return is_file($pfad); // notfalls die veraltete Kachel
}
$url = str_replace(["{z}", "{x}", "{y}"], [$z, $x, $y], KACHEL_QUELLE);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => KENNUNG,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 2,
]);
$inhalt = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// Auf den PNG-Kopf prüfen, statt nur auf den Statuscode: eine Fehlerseite
// mit Status 200 wäre sonst als Kachel im Speicher gelandet und bliebe
// dort für immer.
if ($status !== 200 || $inhalt === false || strncmp($inhalt, "\x89PNG", 4) !== 0) {
return is_file($pfad);
}
if (!is_dir(dirname($pfad)) && !mkdir(dirname($pfad), 0755, true)) {
return is_file($pfad);
}
// Erst daneben schreiben, dann umbenennen. Sonst könnte ein zweiter
// Aufruf eine halb geschriebene Datei ausliefern.
$temp = $pfad . "." . getmypid() . ".teil";
if (file_put_contents($temp, $inhalt) === false) {
return is_file($pfad);
}
if (!rename($temp, $pfad)) {
unlink($temp);
return is_file($pfad);
}
return true;
}
/**
* Darf jetzt noch eine Kachel geholt werden?
*
* Gezählt wird in einer kleinen Datei, gegen gleichzeitige Zugriffe mit
* flock abgesichert. Zwei Fenster: eines je Minute, eines je Stunde. Läuft
* ein Fenster ab, fängt sein Zähler wieder bei null an.
*/
function bremseErlaubt()
{
$datei = speicherOrt() . "/.bremse.json";
$griff = fopen($datei, "c+");
if (!$griff) {
return true;
}
if (!flock($griff, LOCK_EX)) {
fclose($griff);
return true;
}
$stand = json_decode(stream_get_contents($griff), true);
if (!is_array($stand)) {
$stand = ["minute" => 0, "min_n" => 0, "stunde" => 0, "std_n" => 0];
}
$jetzt = time();
if ($jetzt - $stand["minute"] >= 60) { $stand["minute"] = $jetzt; $stand["min_n"] = 0; }
if ($jetzt - $stand["stunde"] >= 3600) { $stand["stunde"] = $jetzt; $stand["std_n"] = 0; }
$erlaubt = $stand["min_n"] < HOLEN_JE_MINUTE && $stand["std_n"] < HOLEN_JE_STUNDE;
if ($erlaubt) {
$stand["min_n"]++;
$stand["std_n"]++;
}
ftruncate($griff, 0);
rewind($griff);
fwrite($griff, json_encode($stand));
flock($griff, LOCK_UN);
fclose($griff);
if (!$erlaubt) {
error_log("tile.php: Bremse greift, keine weiteren Kacheln in diesem Fenster.");
}
return $erlaubt;
}
/** Eine gespeicherte Kachel ausliefern, mit Unterstützung für 304. */
function liefereDatei($pfad)
{
$zeit = filemtime($pfad);
$marke = '"' . md5($pfad . $zeit) . '"';
header("Content-Type: image/png");
header("Cache-Control: public, max-age=" . BROWSER_FRIST);
header("ETag: $marke");
header("Last-Modified: " . gmdate("D, d M Y H:i:s", $zeit) . " GMT");
$bekannt = isset($_SERVER["HTTP_IF_NONE_MATCH"]) ? trim($_SERVER["HTTP_IF_NONE_MATCH"]) : "";
if ($bekannt === $marke) {
http_response_code(304);
return;
}
header("Content-Length: " . filesize($pfad));
readfile($pfad);
}
/**
* Graue Ersatzkachel, wenn nichts zu holen war - kein Internet, Quelle
* gestört oder Bremse aktiv. Ein leeres Feld ist besser als ein kaputtes
* Bildsymbol, und es wird bewusst nicht zwischengespeichert.
*/
function liefereErsatzkachel()
{
header("Content-Type: image/png");
header("Cache-Control: no-store");
// 1x1 Punkt in Grau, von Leaflet auf Kachelgröße gezogen.
echo base64_decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
. "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
}