Zum Hauptinhalt springen
Schneespur
Dokumentation durchblättern

Module entwickeln

Wetter & Zeitplanung

Wie Module eigene Wetter-Provider und zeitgesteuerte Aufgaben bereitstellen — austauschbare WeatherProvider, das WeatherData-Objekt und der ScheduledTask mit Cron-Ausdruck und Lauf-Protokoll.

Winterdienst hängt am Wetter, und vieles läuft zeitgesteuert im Hintergrund: Daten abrufen, alte Daten löschen, Berichte erzeugen. Dieser Abschnitt zeigt die zwei Erweiterungspunkte dafür — eigene Wetter-Provider und eigene geplante Aufgaben.

Wetter-Provider

Warum austauschbare Provider

Schneespur bezieht Wetterdaten nicht fest verdrahtet von einer einzigen Quelle, sondern über eine austauschbare Schnittstelle. Jeder Provider liefert dieselbe Datenstruktur, egal woher die Werte stammen. So lässt sich die Datenquelle wechseln, ohne den Rest der Anwendung anzufassen — und ein Modul kann eine zusätzliche Quelle nachrüsten, ohne Core-Dateien zu verändern.

Jeder Provider implementiert dasselbe Interface:

interface WeatherProviderInterface
{
    public function fetchCurrent(float $lat, float $lon): ?WeatherData;

    /** @return array{ok: bool, message: string, latency_ms: int} */
    public function testConnection(float $lat, float $lon): array;

    public function name(): string;
    public function requiresApiKey(): bool;
}

Die vier Methoden teilen sich zwei Aufgaben: fetchCurrent() holt die eigentlichen Wetterwerte für einen Standort, testConnection() prüft im Hintergrund, ob die Quelle erreichbar ist und wie schnell sie antwortet. name() und requiresApiKey() liefern der Oberfläche die Angaben, die sie für die Provider-Auswahl und die Schlüssel-Eingabe braucht.

Mitgelieferte Provider

Im Kern sind mehrere Provider registriert. Welcher davon im Betrieb sinnvoll ist, hängt vom Anbieter, seinen Nutzungsbedingungen und Ihrem Standort ab — die Auswahl treffen Sie in der Oberfläche.

SlugProviderAPI-Schlüssel nötig
openmeteo_freeOpen-Meteo (free tier)Nein
openmeteo_apiOpen-Meteo (API tier)Ja
brightskyBrightSky (DWD-Daten)Nein
met_norwayMet Norway (MET API)Nein

Prüfen Sie vor dem produktiven Einsatz die Nutzungsbedingungen des jeweiligen Anbieters — manche Tarife sind ausdrücklich auf nicht-kommerzielle Nutzung beschränkt. Genau für solche Fälle ist das Provider-System austauschbar: Reicht eine vorhandene Quelle nicht, binden Sie über ein Modul eine eigene ein.

Einen Wetter-Provider als Modul bauen

Ein eigener Provider ist eine Klasse, die WeatherProviderInterface implementiert. Das folgende Beispiel ruft eine externe API ab und übersetzt deren Antwort in ein WeatherData-Objekt:

namespace Schneespur\Module\CustomWeather\Weather;

use App\Models\Setting;
use App\Services\Weather\WeatherData;
use App\Services\Weather\WeatherProviderInterface;
use Illuminate\Support\Facades\Http;

class CustomWeatherProvider implements WeatherProviderInterface
{
    public function fetchCurrent(float $lat, float $lon): ?WeatherData
    {
        $response = Http::get('https://api.custom-weather.com/current', [
            'lat' => $lat,
            'lon' => $lon,
            'key' => Setting::get('custom-weather.api_key'),
        ]);

        if (!$response->successful()) return null;

        $data = $response->json();

        return new WeatherData(
            temperature: $data['temp'],
            precipitation: $data['precip'],
            snowDepth: $data['snow_depth'] ?? null,
            windSpeed: $data['wind_speed'],
            humidity: $data['humidity'],
            weatherCode: $data['wmo_code'],
        );
    }

    public function testConnection(float $lat, float $lon): array
    {
        $start = microtime(true);
        try {
            $result = $this->fetchCurrent($lat, $lon);
            $ms = (int) ((microtime(true) - $start) * 1000);
            return [
                'ok' => $result !== null,
                'message' => $result ? 'Connection successful' : 'No data returned',
                'latency_ms' => $ms,
            ];
        } catch (\Throwable $e) {
            return [
                'ok' => false,
                'message' => $e->getMessage(),
                'latency_ms' => (int) ((microtime(true) - $start) * 1000),
            ];
        }
    }

    public function name(): string { return 'Custom Weather API'; }
    public function requiresApiKey(): bool { return true; }
}

Zwei Punkte sind hier wichtig. Erstens gibt fetchCurrent() bei einem Fehlschlag null zurück statt eine Ausnahme nach oben durchzureichen — die Anwendung kann dann auf eine andere Quelle ausweichen, statt abzubrechen. Zweitens misst testConnection() die Antwortzeit in Millisekunden und meldet jeden Fehler als lesbare Nachricht zurück; genau diese Angaben zeigt die Oberfläche beim Verbindungstest an.

Registriert wird der Provider in der boot()-Phase Ihres ServiceProviders über die zuständige Registry:

app(WeatherProviderRegistry::class)->register('custom_weather', CustomWeatherProvider::class);

Das WeatherData-Objekt

WeatherData ist das gemeinsame Wertobjekt, in das jeder Provider seine Antwort übersetzt. Weil alle Provider dieselbe Struktur zurückgeben, muss der Rest der Anwendung nie wissen, woher die Daten stammen.

EigenschaftTypBedeutung
temperaturefloatTemperatur in °C
precipitationfloatNiederschlag in mm
snowDepth?floatSchneehöhe in cm
windSpeedfloatWindgeschwindigkeit in km/h
humidityintrelative Luftfeuchte in %
weatherCodeintWMO-Wettercode

Ihr Provider ist dafür zuständig, die Felder der externen API auf genau diese Eigenschaften und Einheiten abzubilden. Liefert eine Quelle etwa Temperaturen in Fahrenheit, rechnen Sie im Provider auf °C um.

Konvention: Einheiten der TTL

Die Wetter-Einstellungen verwenden in der Oberfläche Minuten, in der Datenbank dagegen Sekunden (TTL = Gültigkeitsdauer zwischengespeicherter Wetterdaten). Die Umrechnung in beide Richtungen übernimmt der Controller. Wenn Ihr Modul mit den Wetter-TTL-Einstellungen arbeitet, halten Sie sich an dieselbe Konvention, damit die Werte konsistent bleiben.

Geplante Aufgaben

Warum geplante Aufgaben

Manche Arbeit soll nicht auf Knopfdruck laufen, sondern regelmäßig im Hintergrund: täglich abgelaufene Daten löschen, stündlich nach Updates sehen, jede Minute die Warteschlange abarbeiten. Dafür gibt es geplante Aufgaben. Ein Modul kann eigene Aufgaben beisteuern, ohne den Cron-Aufbau der Anwendung zu kennen — es beschreibt nur, was wann laufen soll.

Jede Aufgabe implementiert dieses Interface:

interface ScheduledTaskInterface
{
    public function slug(): string;           // unique identifier
    public function label(): string;          // human-readable name
    public function schedule(): string;       // cron expression
    public function handle(): void;           // task execution
    public function isEnabled(): bool;        // whether task should run
    public function source(): string;         // 'core' or module slug
}

slug() ist die eindeutige Kennung, label() der lesbare Name für die Oberfläche. schedule() legt über einen Cron-Ausdruck fest, wann die Aufgabe läuft, handle() enthält die eigentliche Arbeit. Über isEnabled() kann sich eine Aufgabe abhängig von einer Einstellung selbst stilllegen, und source() weist sie dem Kern ('core') oder einem Modul-Slug zu — so bleibt nachvollziehbar, woher eine Aufgabe stammt.

Mitgelieferte Aufgaben

Im Kern laufen unter anderem diese Aufgaben:

SlugZeitplanBeschreibung
retention-deletetäglichlöscht abgelaufene Daten gemäß Aufbewahrungsregel
update-checktäglichsucht nach Anwendungs-Updates
queue-work* * * * *arbeitet Jobs aus der Warteschlange ab
cron-heartbeat* * * * *hält Cron-Aktivität für die Funktionsprüfung fest

Eine geplante Aufgabe bauen

Das folgende Beispiel erzeugt jeden Morgen um 06:00 Uhr einen Bericht und lässt sich über eine Einstellung an- und abschalten:

namespace Schneespur\Module\Reports\Scheduler;

use App\Models\Setting;
use App\Services\Scheduler\ScheduledTaskInterface;

class DailyReportTask implements ScheduledTaskInterface
{
    public function slug(): string { return 'daily-report'; }
    public function label(): string { return 'Daily Report Generation'; }
    public function schedule(): string { return '0 6 * * *'; } // 06:00 daily

    public function handle(): void
    {
        // Generate and send daily report
        $this->generateReport();
    }

    public function isEnabled(): bool
    {
        return (bool) Setting::get('reports.daily_enabled', false);
    }

    public function source(): string { return 'reports'; }
}

isEnabled() liest hier den Wert reports.daily_enabled aus den Einstellungen. Steht er auf false, überspringt die Anwendung die Aufgabe, ohne dass Sie den Code ändern müssen — Aktivieren und Deaktivieren läuft über die Oberfläche.

Registriert wird die Aufgabe wie der Wetter-Provider in der boot()-Phase über die zuständige Registry:

app(ScheduledTaskRegistry::class)->register('daily-report', DailyReportTask::class);

Läufe nachvollziehen

Die Registry hält fest, wann eine Aufgabe zuletzt lief, ob sie erfolgreich war und wie lange sie brauchte. Das macht Hintergrundarbeit überprüfbar: Eine Aufgabe, die seit Tagen nicht mehr erfolgreich gelaufen ist, fällt so auf.

$registry = app(ScheduledTaskRegistry::class);

// After running a task
$registry->recordRun('daily-report', 'success', null, 1234); // 1234ms

// On failure
$registry->recordRun('daily-report', 'failed', 'Connection timeout', 5000);

// Get last run info
$lastRun = $registry->lastRun('daily-report');
// → object{slug, status, error_message, duration_ms, ran_at}

// Get all tasks with status
$all = $registry->allWithStatus();
// → ['daily-report' => ['task' => ..., 'last_run' => ...]]

recordRun() protokolliert einen einzelnen Lauf samt Status, Fehlermeldung und Dauer. lastRun() liefert den letzten Lauf einer Aufgabe, allWithStatus() alle Aufgaben mit ihrem jeweils letzten Status — genau die Übersicht, die die Oberfläche anzeigt.

Cron-Ausdrücke

Der Zeitplan einer Aufgabe ist ein gewöhnlicher Cron-Ausdruck. Die häufigsten Muster:

AusdruckBedeutung
* * * * *jede Minute
*/5 * * * *alle 5 Minuten
0 * * * *jede volle Stunde
0 6 * * *täglich um 06:00
0 0 * * 1jeden Montag um Mitternacht
0 0 1 * *am ersten Tag jedes Monats

Die genaue API der hier genutzten Registries — WeatherProviderRegistry und ScheduledTaskRegistry — steht unter Registries.