/**
* [Wetter.com / Meteonomiqs PWA v4.0 -> ioBroker] (TrueScript / TypeScript)
*
* CHANGELOG:
* - 3.2.1: 2026-08-06 - HOTFIX: 'current' wurde beim Neustart nicht angelegt
* BUGFIX: wcomUpdateCurrent() lief ausschliesslich am Ende von wcomProcessForecastData().
* Wurde der Start-Abruf uebersprungen (Cooldown, Budget, fehlender API-Key),
* entstand der Ordner nie - im Extremfall bis zum naechsten planmaessigen Abruf.
* Der Aufbau haengt jetzt nicht mehr am Abruf: 'current' wird beim Skriptstart
* immer erzeugt und aus den bereits vorhandenen Stunden-Datenpunkten gefuellt.
*
* - 3.2.0: 2026-08-06 - CURRENT-ORDNER (aktuelle Stunde)
* FEATURE: Neuer Zweig '<DP_PATH>.current' mit den Werten der laufenden Stunde -
* analog zu den Adaptern 'daswetter' und 'open-meteo-weather'.
* Wird STUENDLICH aktualisiert (Cron :01), nicht nur beim API-Abruf.
* Quelle sind die bereits vorhandenen Stunden-Datenpunkte, nicht der RAM-Cache:
* dadurch funktioniert 'current' auch nach einem Skript-Neustart sofort weiter
* und kostet keinen einzigen zusaetzlichen API-Call.
* Der Tageswechsel wird ueber 'date_iso' aufgeloest - zwischen 00:00 und dem
* 01:10-Abruf liegt "heute" noch in day_1, danach in day_0.
* HINWEIS: 'current' ist die VORHERSAGE fuer die laufende Stunde, keine Messung.
* Echte Momentanwerte liefert /nowcast bzw. /stations - beides kostet je
* Abfrage einen API-Call und passt damit nicht in ein 100er-Kontingent.
*
* - 3.1.0: 2026-08-06 - DREI ABRUFE PRO TAG MIT PRIORITAETS-STAFFELUNG
* FEATURE: Abrufzeiten liegen jetzt als Liste in CONFIG.UPDATES - beliebig viele Eintraege.
* 01:10 (Tagesplanung) | 11:40 (Korrektur Nachmittag) | 18:40 (Nacht + Folgetag)
* FEATURE: Jeder Abruf hat eine 'tier'-Stufe. Wird das Budget knapp, fallen die Abrufe in
* umgekehrter Wichtigkeit weg (3 -> 2 -> 1/Tag), statt dass das Skript gegen Ende
* des Monats komplett stehen bleibt.
* FEATURE: Retry verbraucht keine Notreserve mehr (CONFIG.RESERVE_CALLS).
* FEATURE: Startup-Check prueft jetzt auch, ob die Taktung ueberhaupt ins Monatsbudget passt.
* CHANGE: Cooldown 8 h -> 5 h (kuerzester Abstand der neuen Zeiten betraegt 6,5 h).
*
* - 3.0.0: 2026-08-06 - API-KONFORMITAET, BUGFIXES & DATENERWEITERUNG
* BUGFIX (kritisch): Cooldown blockierte dauerhaft einen der beiden Abrufe.
* 12 h Cooldown bei 11 h Cron-Abstand => Nachmittags-Abruf lief NIE.
* Neu: 8 h Cooldown + automatische Plausibilitaetspruefung beim Start.
* BUGFIX (kritisch): Icon wurde selbst gebaut (`d_<state>.svg`). Die API liefert das
* korrekte Icon inkl. Nacht- (n_), Unwetter- (d_e_) und Wind-Variante
* (d_w_) im Feld `weather.icon`. Wird jetzt uebernommen.
* BUGFIX: Monats-/Tageszaehler wurden nur per Cron zurueckgesetzt. Lief ioBroker um
* 00:00 am 1. nicht, blieb der Zaehler stehen -> Skript pausierte den ganzen Monat.
* Neu: Perioden-Marker (info.requests_month_period / _today_date), Reset lazy.
* BUGFIX: API-Calls wurden nur bei HTTP 200 gezaehlt. 4xx/5xx verbrauchen aber Kontingent.
* Neu: jede beantwortete Anfrage wird gezaehlt.
* BUGFIX: `spaces[i]` wurde per Index auf `summary[i]` gemappt. Neu: Zuordnung ueber `date`.
* BUGFIX: Datums-Strings "YYYY-MM-DD" wurden als UTC-Mitternacht geparst -> in Zeitzonen
* westlich von UTC um einen Tag verschoben. Neu: lokale Interpretation.
* BUGFIX: Stunden-Zuordnung basierte auf der Server-Zeitzone. Neu: Zeitzone des Ortes
* aus `location.timezone` der API (Intl), Fallback auf Serverzeit.
* BUGFIX: Vergangene Stunden blieben mit veralteten Werten stehen. Neu: `valid`-Flag.
* FEATURE: Aenderung von `forecast_days` rendert aus dem RAM-Cache - ohne API-Call.
* FEATURE: Neue Datenpunkte aus der v4.0-Spec (alle aus DERSELBEN Antwort, 0 Extra-Calls):
* Wetterwarnungen, Astronomie (Sonne/Mond), Luftdruck/Taupunkt/Windchill, Schnee.
* FEATURE: JSON-Aggregate `forecast_json` / `hourly_json` fuer VIS/jarvis/Material-Widgets.
* FEATURE: Ein Retry bei Netzwerkfehler/5xx, damit ein Abruffenster nicht verloren geht.
* REFACTOR: Tabellengetriebene Feld-Definitionen statt duplizierter setState-Bloecke.
*
* KONTEXT:
* - Endpoint: GET https://forecast.meteonomiqs.com/v4_0/forecast/{lat}/{lon}
* - Auth: Header `x-api-key`, Sprache ueber `Accept-Language`
* - Standort: automatisch aus system.config (Breiten-/Laengengrad des ioBroker-Systems)
*
* ZIELE:
* - Zero-Churn (setStateChanged), Zero-I/O-Overhead (RAM-Cache fuer Objektpfade),
* typsichere Auswertung, harte Budget-Einhaltung.
*/
// =====================================================================================
// KONFIGURATION
// =====================================================================================
const CONFIG = {
// --- Datenpunkte fuer Benutzereingaben ---
DP_API_KEY: '0_userdata.0.wetter.info.api_key',
DP_FORECAST_DAYS: '0_userdata.0.wetter.info.forecast_days',
DP_FORCE_RESET: '0_userdata.0.wetter.info.force_reset',
DP_FORCE_UPDATE: '0_userdata.0.wetter.info.force_update',
/**
* --- Zeitsteuerung ---
* Feste Zeiten, keine Randomisierung. Jeder Eintrag hat eine Prioritaetsstufe:
*
* tier = "wie viele Abrufe pro Tag muss das Restbudget noch tragen,
* damit dieser Abruf laufen darf?"
*
* Wird das Budget knapp, faellt zuerst tier 3 weg, dann tier 2. tier 1 laeuft
* bis zuletzt (notfalls nur noch jeden zweiten Tag). Dadurch bricht die
* Versorgung nie komplett ab, sie wird nur gröber.
*
* 01:10 tier 1 Tagesplanung - die vollstaendige Vorhersage fuer den Tag
* 11:40 tier 2 Nachmittags-Korrektur - Gewitter, Boeen, Hitzespitze vor der kritischen Phase
* 18:40 tier 3 Abend + Folgetag - Nachtfrost, Sturm, Vorschau auf morgen
*/
UPDATES: [
{ cron: '10 1 * * *', id: 'nacht', tier: 1, name: 'Nacht (Tagesplanung)' },
{ cron: '40 11 * * *', id: 'mittag', tier: 2, name: 'Mittag (Korrektur Nachmittag)' },
{ cron: '40 18 * * *', id: 'abend', tier: 3, name: 'Abend (Nacht + Folgetag)' }
] as { cron: string; id: string; tier: number; name: string }[],
// Cooldown MUSS kleiner sein als der kuerzeste Abstand zwischen zwei Abrufen.
// 01:10 -> 11:40 = 10,5 h | 11:40 -> 18:40 = 7,0 h | 18:40 -> 01:10 = 6,5 h => 5 h ist sicher.
// Der Startup-Check rechnet das nach und meldet sich, falls es nicht passt.
MIN_HOURS_BETWEEN_UPDATES: 5,
// Notreserve: so viele Calls bleiben fuer manuelle Aktionen frei. Automatische
// Wiederholungsversuche (Retry) greifen die Reserve nicht an.
RESERVE_CALLS: 5,
// --- API ---
BASE_URL: 'https://forecast.meteonomiqs.com/v4_0',
ICON_BASE_URL: 'https://cs3.wettercomassets.com/wcomv5/images/icons/weather',
HTTP_TIMEOUT_MS: 20000,
MAX_RETRIES: 1, // zusaetzliche Versuche bei Netzwerkfehler / HTTP 5xx
RETRY_DELAY_MS: 30000,
// --- Budget ---
MONTHLY_LIMIT: 100,
// --- Objektbaum ---
DP_PATH: '0_userdata.0.wetter',
DEFAULT_LANGUAGE: 'de',
MAX_FORECAST_DAYS: 14, // API liefert max. 14 Tage
CLEANUP_UP_TO_DAY: 25, // Aufraeumgrenze fuer alte Strukturen
// --- Umfang der Datenpunkte (alles aus einem einzigen API-Call) ---
ENABLE_SPACES: true, // Tagesabschnitte morning/afternoon/evening/night
ENABLE_HOURLY: true, // Stundenwerte
HOURLY_DAYS: 2, // fuer wie viele Tage (0 = aus, 2 = heute + morgen)
ENABLE_CURRENT: true, // Ordner 'current' mit den Werten der laufenden Stunde
CURRENT_CRON: '1 * * * *', // stuendlich um :01 aus den Stunden-Datenpunkten uebernehmen
ENABLE_WARNINGS: true, // highestWarning (Warngruppe, Schweregrad)
ENABLE_ASTRO: true, // Sonnenauf-/untergang, Daemmerung, Mond
ENABLE_EXTENDED: true, // Luftdruck, Taupunkt, Windchill
ENABLE_SNOW: true, // Schneefallgrenze, Frischschnee, Schneemenge
ENABLE_JSON: true, // forecast_json / hourly_json fuer VIS-Widgets
LOG_LEVEL: 'info' as 'debug' | 'info' | 'warn' | 'error',
LOCATION: {
LAT: '',
LON: '',
FORCE_MANUAL: false
}
};
// =====================================================================================
// TYPEN
// =====================================================================================
type FetchSource = 'scheduled' | 'start' | 'key_update' | 'days_update' | 'force_reset' | 'manual';
type FieldGroup = 'core' | 'warn' | 'astro' | 'extended' | 'snow';
interface Ctx {
lang: string;
tz: string;
}
interface FieldDef {
id: string;
name: string;
type: iobJS.CommonType;
role: string;
unit?: string;
digits?: number;
group?: FieldGroup;
get: (src: any, ctx: Ctx) => any;
}
interface SystemConfig {
lat: string;
lon: string;
lang: string;
}
// =====================================================================================
// GLOBALER ZUSTAND
// =====================================================================================
let isFetching = false;
let lastPayload: any = null; // RAM-Cache der letzten API-Antwort
let lastPayloadTs = 0;
let daysDebounce: any = null;
let authFailed = false; // Sperre nach HTTP 401/403, bis der Key geaendert wird
const ensuredPaths = new Set<string>();
// =====================================================================================
// HILFSFUNKTIONEN
// =====================================================================================
/** Filtert Log-Ausgaben anhand des konfigurierten Log-Levels. */
function wcomLog(msg: string, level: 'debug' | 'info' | 'warn' | 'error' = 'info'): void {
const levels = { debug: 0, info: 1, warn: 2, error: 3 };
if (levels[level] >= levels[CONFIG.LOG_LEVEL]) log(`[Wetter.com] ${msg}`, level);
}
function wcomPad(n: number | string): string { return String(n).padStart(2, '0'); }
function wcomSleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** Rundet und faengt NaN/Infinity ab (verhindert Float-Rauschen und damit unnoetige State-Writes). */
function wcomRound(v: number, digits: number = 1): number {
if (typeof v !== 'number' || !isFinite(v)) return 0;
const p = Math.pow(10, digits);
return Math.round(v * p) / p;
}
/**
* Extrahiert typsicher numerische Werte aus den verschachtelten API-Objekten
* ({value}|{avg}|{sum}|{max}|{min}) und faengt korrupte Rueckgaben ab.
*/
function wcomExtractValue(val: any): number {
if (val === null || val === undefined) return 0;
if (typeof val === 'number') return isFinite(val) ? val : 0;
if (typeof val === 'boolean') return val ? 1 : 0;
if (typeof val === 'object') {
for (const key of ['value', 'avg', 'sum', 'max', 'min']) {
if (val[key] !== undefined && val[key] !== null) return wcomExtractValue(val[key]);
}
return 0;
}
const parsed = parseFloat(String(val));
if (isNaN(parsed)) {
if (String(val).trim() !== '') wcomLog(`Unerwarteter Nicht-Zahlenwert von API: "${val}"`, 'debug');
return 0;
}
return parsed;
}
/**
* Parst API-Datumsangaben. "YYYY-MM-DD" wird bewusst LOKAL interpretiert -
* `new Date('2026-08-06')` waere UTC-Mitternacht und in Zeitzonen westlich von
* UTC damit der Vortag.
*/
function wcomParseApiDate(input: string | Date): Date | null {
if (!input) return null;
if (input instanceof Date) return isNaN(input.getTime()) ? null : input;
const s = String(input).trim();
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
const d = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : new Date(s);
return isNaN(d.getTime()) ? null : d;
}
/** Formatiert nach DD.MM.YYYY. */
function wcomFormatDate(input: string | Date): string {
const d = wcomParseApiDate(input);
if (!d) return '';
return `${wcomPad(d.getDate())}.${wcomPad(d.getMonth() + 1)}.${d.getFullYear()}`;
}
/** Ausgeschriebener Wochentag in der Systemsprache. */
function wcomGetDayName(dateStr: string, locale: string): string {
const d = wcomParseApiDate(dateStr);
if (!d) return '';
try { return d.toLocaleDateString(locale, { weekday: 'long' }); }
catch (e) { return d.toLocaleDateString('de', { weekday: 'long' }); }
}
/**
* Rechnet einen UTC-Zeitstempel in die Ortszeit des Vorhersageortes um.
* Nutzt die von der API gelieferte `location.timezone` - dadurch stimmen die
* Stunden-Datenpunkte auch dann, wenn der ioBroker-Server in einer anderen
* Zeitzone laeuft als der abgefragte Ort.
*/
function wcomLocalParts(utcIso: string, tz: string): { date: string; hour: string } | null {
const d = utcIso ? new Date(utcIso) : null;
if (!d || isNaN(d.getTime())) return null;
try {
const parts: any = {};
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', hourCycle: 'h23'
});
for (const p of fmt.formatToParts(d)) parts[p.type] = p.value;
const hour = parts.hour === '24' ? '00' : parts.hour;
return { date: `${parts.year}-${parts.month}-${parts.day}`, hour: hour };
} catch (e) {
return {
date: `${d.getFullYear()}-${wcomPad(d.getMonth() + 1)}-${wcomPad(d.getDate())}`,
hour: wcomPad(d.getHours())
};
}
}
/** Zieht "HH:MM" aus einem ISO-String mit Offset (z. B. 2026-08-06T06:17:49+02:00). */
function wcomIsoTime(iso: any): string {
const m = /T(\d{2}):(\d{2})/.exec(String(iso || ''));
return m ? `${m[1]}:${m[2]}` : '';
}
/** Tageslaenge in Stunden aus Sonnenauf- und -untergang. */
function wcomDayLength(sunrise: any, sunset: any): number {
const a = new Date(String(sunrise || '')); const b = new Date(String(sunset || ''));
if (isNaN(a.getTime()) || isNaN(b.getTime())) return 0;
return wcomRound((b.getTime() - a.getTime()) / 3600000, 2);
}
/**
* Liefert die vollstaendige Icon-URL. Bevorzugt das von der API gelieferte
* Dateifeld - dieses beruecksichtigt Tag/Nacht (d_/n_), Unwetterwarnung (d_e_)
* und Windwarnung (d_w_). Nur als Fallback wird aus dem State gebaut.
*/
function wcomIconUrl(weather: any, isNight?: boolean): string {
const raw = weather && typeof weather.icon === 'string' ? weather.icon.trim() : '';
const file = raw !== '' ? raw : `${isNight ? 'n' : 'd'}_${weather && weather.state !== undefined && weather.state !== null ? weather.state : 999}.svg`;
return `${CONFIG.ICON_BASE_URL}/${file}`;
}
/** Wolkenbedeckung in Prozent. Stuendlich liefert die API Oktas (`eights`). */
function wcomCloudsPercent(clouds: any): number {
if (clouds === null || clouds === undefined) return 0;
if (typeof clouds === 'number') return clouds;
if (typeof clouds.avg === 'number') return clouds.avg;
if (typeof clouds.eights === 'number') return wcomRound((clouds.eights / 8) * 100, 1);
if (typeof clouds.low === 'number' || typeof clouds.middle === 'number' || typeof clouds.high === 'number') {
return Math.max(Number(clouds.low) || 0, Number(clouds.middle) || 0, Number(clouds.high) || 0);
}
return wcomExtractValue(clouds);
}
const MOON_PHASE: Record<string, string[]> = {
de: ['', 'Neumond', 'Zunehmender Sichelmond', 'Zunehmender Halbmond', 'Zunehmender Dreiviertelmond',
'Vollmond', 'Abnehmender Dreiviertelmond', 'Abnehmender Halbmond', 'Abnehmender Sichelmond'],
en: ['', 'New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous',
'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent']
};
const MOON_ZODIAC: Record<string, string[]> = {
de: ['', 'Widder', 'Stier', 'Zwillinge', 'Krebs', 'Löwe', 'Jungfrau', 'Waage', 'Skorpion', 'Schütze', 'Steinbock', 'Wassermann', 'Fische'],
en: ['', 'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
};
function wcomLookup(table: Record<string, string[]>, lang: string, idx: any): string {
const list = table[String(lang).slice(0, 2).toLowerCase()] || table.de;
const i = Number(idx);
return (Number.isInteger(i) && i > 0 && i < list.length) ? list[i] : '';
}
/** Prueft, ob eine Feldgruppe laut CONFIG angelegt werden soll. */
function wcomGroupEnabled(group?: FieldGroup): boolean {
switch (group) {
case 'warn': return CONFIG.ENABLE_WARNINGS;
case 'astro': return CONFIG.ENABLE_ASTRO;
case 'extended': return CONFIG.ENABLE_EXTENDED;
case 'snow': return CONFIG.ENABLE_SNOW;
default: return true;
}
}
// =====================================================================================
// FELD-DEFINITIONEN (tabellengetrieben statt duplizierter setState-Bloecke)
// =====================================================================================
const DAY_FIELDS: FieldDef[] = [
{ id: 'date', name: 'Datum', type: 'string', role: 'text', get: d => wcomFormatDate(d.date) },
{ id: 'date_iso', name: 'Datum (ISO)', type: 'string', role: 'date', get: d => d.date || '' },
{ id: 'day_name', name: 'Wochentag', type: 'string', role: 'text', get: (d, c) => wcomGetDayName(d.date, c.lang) },
{ id: 'temp_max', name: 'Max. Temperatur', type: 'number', role: 'value.temperature.max', unit: '°C', digits: 1, get: d => d.temperature?.max },
{ id: 'temp_min', name: 'Min. Temperatur', type: 'number', role: 'value.temperature.min', unit: '°C', digits: 1, get: d => d.temperature?.min },
{ id: 'temp_avg', name: 'Durchschnittstemperatur', type: 'number', role: 'value.temperature', unit: '°C', digits: 1, get: d => d.temperature?.avg },
{ id: 'weather_state', name: 'Wetter-Code', type: 'number', role: 'value', digits: 0, get: d => d.weather?.state },
{ id: 'weather_text', name: 'Wetter', type: 'string', role: 'weather.state', get: d => d.weather?.text },
{ id: 'weather_icon', name: 'Icon URL', type: 'string', role: 'weather.icon', get: d => wcomIconUrl(d.weather, false) },
{ id: 'prec_probability', name: 'Regenrisiko', type: 'number', role: 'value.precipitation.probability', unit: '%', digits: 0, get: d => d.prec?.probability },
{ id: 'prec_sum', name: 'Niederschlagsmenge', type: 'number', role: 'value.precipitation', unit: 'mm', digits: 2, get: d => d.prec?.sum },
{ id: 'prec_class', name: 'Niederschlagsklasse (0-3)', type: 'number', role: 'value', digits: 0, get: d => d.prec?.class },
{ id: 'rainy_hours', name: 'Regenstunden', type: 'number', role: 'value', unit: 'h', digits: 1, get: d => d.rainyHours },
{ id: 'sun_hours', name: 'Sonnenstunden', type: 'number', role: 'value.sun', unit: 'h', digits: 1, get: d => d.sunHours },
{ id: 'clouds', name: 'Bewölkung', type: 'number', role: 'value.clouds', unit: '%', digits: 1, get: d => wcomCloudsPercent(d.clouds) },
{ id: 'humidity', name: 'Relative Feuchte', type: 'number', role: 'value.humidity', unit: '%', digits: 0, get: d => d.relativeHumidity },
{ id: 'wind_speed', name: 'Windgeschwindigkeit (Ø)', type: 'number', role: 'value.speed.wind', unit: 'km/h', digits: 1, get: d => d.wind?.avg },
{ id: 'wind_speed_max', name: 'Max. Windgeschwindigkeit', type: 'number', role: 'value.speed.wind.max', unit: 'km/h', digits: 1, get: d => d.wind?.max ?? d.wind?.avg },
{ id: 'wind_gusts', name: 'Windböen', type: 'number', role: 'value.speed.wind.gust', unit: 'km/h', digits: 1, get: d => d.wind?.gusts },
{ id: 'wind_direction', name: 'Windrichtung', type: 'string', role: 'weather.direction', get: d => d.wind?.direction },
{ id: 'wind_direction_short', name: 'Windrichtung (kurz)', type: 'string', role: 'weather.direction', get: d => d.wind?.text },
{ id: 'wind_degree', name: 'Windrichtung (Grad)', type: 'number', role: 'value.direction.wind', unit: '°', digits: 0, get: d => d.wind?.degree },
// --- Erweiterte meteorologische Werte ---
{ id: 'pressure', name: 'Luftdruck', type: 'number', role: 'value.pressure', unit: 'hPa', digits: 0, group: 'extended', get: d => d.pressure },
{ id: 'windchill_min', name: 'Gefühlt min.', type: 'number', role: 'value.temperature.min', unit: '°C', digits: 1, group: 'extended', get: d => d.windchill?.min },
{ id: 'windchill_max', name: 'Gefühlt max.', type: 'number', role: 'value.temperature.max', unit: '°C', digits: 1, group: 'extended', get: d => d.windchill?.max },
// --- Schnee ---
{ id: 'snow_sum', name: 'Schnee (Wasseräquivalent)', type: 'number', role: 'value.precipitation', unit: 'mm', digits: 2, group: 'snow', get: d => d.prec?.snow },
{ id: 'fresh_snow_min', name: 'Neuschnee min.', type: 'number', role: 'value', unit: 'cm', digits: 1, group: 'snow', get: d => d.prec?.freshSnowRangeMin },
{ id: 'fresh_snow_max', name: 'Neuschnee max.', type: 'number', role: 'value', unit: 'cm', digits: 1, group: 'snow', get: d => d.prec?.freshSnowRangeMax },
{ id: 'snowline_min', name: 'Schneefallgrenze min.', type: 'number', role: 'value', unit: 'm', digits: 0, group: 'snow', get: d => d.snowLine?.min },
{ id: 'snowline_max', name: 'Schneefallgrenze max.', type: 'number', role: 'value', unit: 'm', digits: 0, group: 'snow', get: d => d.snowLine?.max },
// --- Wetterwarnung ---
{ id: 'warn_active', name: 'Warnung aktiv', type: 'boolean', role: 'indicator.alarm', group: 'warn', get: d => !!d.highestWarning },
{ id: 'warn_group', name: 'Warnung Gruppe', type: 'string', role: 'text', group: 'warn', get: d => d.highestWarning?.group },
{ id: 'warn_text', name: 'Warnung Text', type: 'string', role: 'text', group: 'warn', get: d => d.highestWarning?.text },
{ id: 'warn_severity', name: 'Warnung Schweregrad', type: 'string', role: 'text', group: 'warn', get: d => d.highestWarning?.severity },
{ id: 'warn_severity_int', name: 'Warnung Schweregrad (0-4)', type: 'number', role: 'value.severity', digits: 0, group: 'warn', get: d => d.highestWarning?.severityInt }
];
const ASTRO_FIELDS: FieldDef[] = [
{ id: 'sunrise', name: 'Sonnenaufgang', type: 'string', role: 'date.sunrise', get: a => wcomIsoTime(a.sunrise) },
{ id: 'sunset', name: 'Sonnenuntergang', type: 'string', role: 'date.sunset', get: a => wcomIsoTime(a.sunset) },
{ id: 'dawn', name: 'Morgendämmerung', type: 'string', role: 'text', get: a => wcomIsoTime(a.dawn) },
{ id: 'dusk', name: 'Abenddämmerung', type: 'string', role: 'text', get: a => wcomIsoTime(a.dusk) },
{ id: 'suntransit', name: 'Sonnenhöchststand', type: 'string', role: 'text', get: a => wcomIsoTime(a.suntransit) },
{ id: 'sunrise_iso', name: 'Sonnenaufgang (ISO)', type: 'string', role: 'date.sunrise', get: a => a.sunrise || '' },
{ id: 'sunset_iso', name: 'Sonnenuntergang (ISO)', type: 'string', role: 'date.sunset', get: a => a.sunset || '' },
{ id: 'day_length', name: 'Tageslänge', type: 'number', role: 'value', unit: 'h', digits: 2, get: a => wcomDayLength(a.sunrise, a.sunset) },
{ id: 'moonrise', name: 'Mondaufgang', type: 'string', role: 'text', get: a => wcomIsoTime(a.moonrise) },
{ id: 'moonset', name: 'Monduntergang', type: 'string', role: 'text', get: a => wcomIsoTime(a.moonset) },
{ id: 'moonphase', name: 'Mondphase (1-8)', type: 'number', role: 'value', digits: 0, get: a => a.moonphase },
{ id: 'moonphase_text', name: 'Mondphase', type: 'string', role: 'text', get: (a, c) => wcomLookup(MOON_PHASE, c.lang, a.moonphase) },
{ id: 'moonzodiac', name: 'Mond-Tierkreiszeichen (1-12)', type: 'number', role: 'value', digits: 0, get: a => a.moonzodiac },
{ id: 'moonzodiac_text', name: 'Mond-Tierkreiszeichen', type: 'string', role: 'text', get: (a, c) => wcomLookup(MOON_ZODIAC, c.lang, a.moonzodiac) }
];
const SPACE_FIELDS: FieldDef[] = [
{ id: 'temp', name: 'Temperatur', type: 'number', role: 'value.temperature', unit: '°C', digits: 1, get: s => s.temperature?.avg ?? s.temperature },
{ id: 'temp_min', name: 'Temperatur min.', type: 'number', role: 'value.temperature.min', unit: '°C', digits: 1, get: s => s.temperature?.min },
{ id: 'temp_max', name: 'Temperatur max.', type: 'number', role: 'value.temperature.max', unit: '°C', digits: 1, get: s => s.temperature?.max },
{ id: 'weather_state', name: 'Wetter-Code', type: 'number', role: 'value', digits: 0, get: s => s.weather?.state },
{ id: 'text', name: 'Wetter', type: 'string', role: 'weather.state', get: s => s.weather?.text },
{ id: 'icon', name: 'Icon URL', type: 'string', role: 'weather.icon', get: s => wcomIconUrl(s.weather, !!s.isNight) },
{ id: 'is_night', name: 'Nacht', type: 'boolean', role: 'indicator', get: s => !!s.isNight },
{ id: 'prec_prob', name: 'Regenrisiko', type: 'number', role: 'value.precipitation.probability', unit: '%', digits: 0, get: s => s.prec?.probability },
{ id: 'prec_sum', name: 'Niederschlagsmenge', type: 'number', role: 'value.precipitation', unit: 'mm', digits: 2, get: s => s.prec?.sum },
{ id: 'wind_speed', name: 'Windgeschwindigkeit', type: 'number', role: 'value.speed.wind', unit: 'km/h', digits: 1, get: s => s.wind?.avg },
{ id: 'wind_gusts', name: 'Windböen', type: 'number', role: 'value.speed.wind.gust', unit: 'km/h', digits: 1, get: s => s.wind?.gusts },
{ id: 'wind_direction_short', name: 'Windrichtung (kurz)', type: 'string', role: 'weather.direction', get: s => s.wind?.text },
{ id: 'wind_degree', name: 'Windrichtung (Grad)', type: 'number', role: 'value.direction.wind', unit: '°', digits: 0, get: s => s.wind?.degree },
{ id: 'clouds', name: 'Bewölkung', type: 'number', role: 'value.clouds', unit: '%', digits: 1, get: s => wcomCloudsPercent(s.clouds) },
{ id: 'humidity', name: 'Relative Feuchte', type: 'number', role: 'value.humidity', unit: '%', digits: 0, get: s => s.relativeHumidity },
{ id: 'sun_hours', name: 'Sonnenstunden', type: 'number', role: 'value.sun', unit: 'h', digits: 1, get: s => s.sunHours },
{ id: 'pressure', name: 'Luftdruck', type: 'number', role: 'value.pressure', unit: 'hPa', digits: 0, group: 'extended', get: s => s.pressure },
{ id: 'windchill_min', name: 'Gefühlt min.', type: 'number', role: 'value.temperature.min', unit: '°C', digits: 1, group: 'extended', get: s => s.windchill?.min },
{ id: 'windchill_max', name: 'Gefühlt max.', type: 'number', role: 'value.temperature.max', unit: '°C', digits: 1, group: 'extended', get: s => s.windchill?.max },
{ id: 'snowline_min', name: 'Schneefallgrenze min.', type: 'number', role: 'value', unit: 'm', digits: 0, group: 'snow', get: s => s.snowLine?.min },
{ id: 'snowline_max', name: 'Schneefallgrenze max.', type: 'number', role: 'value', unit: 'm', digits: 0, group: 'snow', get: s => s.snowLine?.max },
{ id: 'fresh_snow_max', name: 'Neuschnee max.', type: 'number', role: 'value', unit: 'cm', digits: 1, group: 'snow', get: s => s.prec?.freshSnowRangeMax },
{ id: 'warn_active', name: 'Warnung aktiv', type: 'boolean', role: 'indicator.alarm', group: 'warn', get: s => !!s.highestWarning },
{ id: 'warn_text', name: 'Warnung Text', type: 'string', role: 'text', group: 'warn', get: s => s.highestWarning?.text },
{ id: 'warn_severity_int', name: 'Warnung Schweregrad (0-4)', type: 'number', role: 'value.severity', digits: 0, group: 'warn', get: s => s.highestWarning?.severityInt }
];
const HOUR_FIELDS: FieldDef[] = [
{ id: 'time', name: 'Uhrzeit (Ortszeit)', type: 'string', role: 'text', get: h => h.__time },
{ id: 'from', name: 'Zeitstempel (UTC)', type: 'string', role: 'date', get: h => h.from || h.dateWithTimezone || '' },
{ id: 'valid', name: 'Wert aktuell', type: 'boolean', role: 'indicator', get: () => true },
{ id: 'temp', name: 'Temperatur', type: 'number', role: 'value.temperature', unit: '°C', digits: 1, get: h => h.temperature },
{ id: 'windchill', name: 'Gefühlt', type: 'number', role: 'value.temperature', unit: '°C', digits: 1, get: h => h.windchill },
{ id: 'weather_state', name: 'Wetter-Code', type: 'number', role: 'value', digits: 0, get: h => h.weather?.state },
{ id: 'weather_text', name: 'Wetter', type: 'string', role: 'weather.state', get: h => h.weather?.text },
{ id: 'weather_icon', name: 'Icon URL', type: 'string', role: 'weather.icon', get: h => wcomIconUrl(h.weather, !!h.isNight) },
{ id: 'is_night', name: 'Nacht', type: 'boolean', role: 'indicator', get: h => !!h.isNight },
{ id: 'prec_prob', name: 'Regenwahrscheinlichkeit', type: 'number', role: 'value.precipitation.probability', unit: '%', digits: 0, get: h => h.prec?.probability },
{ id: 'prec_sum', name: 'Niederschlagsmenge', type: 'number', role: 'value.precipitation', unit: 'mm', digits: 2, get: h => h.prec?.sum },
{ id: 'wind_speed', name: 'Windgeschwindigkeit', type: 'number', role: 'value.speed.wind', unit: 'km/h', digits: 1, get: h => h.wind?.avg },
{ id: 'wind_gusts', name: 'Windböen', type: 'number', role: 'value.speed.wind.gust', unit: 'km/h', digits: 1, get: h => h.wind?.gusts },
// ACHTUNG: 'wind_dir' bleibt bewusst die Langform ("Westwind") wie in v2.7.0 -
// bestehende VIS-Bindings brechen dadurch nicht. Die Kurzform kommt zusaetzlich dazu.
{ id: 'wind_dir', name: 'Windrichtung', type: 'string', role: 'weather.direction', get: h => h.wind?.direction },
{ id: 'wind_dir_short', name: 'Windrichtung (kurz)', type: 'string', role: 'weather.direction', get: h => h.wind?.text },
{ id: 'wind_degree', name: 'Windrichtung (Grad)', type: 'number', role: 'value.direction.wind', unit: '°', digits: 0, get: h => h.wind?.degree },
{ id: 'humidity', name: 'Relative Feuchte', type: 'number', role: 'value.humidity', unit: '%', digits: 0, get: h => h.relativeHumidity },
{ id: 'clouds', name: 'Bewölkung', type: 'number', role: 'value.clouds', unit: '%', digits: 1, get: h => wcomCloudsPercent(h.clouds) },
{ id: 'sun_hours', name: 'Sonnenstunden', type: 'number', role: 'value.sun', unit: 'h', digits: 2, get: h => h.sunHours },
{ id: 'pressure', name: 'Luftdruck', type: 'number', role: 'value.pressure', unit: 'hPa', digits: 0, group: 'extended', get: h => h.pressure },
{ id: 'dewpoint', name: 'Taupunkt', type: 'number', role: 'value.temperature.dewpoint', unit: '°C', digits: 1, group: 'extended', get: h => h.dewpoint },
{ id: 'fresh_snow', name: 'Neuschnee', type: 'number', role: 'value', unit: 'cm', digits: 1, group: 'snow', get: h => h.freshSnow },
{ id: 'snowline', name: 'Schneefallgrenze', type: 'number', role: 'value', unit: 'm', digits: 0, group: 'snow', get: h => h.prec?.snowLine },
{ id: 'warn_active', name: 'Warnung aktiv', type: 'boolean', role: 'indicator.alarm', group: 'warn', get: h => !!h.highestWarning },
{ id: 'warn_text', name: 'Warnung Text', type: 'string', role: 'text', group: 'warn', get: h => h.highestWarning?.text },
{ id: 'warn_severity_int', name: 'Warnung Schweregrad (0-4)', type: 'number', role: 'value.severity', digits: 0, group: 'warn', get: h => h.highestWarning?.severityInt }
];
// =====================================================================================
// OBJEKTBAUM
// =====================================================================================
/** Legt Ordner (device/channel) an. Nutzt den RAM-Cache, um I/O zu vermeiden. */
async function wcomEnsureSubStructure(path: string, name: string, type: 'device' | 'channel' = 'channel'): Promise<void> {
if (!path || ensuredPaths.has(path)) return;
if (!existsObject(path)) {
await extendObjectAsync(path, { type: type, common: { name: name }, native: {} });
}
ensuredPaths.add(path);
}
/** Legt einen Datenpunkt an. Nutzt den RAM-Cache, um I/O zu vermeiden. */
async function wcomEnsureState(path: string, init: any, type: iobJS.CommonType, name: string, role: string, unit?: string, writeable: boolean = false): Promise<void> {
if (ensuredPaths.has(path)) return;
if (!existsObject(path)) {
await createStateAsync(path, init, false, { name, type, role, unit: unit || '', read: true, write: writeable } as any);
}
ensuredPaths.add(path);
}
function wcomInitValue(type: iobJS.CommonType): any {
return type === 'number' ? 0 : (type === 'boolean' ? false : '');
}
function wcomCoerce(raw: any, f: FieldDef): any {
if (f.type === 'number') return wcomRound(wcomExtractValue(raw), f.digits === undefined ? 1 : f.digits);
if (f.type === 'boolean') return !!raw;
return raw === null || raw === undefined ? '' : String(raw);
}
/**
* Legt alle Datenpunkte einer Feldtabelle an und puffert die Schreibvorgaenge.
* Ein einziger Ort fuer Anlegen + Schreiben - dadurch koennen Definition und
* Schreibvorgang nicht mehr auseinanderlaufen.
*/
async function wcomRenderFields(basePath: string, labelPrefix: string, fields: FieldDef[], src: any, ctx: Ctx, buffer: Promise<any>[]): Promise<void> {
const active = fields.filter(f => wcomGroupEnabled(f.group));
await Promise.all(active.map(f =>
wcomEnsureState(`${basePath}.${f.id}`, wcomInitValue(f.type), f.type, `${labelPrefix}${f.name}`, f.role, f.unit)
));
for (const f of active) {
let raw: any;
try { raw = f.get(src, ctx); }
catch (e) { raw = null; wcomLog(`Feld '${f.id}' konnte nicht gelesen werden: ${e}`, 'debug'); }
buffer.push(setStateChangedAsync(`${basePath}.${f.id}`, wcomCoerce(raw, f), true));
}
}
// =====================================================================================
// SYSTEM / STANDORT
// =====================================================================================
/** Liest Koordinaten und Sprache aus den ioBroker-Systemeinstellungen. */
async function wcomGetSystemSettings(): Promise<SystemConfig | null> {
if (CONFIG.LOCATION.FORCE_MANUAL) {
const lat = parseFloat(CONFIG.LOCATION.LAT);
const lon = parseFloat(CONFIG.LOCATION.LON);
if (isNaN(lat) || isNaN(lon)) {
wcomLog('FORCE_MANUAL aktiv, aber die manuellen Koordinaten sind ungültig.', 'error');
return null;
}
const sysLang = await wcomGetSystemLanguage();
return { lat: lat.toFixed(3), lon: lon.toFixed(3), lang: sysLang };
}
const obj: any = await getObjectAsync('system.config');
const common = obj && obj.common ? obj.common : null;
if (!common || common.latitude === undefined || common.latitude === null || common.longitude === undefined || common.longitude === null) {
wcomLog('Kein Standort in den ioBroker-Systemeinstellungen hinterlegt (Einstellungen -> System -> Standort).', 'error');
return null;
}
const lat = parseFloat(String(common.latitude));
const lon = parseFloat(String(common.longitude));
if (isNaN(lat) || isNaN(lon)) {
wcomLog('Standort in system.config ist nicht numerisch.', 'error');
return null;
}
return { lat: lat.toFixed(3), lon: lon.toFixed(3), lang: common.language || CONFIG.DEFAULT_LANGUAGE };
}
async function wcomGetSystemLanguage(): Promise<string> {
try {
const obj: any = await getObjectAsync('system.config');
return (obj && obj.common && obj.common.language) || CONFIG.DEFAULT_LANGUAGE;
} catch (e) { return CONFIG.DEFAULT_LANGUAGE; }
}
// =====================================================================================
// HTTP
// =====================================================================================
/** HTTP GET mit hartem Timeout - verhindert einen Deadlock im isFetching-Lock. */
async function wcomHttpGetAsync(url: string, options: any): Promise<any> {
let timeoutId: any = null;
const fetchPromise = new Promise((resolve, reject) => {
httpGet(url, options, (err: any, response: any) => {
if (err) reject(err); else resolve(response);
});
});
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`HTTP Timeout nach ${CONFIG.HTTP_TIMEOUT_MS / 1000} s`)), CONFIG.HTTP_TIMEOUT_MS + 2000);
});
try {
return await Promise.race([fetchPromise, timeoutPromise]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
// =====================================================================================
// BUDGET & ZAEHLER
// =====================================================================================
function wcomMonthKey(d: Date): string { return `${d.getFullYear()}-${wcomPad(d.getMonth() + 1)}`; }
function wcomDayKey(d: Date): string { return `${d.getFullYear()}-${wcomPad(d.getMonth() + 1)}-${wcomPad(d.getDate())}`; }
async function wcomGetNum(id: string, fallback = 0): Promise<number> {
const s = await getStateAsync(id);
return s && s.val !== null && s.val !== undefined ? Number(s.val) : fallback;
}
/**
* Setzt Monats-/Tageszaehler anhand von Perioden-Markern zurueck.
* Robuster als ein reiner Cron: laeuft ioBroker zum Umschaltzeitpunkt nicht,
* wird beim naechsten Aufruf trotzdem korrekt zurueckgesetzt.
*/
async function wcomRollPeriods(): Promise<void> {
const now = new Date();
const monthKey = wcomMonthKey(now);
const storedMonth = await getStateAsync(`${CONFIG.DP_PATH}.info.requests_month_period`);
const storedMonthVal = storedMonth && storedMonth.val ? String(storedMonth.val) : '';
if (storedMonthVal !== monthKey) {
if (storedMonthVal) wcomLog(`Monatswechsel erkannt (${storedMonthVal} -> ${monthKey}). Monatszähler zurückgesetzt.`, 'info');
await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month`, 0, true);
await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month_period`, monthKey, true);
}
const dayKey = wcomDayKey(now);
const storedDay = await getStateAsync(`${CONFIG.DP_PATH}.info.requests_today_date`);
const storedDayVal = storedDay && storedDay.val ? String(storedDay.val) : '';
if (storedDayVal !== dayKey) {
await setStateAsync(`${CONFIG.DP_PATH}.info.requests_today`, 0, true);
await setStateAsync(`${CONFIG.DP_PATH}.info.requests_today_date`, dayKey, true);
}
}
/** Hoechste konfigurierte Prioritaetsstufe = Abrufe pro Tag im Vollbetrieb. */
function wcomMaxTier(): number {
return CONFIG.UPDATES.reduce((m, u) => Math.max(m, u.tier), 1);
}
/**
* Prueft Monatsbudget, Cooldown und die gestaffelte Sparlogik.
*
* @param source Ursprung des Abrufs.
* @param tier Prioritaetsstufe (nur bei automatischen Abrufen relevant).
* Ein Abruf der Stufe N laeuft nur, wenn das Restbudget noch
* N Abrufe pro Tag bis Monatsende traegt.
* @param label Klartextname fuer die Logausgabe.
*/
async function wcomCheckBudget(source: FetchSource, tier: number = 0, label: string = ''): Promise<boolean> {
await wcomRollPeriods();
const currentUsage = await wcomGetNum(`${CONFIG.DP_PATH}.info.requests_month`, 0);
if (currentUsage >= CONFIG.MONTHLY_LIMIT) {
wcomLog(`Monatslimit erreicht (${currentUsage}/${CONFIG.MONTHLY_LIMIT}). Pausiert bis zum 01. des Folgemonats.`, 'warn');
await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'Monatslimit erreicht', true);
return false;
}
const automatic = (source === 'scheduled' || source === 'start');
if (!automatic) return true; // manuelle Aktionen: nur das harte Limit gilt
// Cooldown schuetzt vor Skript-Neustart-Schleifen.
const lastTs = await wcomGetNum(`${CONFIG.DP_PATH}.info.last_sync_ts`, 0);
if (lastTs > 0) {
const diffHours = (Date.now() - lastTs) / 3600000;
if (diffHours < CONFIG.MIN_HOURS_BETWEEN_UPDATES) {
wcomLog(`Abruf übersprungen (Cooldown). Letzter Abruf vor ${diffHours.toFixed(1)} h, Minimum ${CONFIG.MIN_HOURS_BETWEEN_UPDATES} h.`, 'debug');
return false;
}
}
// Ein Skriptstart wird wie der niedrigst priorisierte Abruf behandelt: er darf
// nie die Reserve anknabbern, die die planmaessigen Abrufe am Leben haelt.
const effTier = tier > 0 ? tier : wcomMaxTier();
const now = new Date();
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
const daysLeft = daysInMonth - now.getDate(); // volle Tage nach heute
const needed = effTier * (daysLeft + 1); // effTier Abrufe/Tag inkl. heute
if (currentUsage + needed <= CONFIG.MONTHLY_LIMIT) return true;
if (effTier > 1) {
wcomLog(`SPARMODUS: "${label || source}" übersprungen. Das Restbudget trägt keine ${effTier} Abrufe/Tag mehr bis Monatsende (Verbrauch ${currentUsage}/${CONFIG.MONTHLY_LIMIT}, ${daysLeft + 1} Tage offen).`, 'warn');
return false;
}
// Stufe 1 ist der letzte verbliebene Abruf: statt ganz auszusetzen nur noch
// jeden zweiten Tag - lieber gröber als gar nicht.
if (now.getDate() % 2 !== 0) {
wcomLog(`NOTLAUF: "${label || source}" übersprungen (Budget kritisch, Abruf nur noch an geraden Tagen). Verbrauch ${currentUsage}/${CONFIG.MONTHLY_LIMIT}.`, 'warn');
return false;
}
return true;
}
/**
* Zaehlt eine beantwortete API-Anfrage gegen das Kontingent.
* Bewusst auch bei Fehlerantworten (ausser 401/403) - diese verbrauchen beim
* Anbieter ebenfalls Kontingent. In v2.7.0 wurde nur HTTP 200 gezaehlt, dadurch
* konnte der lokale Zaehler dauerhaft vom echten Verbrauch abweichen.
*/
async function wcomCountRequest(): Promise<void> {
await setStateAsync(`${CONFIG.DP_PATH}.info.requests_today`, (await wcomGetNum(`${CONFIG.DP_PATH}.info.requests_today`)) + 1, true);
const month = (await wcomGetNum(`${CONFIG.DP_PATH}.info.requests_month`)) + 1;
await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month`, month, true);
await setStateAsync(`${CONFIG.DP_PATH}.info.requests_left`, Math.max(0, CONFIG.MONTHLY_LIMIT - month), true);
}
/** Schreibt die Sync-Zeitstempel nach einem erfolgreichen Abruf. */
async function wcomUpdateSyncInfo(): Promise<void> {
const now = new Date();
const ts = `${wcomPad(now.getDate())}.${wcomPad(now.getMonth() + 1)}.${now.getFullYear()} ${wcomPad(now.getHours())}:${wcomPad(now.getMinutes())}`;
await setStateAsync(`${CONFIG.DP_PATH}.info.last_sync`, ts, true);
await setStateAsync(`${CONFIG.DP_PATH}.info.last_sync_ts`, now.getTime(), true);
}
// =====================================================================================
// INITIALISIERUNG DER INFO-DATENPUNKTE
// =====================================================================================
async function wcomEnsureBaseStructure(): Promise<void> {
await wcomEnsureSubStructure(CONFIG.DP_PATH, 'Wetter.com Forecast', 'device');
await wcomEnsureSubStructure(`${CONFIG.DP_PATH}.info`, 'Informationen');
await Promise.all([
wcomEnsureState(`${CONFIG.DP_PATH}.info.last_sync`, '', 'string', 'Letztes Update', 'text'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.last_sync_ts`, 0, 'number', 'Letztes Update (Timestamp)', 'value.time'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_month`, 0, 'number', 'Anfragen Monat', 'value'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_month_period`, '', 'string', 'Abrechnungsmonat', 'text'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_today`, 0, 'number', 'Anfragen heute', 'value'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_today_date`, '', 'string', 'Zähltag', 'text'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.requests_left`, CONFIG.MONTHLY_LIMIT, 'number', 'Verbleibende Anfragen', 'value'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.status`, '', 'string', 'Status', 'text'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.last_error`, '', 'string', 'Letzter Fehler', 'text'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.forecast_date`, '', 'string', 'Vorhersage erstellt (API)', 'date'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.next_update`, '', 'string', 'Nächstes API-Update', 'date'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.location`, '', 'string', 'Verwendeter Standort', 'text'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.timezone`, '', 'string', 'Zeitzone des Standorts', 'text'),
wcomEnsureState(`${CONFIG.DP_PATH}.info.elevation`, 0, 'number', 'Höhe über NN', 'value', 'm'),
// Benutzereingaben
wcomEnsureState(CONFIG.DP_API_KEY, '', 'string', 'Wetter.com API Key', 'text', '', true),
wcomEnsureState(CONFIG.DP_FORECAST_DAYS, 7, 'number', 'Vorhersage Tage', 'value', '', true),
wcomEnsureState(CONFIG.DP_FORCE_RESET, false, 'boolean', 'Monatszähler zurücksetzen', 'button', '', true),
wcomEnsureState(CONFIG.DP_FORCE_UPDATE, false, 'boolean', 'Jetzt aktualisieren', 'button', '', true)
]);
if (CONFIG.ENABLE_JSON) {
await Promise.all([
wcomEnsureState(`${CONFIG.DP_PATH}.forecast_json`, '[]', 'string', 'Vorhersage (JSON)', 'json'),
wcomEnsureState(`${CONFIG.DP_PATH}.hourly_json`, '[]', 'string', 'Stundenwerte (JSON)', 'json')
]);
}
}
// =====================================================================================
// HAUPTLOGIK
// =====================================================================================
async function wcomFetchWeatherData(source: FetchSource = 'start', tier: number = 0, label: string = ''): Promise<void> {
if (isFetching) {
wcomLog('Abruf läuft bereits (Lock aktiv). Abbruch.', 'debug');
return;
}
isFetching = true;
try {
await wcomEnsureBaseStructure();
// Schreibrechte auf den Eingabe-Datenpunkten sicherstellen (Migration alter Installationen)
if (source === 'start') {
await extendObjectAsync(CONFIG.DP_API_KEY, { common: { write: true } });
await extendObjectAsync(CONFIG.DP_FORECAST_DAYS, { common: { write: true } });
}
const apiKeyState = await getStateAsync(CONFIG.DP_API_KEY);
const apiKey = apiKeyState && apiKeyState.val ? String(apiKeyState.val).trim() : '';
if (apiKey.length < 10) {
wcomLog(`Bitte einen gültigen API-Key in '${CONFIG.DP_API_KEY}' eintragen.`, 'error');
await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'Kein API-Key', true);
return;
}
if (authFailed && (source === 'start' || source === 'scheduled')) {
wcomLog('Abruf übersprungen: der API-Key wurde zuletzt abgelehnt. Bitte Key korrigieren.', 'warn');
return;
}
const forecastDays = await wcomGetForecastDays();
if (!(await wcomCheckBudget(source, tier, label))) return;
const settings = await wcomGetSystemSettings();
if (!settings) {
await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'Kein Standort', true);
return;
}
const url = `${CONFIG.BASE_URL}/forecast/${settings.lat}/${settings.lon}`;
const options = {
timeout: CONFIG.HTTP_TIMEOUT_MS,
headers: { 'x-api-key': apiKey, 'Accept-Language': settings.lang }
};
wcomLog(`Abruf gestartet (${label || source}) für ${settings.lat}/${settings.lon}, ${forecastDays} Tage.`, 'info');
let data: any = null;
// Wiederholungsversuche duerfen die Notreserve nicht aufbrauchen.
const mayRetry = async () => (await wcomGetNum(`${CONFIG.DP_PATH}.info.requests_left`, CONFIG.MONTHLY_LIMIT)) > CONFIG.RESERVE_CALLS;
for (let attempt = 0; attempt <= CONFIG.MAX_RETRIES; attempt++) {
let response: any = null;
let netError: any = null;
try { response = await wcomHttpGetAsync(url, options); }
catch (e) { netError = e; }
if (netError) {
const retry = attempt < CONFIG.MAX_RETRIES && await mayRetry();
wcomLog(`Netzwerkfehler: ${netError.message || netError}`, retry ? 'warn' : 'error');
await setStateAsync(`${CONFIG.DP_PATH}.info.last_error`, String(netError.message || netError), true);
if (retry) { await wcomSleep(CONFIG.RETRY_DELAY_MS); continue; }
await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'Netzwerkfehler', true);
return;
}
const code = response ? Number(response.statusCode) : 0;
// 401/403 werden vom Gateway vor der Verrechnung abgewiesen und daher nicht gezaehlt.
if (code !== 401 && code !== 403) await wcomCountRequest();
if (code === 429) {
wcomLog('API-Kontingent erschöpft (HTTP 429). Zähler wird auf das Limit gesetzt.', 'error');
await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month`, CONFIG.MONTHLY_LIMIT, true);
await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'HTTP 429 - Limit erschöpft', true);
return;
}
if (code === 401 || code === 403) {
// Sperre setzen, damit wiederholte Skript-Neustarts nicht dauerhaft
// gegen die API laufen. Wird beim Ändern des API-Keys aufgehoben.
authFailed = true;
wcomLog(`API-Key abgelehnt (HTTP ${code}). Automatische Abrufe pausiert, bis der Key geändert wird.`, 'error');
await setStateAsync(`${CONFIG.DP_PATH}.info.status`, `HTTP ${code} - Key ungültig`, true);
return;
}
if (code >= 500) {
const retry = attempt < CONFIG.MAX_RETRIES && await mayRetry();
wcomLog(`Serverfehler HTTP ${code}.`, retry ? 'warn' : 'error');
if (retry) { await wcomSleep(CONFIG.RETRY_DELAY_MS); continue; }
await setStateAsync(`${CONFIG.DP_PATH}.info.status`, `HTTP ${code}`, true);
return;
}
if (code !== 200) {
wcomLog(`Unerwarteter Statuscode HTTP ${code}.`, 'error');
await setStateAsync(`${CONFIG.DP_PATH}.info.status`, `HTTP ${code}`, true);
return;
}
try {
data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
} catch (e) {
wcomLog('API-Antwort konnte nicht als JSON gelesen werden.', 'error');
await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'JSON-Parsefehler', true);
return;
}
break;
}
if (!data || !Array.isArray(data.summary) || data.summary.length === 0) {
wcomLog('API-Antwort enthält keine Vorhersagedaten.', 'error');
await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'Leere Antwort', true);
return;
}
lastPayload = data;
lastPayloadTs = Date.now();
authFailed = false;
await wcomUpdateSyncInfo();
await wcomProcessForecastData(data, settings.lang, forecastDays);
await setStateAsync(`${CONFIG.DP_PATH}.info.status`, 'OK', true);
await setStateAsync(`${CONFIG.DP_PATH}.info.last_error`, '', true);
} catch (e: any) {
wcomLog(`Script-Fehler: ${e && e.message ? e.message : e}`, 'error');
try { await setStateAsync(`${CONFIG.DP_PATH}.info.last_error`, String(e && e.message ? e.message : e), true); } catch (ignore) { /* noop */ }
} finally {
isFetching = false;
}
}
async function wcomGetForecastDays(): Promise<number> {
const raw = await wcomGetNum(CONFIG.DP_FORECAST_DAYS, 7);
return Math.max(1, Math.min(Math.round(raw) || 7, CONFIG.MAX_FORECAST_DAYS));
}
// =====================================================================================
// VERARBEITUNG
// =====================================================================================
async function wcomProcessForecastData(data: any, lang: string, forecastDays: number): Promise<void> {
const tz = (data.location && data.location.timezone) ? String(data.location.timezone) : Intl.DateTimeFormat().resolvedOptions().timeZone;
const ctx: Ctx = { lang: lang, tz: tz };
await wcomEnsureSubStructure(CONFIG.DP_PATH, 'Wetter.com Forecast', 'device');
// Metadaten aus der Antwort
await Promise.all([
setStateChangedAsync(`${CONFIG.DP_PATH}.info.forecast_date`, String(data.forecastDate || ''), true),
setStateChangedAsync(`${CONFIG.DP_PATH}.info.next_update`, String(data.nextUpdate || ''), true),
setStateChangedAsync(`${CONFIG.DP_PATH}.info.timezone`, tz, true),
setStateChangedAsync(`${CONFIG.DP_PATH}.info.location`,
data.location && data.location.coordinates
? `${data.location.coordinates.latitude}, ${data.location.coordinates.longitude}` : '', true),
setStateChangedAsync(`${CONFIG.DP_PATH}.info.elevation`,
wcomExtractValue(data.location && data.location.coordinates ? data.location.coordinates.elevation : 0), true)
]);
const summary: any[] = Array.isArray(data.summary) ? data.summary : [];
const maxDays = Math.min(summary.length, forecastDays);
// Tagesabschnitte ueber das Datum zuordnen statt ueber den Array-Index.
const spacesByDate = new Map<string, any>();
if (Array.isArray(data.spaces)) {
for (const s of data.spaces) if (s && s.date) spacesByDate.set(String(s.date), s);
}
// Stundenwerte nach Ortsdatum gruppieren.
const hourlyByDate = new Map<string, any[]>();
if (Array.isArray(data.hourly)) {
for (const h of data.hourly) {
const parts = wcomLocalParts(h.from || h.dateWithTimezone || h.date, tz);
const key = parts ? parts.date : String(h.date || '');
if (!key) continue;
h.__hour = parts ? parts.hour : '00';
h.__time = `${h.__hour}:00`;
if (!hourlyByDate.has(key)) hourlyByDate.set(key, []);
hourlyByDate.get(key)!.push(h);
}
}
const jsonDays: any[] = [];
const jsonHours: any[] = [];
let totalWrites = 0;
for (let i = 0; i < maxDays; i++) {
const day = summary[i];
if (!day) continue;
const dayKey = String(day.date || '');
const dayPath = `${CONFIG.DP_PATH}.day_${i}`;
const buffer: Promise<any>[] = [];
await wcomEnsureSubStructure(dayPath, `Tag ${i}`);
await wcomRenderFields(dayPath, `Tag ${i}: `, DAY_FIELDS, day, ctx, buffer);
// --- Astronomie ---
if (CONFIG.ENABLE_ASTRO && day.astronomy) {
const astroPath = `${dayPath}.astro`;
await wcomEnsureSubStructure(astroPath, 'Sonne & Mond');
await wcomRenderFields(astroPath, `Tag ${i}: `, ASTRO_FIELDS, day.astronomy, ctx, buffer);
}
// --- Tagesabschnitte ---
if (CONFIG.ENABLE_SPACES) {
const spaceDay = spacesByDate.get(dayKey);
if (spaceDay) {
const spacesPath = `${dayPath}.spaces`;
await wcomEnsureSubStructure(spacesPath, 'Tagesabschnitte');
const labels: Record<string, string> = { morning: 'Morgen', afternoon: 'Mittag', evening: 'Abend', night: 'Nacht' };
for (const seg of ['morning', 'afternoon', 'evening', 'night']) {
const sData = spaceDay[seg];
if (!sData) continue;
const sPath = `${spacesPath}.${seg}`;
await wcomEnsureSubStructure(sPath, labels[seg]);
await wcomRenderFields(sPath, `Tag ${i} ${labels[seg]}: `, SPACE_FIELDS, sData, ctx, buffer);
}
} else {
wcomLog(`Keine Tagesabschnitte für ${dayKey} in der API-Antwort.`, 'debug');
}
}
// --- Stundenwerte ---
if (CONFIG.ENABLE_HOURLY && i < CONFIG.HOURLY_DAYS) {
const hourlyPath = `${dayPath}.hourly`;
await wcomEnsureSubStructure(hourlyPath, 'Stündlich');
const hours = hourlyByDate.get(dayKey) || [];
const delivered = new Set<string>();
for (const h of hours) {
const hPath = `${hourlyPath}.${h.__hour}`;
delivered.add(h.__hour);
await wcomEnsureSubStructure(hPath, `${h.__hour}:00 Uhr`);
await wcomRenderFields(hPath, `Tag ${i} ${h.__hour}:00: `, HOUR_FIELDS, h, ctx, buffer);
if (CONFIG.ENABLE_JSON && i === 0) {
jsonHours.push({
date: dayKey, time: h.__time, from: h.from || '',
temp: wcomRound(wcomExtractValue(h.temperature), 1),
icon: wcomIconUrl(h.weather, !!h.isNight),
text: h.weather?.text || '',
precProb: wcomRound(wcomExtractValue(h.prec?.probability), 0),
precSum: wcomRound(wcomExtractValue(h.prec?.sum), 2),
wind: wcomRound(wcomExtractValue(h.wind?.avg), 1),
gusts: wcomRound(wcomExtractValue(h.wind?.gusts), 1),
humidity: wcomRound(wcomExtractValue(h.relativeHumidity), 0)
});
}
}
// Bereits vergangene Stunden liefert die API nicht mehr mit:
// Werte stehen lassen, aber als nicht mehr aktuell markieren.
for (let hh = 0; hh < 24; hh++) {
const label = wcomPad(hh);
if (delivered.has(label)) continue;
const validPath = `${hourlyPath}.${label}.valid`;
if (existsObject(validPath)) buffer.push(setStateChangedAsync(validPath, false, true));
}
}
// --- JSON-Aggregat ---
if (CONFIG.ENABLE_JSON) {
jsonDays.push({
day: i,
date: wcomFormatDate(day.date),
dateIso: dayKey,
dayName: wcomGetDayName(day.date, lang),
tempMin: wcomRound(wcomExtractValue(day.temperature?.min), 1),
tempMax: wcomRound(wcomExtractValue(day.temperature?.max), 1),
text: day.weather?.text || '',
icon: wcomIconUrl(day.weather, false),
state: wcomRound(wcomExtractValue(day.weather?.state), 0),
precProb: wcomRound(wcomExtractValue(day.prec?.probability), 0),
precSum: wcomRound(wcomExtractValue(day.prec?.sum), 2),
wind: wcomRound(wcomExtractValue(day.wind?.avg), 1),
gusts: wcomRound(wcomExtractValue(day.wind?.gusts), 1),
windDir: day.wind?.text || '',
sunHours: wcomRound(wcomExtractValue(day.sunHours), 1),
clouds: wcomRound(wcomCloudsPercent(day.clouds), 0),
humidity: wcomRound(wcomExtractValue(day.relativeHumidity), 0),
pressure: wcomRound(wcomExtractValue(day.pressure), 0),
sunrise: wcomIsoTime(day.astronomy?.sunrise),
sunset: wcomIsoTime(day.astronomy?.sunset),
warnActive: !!day.highestWarning,
warnText: day.highestWarning?.text || '',
warnSeverity: wcomRound(wcomExtractValue(day.highestWarning?.severityInt), 0)
});
}
totalWrites += buffer.length;
await Promise.all(buffer);
}
if (CONFIG.ENABLE_JSON) {
await Promise.all([
setStateChangedAsync(`${CONFIG.DP_PATH}.forecast_json`, JSON.stringify(jsonDays), true),
setStateChangedAsync(`${CONFIG.DP_PATH}.hourly_json`, JSON.stringify(jsonHours), true)
]);
}
await wcomCleanupObsoleteDays(maxDays);
await wcomUpdateCurrent('nach Abruf');
wcomLog(`Update abgeschlossen: ${maxDays} Tage, ${totalWrites} Werte verarbeitet.`, 'info');
}
// =====================================================================================
// CURRENT - Werte der laufenden Stunde
// =====================================================================================
/** Zusatzfelder in 'current', die nicht aus den Stundendaten stammen. */
const CURRENT_EXTRA: { id: string; name: string; type: iobJS.CommonType; role: string; unit?: string; from: string; astro?: boolean }[] = [
{ id: 'date', name: 'Datum', type: 'string', role: 'text', from: 'date' },
{ id: 'day_name', name: 'Wochentag', type: 'string', role: 'text', from: 'day_name' },
{ id: 'temp_min_today', name: 'Min. Temperatur heute', type: 'number', role: 'value.temperature.min', unit: '°C', from: 'temp_min' },
{ id: 'temp_max_today', name: 'Max. Temperatur heute', type: 'number', role: 'value.temperature.max', unit: '°C', from: 'temp_max' },
{ id: 'sunrise', name: 'Sonnenaufgang', type: 'string', role: 'date.sunrise', from: 'astro.sunrise', astro: true },
{ id: 'sunset', name: 'Sonnenuntergang', type: 'string', role: 'date.sunset', from: 'astro.sunset', astro: true }
];
function wcomCurrentFields(): FieldDef[] {
return HOUR_FIELDS.filter(f => wcomGroupEnabled(f.group));
}
async function wcomEnsureCurrentStates(): Promise<void> {
const base = `${CONFIG.DP_PATH}.current`;
// Wurzel mit absichern: 'current' kann vor dem ersten erfolgreichen Abruf angelegt werden.
await wcomEnsureSubStructure(CONFIG.DP_PATH, 'Wetter.com Forecast', 'device');
await wcomEnsureSubStructure(base, 'Aktuelle Stunde');
await Promise.all([
...wcomCurrentFields().map(f =>
wcomEnsureState(`${base}.${f.id}`, wcomInitValue(f.type), f.type, `Jetzt: ${f.name}`, f.role, f.unit)),
...CURRENT_EXTRA.filter(e => !e.astro || CONFIG.ENABLE_ASTRO).map(e =>
wcomEnsureState(`${base}.${e.id}`, wcomInitValue(e.type), e.type, `Jetzt: ${e.name}`, e.role, e.unit)),
wcomEnsureState(`${base}.source`, '', 'string', 'Jetzt: Quell-Datenpunkt', 'text'),
wcomEnsureState(`${base}.updated`, '', 'string', 'Jetzt: zuletzt übernommen', 'text')
]);
}
/** Kopiert einen Datenpunktwert, sofern die Quelle existiert. */
async function wcomCopyState(from: string, to: string, buffer: Promise<any>[]): Promise<void> {
if (!existsObject(from)) return;
const s = await getStateAsync(from);
if (!s || s.val === null || s.val === undefined) return;
buffer.push(setStateChangedAsync(to, s.val as any, true));
}
/**
* Fuellt '<DP_PATH>.current' mit den Werten der laufenden Stunde.
*
* Quelle sind bewusst die bereits geschriebenen Stunden-Datenpunkte und nicht der
* RAM-Cache: so liefert 'current' auch direkt nach einem Skript-Neustart wieder
* Werte, ohne dass dafuer ein API-Call faellig wird.
*
* Die Tageszuordnung laeuft ueber 'date_iso' statt ueber einen festen Index -
* zwischen 00:00 und dem 01:10-Abruf steht der heutige Tag noch in day_1.
*/
async function wcomUpdateCurrent(reason: string = 'stündlich'): Promise<void> {
if (!CONFIG.ENABLE_CURRENT) return;
if (!CONFIG.ENABLE_HOURLY || CONFIG.HOURLY_DAYS < 1) {
wcomLog("ENABLE_CURRENT benötigt ENABLE_HOURLY mit HOURLY_DAYS >= 1. 'current' bleibt leer.", 'warn');
return;
}
try {
await wcomEnsureCurrentStates();
const base = `${CONFIG.DP_PATH}.current`;
const tzState = await getStateAsync(`${CONFIG.DP_PATH}.info.timezone`);
const tz = tzState && tzState.val ? String(tzState.val) : Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = wcomLocalParts(new Date().toISOString(), tz);
if (!now) return;
// Tagesordner suchen, dessen Datum dem heutigen Ortsdatum entspricht
let dayIdx = -1;
for (let i = 0; i < CONFIG.HOURLY_DAYS; i++) {
const st = await getStateAsync(`${CONFIG.DP_PATH}.day_${i}.date_iso`);
if (st && String(st.val) === now.date) { dayIdx = i; break; }
}
const buffer: Promise<any>[] = [];
const srcHour = dayIdx >= 0 ? `${CONFIG.DP_PATH}.day_${dayIdx}.hourly.${now.hour}` : '';
if (!srcHour || !existsObject(`${srcHour}.time`)) {
buffer.push(setStateChangedAsync(`${base}.valid`, false, true));
await Promise.all(buffer);
wcomLog(`current: keine Stundendaten für ${now.date} ${now.hour}:00 vorhanden.`, 'debug');
return;
}
const srcDay = `${CONFIG.DP_PATH}.day_${dayIdx}`;
for (const f of wcomCurrentFields()) {
await wcomCopyState(`${srcHour}.${f.id}`, `${base}.${f.id}`, buffer);
}
for (const e of CURRENT_EXTRA) {
if (e.astro && !CONFIG.ENABLE_ASTRO) continue;
await wcomCopyState(`${srcDay}.${e.from}`, `${base}.${e.id}`, buffer);
}
const ts = new Date();
buffer.push(
setStateChangedAsync(`${base}.source`, srcHour, true),
setStateAsync(`${base}.updated`,
`${wcomPad(ts.getDate())}.${wcomPad(ts.getMonth() + 1)}.${ts.getFullYear()} ${wcomPad(ts.getHours())}:${wcomPad(ts.getMinutes())}`, true)
);
await Promise.all(buffer);
wcomLog(`current aktualisiert (${reason}): ${now.date} ${now.hour}:00 aus day_${dayIdx}.`, 'debug');
} catch (e: any) {
wcomLog(`current konnte nicht aktualisiert werden: ${e && e.message ? e.message : e}`, 'error');
}
}
/** Entfernt Tagesordner, die durch eine reduzierte Vorhersagedauer obsolet sind. */
async function wcomCleanupObsoleteDays(activeDays: number): Promise<void> {
for (let i = activeDays; i <= CONFIG.CLEANUP_UP_TO_DAY; i++) {
const path = `${CONFIG.DP_PATH}.day_${i}`;
if (existsObject(path)) {
await deleteObjectAsync(path, true);
// Cache-Eintraege des geloeschten Teilbaums verwerfen
for (const p of Array.from(ensuredPaths)) if (p === path || p.indexOf(`${path}.`) === 0) ensuredPaths.delete(p);
wcomLog(`Obsoleten Tagesordner entfernt: day_${i}`, 'debug');
}
}
}
/** Rendert die zuletzt geholte Antwort neu - ohne API-Call. */
async function wcomRerenderFromCache(reason: string): Promise<boolean> {
if (!lastPayload) return false;
if (isFetching) return false;
isFetching = true;
try {
const settings = await wcomGetSystemSettings();
const lang = settings ? settings.lang : CONFIG.DEFAULT_LANGUAGE;
const days = await wcomGetForecastDays();
wcomLog(`${reason}: Neuaufbau aus dem Cache (Stand ${new Date(lastPayloadTs).toLocaleString()}) - kein API-Call nötig.`, 'info');
await wcomProcessForecastData(lastPayload, lang, days);
return true;
} catch (e: any) {
wcomLog(`Neuaufbau aus dem Cache fehlgeschlagen: ${e && e.message ? e.message : e}`, 'error');
return false;
} finally {
isFetching = false;
}
}
// =====================================================================================
// SELBSTPRUEFUNG DER ZEITPLANUNG
// =====================================================================================
/** Liest "M H * * *" und liefert die Minuten seit Mitternacht, sonst null. */
function wcomCronMinutes(cron: string): number | null {
const p = String(cron).trim().split(/\s+/);
if (p.length < 5) return null;
const m = Number(p[0]); const h = Number(p[1]);
if (!Number.isInteger(m) || !Number.isInteger(h)) return null;
return h * 60 + m;
}
/**
* Prueft beim Start zwei Dinge, die sich sonst nur als "es kommen keine Daten"
* bemerkbar machen:
* 1. Ist der Cooldown kleiner als der kuerzeste Abstand zwischen zwei Abrufen?
* (Genau das liess in v2.7.0 den Nachmittags-Abruf dauerhaft ausfallen.)
* 2. Passt die gewaehlte Taktung ueberhaupt in das Monatsbudget?
*/
function wcomValidateSchedule(): void {
const hhmm = (min: number) => `${wcomPad(Math.floor(min / 60))}:${wcomPad(min % 60)}`;
const times = CONFIG.UPDATES
.map(u => ({ min: wcomCronMinutes(u.cron), u }))
.filter(x => x.min !== null) as { min: number; u: typeof CONFIG.UPDATES[0] }[];
if (times.length === 0) { wcomLog('Keine gültigen Abrufzeiten konfiguriert.', 'error'); return; }
times.sort((x, y) => x.min - y.min);
const plan = times.map(t => `${hhmm(t.min)} (${t.u.name}, Stufe ${t.u.tier})`).join(' | ');
// Kleinster Abstand im 24-h-Kreis
let minGap = 24 * 60;
for (let i = 0; i < times.length; i++) {
const next = times[(i + 1) % times.length];
const raw = next.min - times[i].min;
minGap = Math.min(minGap, raw > 0 ? raw : raw + 1440);
}
const gapHours = times.length > 1 ? minGap / 60 : 24;
wcomLog(`Zeitplan: ${plan}`, 'info');
if (CONFIG.MIN_HOURS_BETWEEN_UPDATES >= gapHours) {
wcomLog(`KONFIGURATIONSFEHLER: Cooldown (${CONFIG.MIN_HOURS_BETWEEN_UPDATES} h) ist größer/gleich dem kürzesten Abstand der Abrufzeiten (${gapHours.toFixed(1)} h). Mindestens ein Abruf wird IMMER übersprungen. Bitte MIN_HOURS_BETWEEN_UPDATES verkleinern.`, 'error');
} else {
wcomLog(`Kürzester Abstand ${gapHours.toFixed(1)} h, Cooldown ${CONFIG.MIN_HOURS_BETWEEN_UPDATES} h - passt.`, 'debug');
}
// Budget-Plausibilitaet fuer den laengsten Monat
const perDay = wcomMaxTier();
const worstCase = perDay * 31;
if (worstCase > CONFIG.MONTHLY_LIMIT) {
wcomLog(`BUDGET-WARNUNG: ${perDay} Abrufe/Tag ergeben in einem 31-Tage-Monat ${worstCase} Calls, das Limit liegt bei ${CONFIG.MONTHLY_LIMIT}. Die Sparstaffelung greift dann gegen Monatsende automatisch.`, 'warn');
} else {
const spare = CONFIG.MONTHLY_LIMIT - worstCase;
wcomLog(`Budget: ${perDay} Abrufe/Tag = max. ${worstCase} Calls/Monat von ${CONFIG.MONTHLY_LIMIT} (${spare} Reserve).`, 'info');
}
}
// =====================================================================================
// ZEITSTEUERUNG & TRIGGER
// =====================================================================================
for (const upd of CONFIG.UPDATES) {
schedule(upd.cron, () => wcomFetchWeatherData('scheduled', upd.tier, upd.name));
}
// Perioden-Reset zusaetzlich taeglich anstossen, damit die Zaehler in der
// Oberflaeche zeitnah umspringen. Der eigentliche Reset ist lazy abgesichert.
schedule('1 0 * * *', () => wcomRollPeriods().catch(e => wcomLog(String(e), 'error')));
// 'current' stuendlich nachziehen - reine Umschichtung vorhandener Datenpunkte, kein API-Call.
if (CONFIG.ENABLE_CURRENT) {
schedule(CONFIG.CURRENT_CRON, () => wcomUpdateCurrent('stündlich'));
}
on({ id: CONFIG.DP_API_KEY, change: 'ne' }, (obj) => {
if (obj.state && typeof obj.state.val === 'string' && obj.state.val.trim().length >= 10) {
authFailed = false; // neue Chance nach einem zuvor abgelehnten Key
wcomLog('API-Key geändert. Starte Test-Abruf.', 'info');
wcomFetchWeatherData('key_update');
}
});
on({ id: CONFIG.DP_FORECAST_DAYS, change: 'ne' }, (obj) => {
if (!obj.state || obj.state.val === null) return;
// Entprellen: schnelle Aenderungen (Slider in VIS) zu einem Vorgang zusammenfassen.
if (daysDebounce) clearTimeout(daysDebounce);
daysDebounce = setTimeout(async () => {
daysDebounce = null;
wcomLog(`Vorhersage-Tage geändert auf ${obj.state.val}.`, 'info');
const done = await wcomRerenderFromCache('Änderung der Vorhersagedauer');
if (!done) wcomFetchWeatherData('days_update');
}, 3000);
});
on({ id: CONFIG.DP_FORCE_RESET, change: 'any', val: true }, async () => {
wcomLog('Manueller Reset: Monatszähler wird auf 0 gesetzt.', 'warn');
await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month`, 0, true);
await setStateAsync(`${CONFIG.DP_PATH}.info.requests_month_period`, wcomMonthKey(new Date()), true);
await setStateAsync(CONFIG.DP_FORCE_RESET, false, true);
});
on({ id: CONFIG.DP_FORCE_UPDATE, change: 'any', val: true }, async () => {
await setStateAsync(CONFIG.DP_FORCE_UPDATE, false, true);
authFailed = false;
wcomLog('Manueller Abruf ausgelöst (umgeht den Cooldown, Monatslimit bleibt aktiv).', 'info');
wcomFetchWeatherData('manual');
});
// --- Skriptstart ---
(async () => {
wcomValidateSchedule();
await wcomFetchWeatherData('start');
// 'current' MUSS unabhaengig vom Start-Abruf aufgebaut werden.
// Sonst entsteht genau die Luecke aus v3.2.0: greift beim Neustart der Cooldown
// (oder fehlt der API-Key), laeuft wcomProcessForecastData() nicht - und damit
// wurde der Ordner nie angelegt. Die Werte stammen ohnehin aus den bereits
// vorhandenen Stunden-Datenpunkten, ein Abruf ist dafuer nicht noetig.
await wcomUpdateCurrent('Skriptstart');
})();