PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); function http_post_form(string $url, array $data): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'], CURLOPT_POSTFIELDS => http_build_query($data), CURLOPT_TIMEOUT => 45, ]); $resp = curl_exec($ch); if ($resp === false) throw new RuntimeException('cURL error: '.curl_error($ch)); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code < 200 || $code >= 300) throw new RuntimeException("HTTP $code: $resp"); $json = json_decode($resp, true); if (!is_array($json)) throw new RuntimeException('Invalid JSON response'); return $json; } function http_get_json(string $url, array $headers): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 45, ]); $resp = curl_exec($ch); if ($resp === false) throw new RuntimeException('cURL error: '.curl_error($ch)); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code < 200 || $code >= 300) throw new RuntimeException("HTTP $code: $resp"); $json = json_decode($resp, true); if (!is_array($json)) throw new RuntimeException('Invalid JSON response'); return $json; } // ------------------------------------------------------- // 2) Access Token per Refresh holen // ------------------------------------------------------- $oauth = http_post_form('https://api.netatmo.com/oauth2/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $REFRESH_TOKEN, 'client_id' => $CLIENT_ID, 'client_secret' => $CLIENT_SECRET, ]); if (empty($oauth['access_token'])) throw new RuntimeException('No access_token from Netatmo'); $accessToken = $oauth['access_token']; if (!empty($oauth['refresh_token']) && $oauth['refresh_token'] !== $REFRESH_TOKEN) { error_log('Hinweis: Netatmo hat einen neuen refresh_token geliefert. Bitte config.php aktualisieren.'); } // ------------------------------------------------------- // 3) Stations- & Modul-Infos + aktuelle Dashboard-Werte // ------------------------------------------------------- $stations = http_get_json('https://api.netatmo.com/api/getstationsdata', [ 'Authorization: Bearer '.$accessToken, ]); if (empty($stations['body']['devices'])) { // Nichts zu tun exit(0); } // Prepared Upsert $upsert = $pdo->prepare(" INSERT INTO netatmo_readings (station_id, module_id, measure_time_utc, temperature, humidity, pressure, co2, noise, wind_strength, wind_angle, rain_60min, rain_24h, raw_json) VALUES (:station_id, :module_id, :time_utc, :temperature, :humidity, :pressure, :co2, :noise, :wind_strength, :wind_angle, :rain_60min, :rain_24h, :raw_json) ON DUPLICATE KEY UPDATE temperature = VALUES(temperature), humidity = VALUES(humidity), pressure = VALUES(pressure), co2 = VALUES(co2), noise = VALUES(noise), wind_strength = VALUES(wind_strength), wind_angle = VALUES(wind_angle), rain_60min = VALUES(rain_60min), rain_24h = VALUES(rain_24h), raw_json = VALUES(raw_json) "); function saveDashboard(PDOStatement $stmt, string $stationId, string $moduleId, array $d): void { $timeUtc = isset($d['time_utc']) ? gmdate('Y-m-d H:i:s', (int)$d['time_utc']) : gmdate('Y-m-d H:i:s'); $params = [ ':station_id' => $stationId, ':module_id' => $moduleId, ':time_utc' => $timeUtc, ':temperature' => isset($d['Temperature']) ? (float)$d['Temperature'] : null, ':humidity' => isset($d['Humidity']) ? (int)$d['Humidity'] : null, ':pressure' => isset($d['Pressure']) ? (float)$d['Pressure'] : null, ':co2' => isset($d['CO2']) ? (int)$d['CO2'] : null, ':noise' => isset($d['Noise']) ? (int)$d['Noise'] : null, ':wind_strength'=> isset($d['WindStrength']) ? (int)$d['WindStrength'] : null, ':wind_angle' => isset($d['WindAngle']) ? (int)$d['WindAngle'] : null, ':rain_60min' => isset($d['sum_rain_1']) ? (float)$d['sum_rain_1'] : null, ':rain_24h' => isset($d['sum_rain_24']) ? (float)$d['sum_rain_24'] : null, ':raw_json' => json_encode($d, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE), ]; $stmt->execute($params); } // Speichere Dashboard + sammle Module $devices = $stations['body']['devices']; $allModules = []; // Liste für getmeasure foreach ($devices as $device) { $stationId = $device['_id']; $mainModuleId = $device['_id']; if (!empty($device['dashboard_data'])) { saveDashboard($upsert, $stationId, $mainModuleId, $device['dashboard_data']); } $allModules[] = [ 'device_id' => $stationId, 'module_id' => null, // Hauptmodul 'types' => inferTypesFromDashboard($device['dashboard_data'] ?? []), // für getmeasure ]; if (!empty($device['modules'])) { foreach ($device['modules'] as $mod) { $moduleId = $mod['_id']; if (!empty($mod['dashboard_data'])) { saveDashboard($upsert, $stationId, $moduleId, $mod['dashboard_data']); } $allModules[] = [ 'device_id' => $stationId, 'module_id' => $moduleId, 'types' => inferTypesFromDashboard($mod['dashboard_data'] ?? []), ]; } } } /** * Leitet aus vorhandenen Dashboard-Feldern ab, welche Typen wir über getmeasure laden sollten. */ function inferTypesFromDashboard(array $d): string { $map = [ 'Temperature' => 'Temperature', 'Humidity' => 'Humidity', 'Pressure' => 'Pressure', 'CO2' => 'CO2', 'Noise' => 'Noise', 'WindStrength' => 'WindStrength', 'WindAngle' => 'WindAngle', 'sum_rain_1' => 'Rain', // getmeasure liefert "Rain" als Typ 'sum_rain_24' => 'Rain', ]; $types = []; foreach ($map as $k => $t) { if (array_key_exists($k, $d)) $types[$t] = true; } // Falls leer (z. B. Modul ohne aktuellen Dashboard-Wert), lieber Standardtypen versuchen: if (!$types) $types = ['Temperature'=>true,'Humidity'=>true,'Pressure'=>true,'CO2'=>true,'Noise'=>true,'WindStrength'=>true,'WindAngle'=>true,'Rain'=>true]; return implode(',', array_keys($types)); } // ------------------------------------------------------- // 4) Lücken via getmeasure nachladen (scale=max, in Chunks) // ------------------------------------------------------- foreach ($allModules as $m) { fetchMeasuresAndUpsert( $pdo, $accessToken, $m['device_id'], $m['module_id'], $m['types'] ?: 'Temperature,Humidity,Pressure,CO2,Noise,WindStrength,WindAngle,Rain', 'max' ); } function fetchMeasuresAndUpsert(PDO $pdo, string $accessToken, string $deviceId, ?string $moduleId, string $typesCsv, string $scale): void { // letzten Timestamp in DB finden $moduleKey = $moduleId ?? $deviceId; $stmt = $pdo->prepare("SELECT MAX(measure_time_utc) AS last_ts FROM netatmo_readings WHERE module_id = :m"); $stmt->execute([':m' => $moduleKey]); $last = $stmt->fetchColumn(); $dateBegin = $last ? (strtotime($last.' UTC') + 1) : (time() - 30*24*3600); // neu: 30 Tage rückwirkend $dateEnd = time(); if ($dateBegin > $dateEnd) return; // Chunking: 7 Tage pro Request ist i.d.R. safe $chunk = 7*24*3600; $insert = $pdo->prepare(" INSERT INTO netatmo_readings (station_id, module_id, measure_time_utc, temperature, humidity, pressure, co2, noise, wind_strength, wind_angle, rain_60min, rain_24h, raw_json) VALUES (:station_id, :module_id, :time_utc, :temperature, :humidity, :pressure, :co2, :noise, :wind_strength, :wind_angle, :rain_60min, :rain_24h, :raw_json) ON DUPLICATE KEY UPDATE temperature = VALUES(temperature), humidity = VALUES(humidity), pressure = VALUES(pressure), co2 = VALUES(co2), noise = VALUES(noise), wind_strength = VALUES(wind_strength), wind_angle = VALUES(wind_angle), rain_60min = VALUES(rain_60min), rain_24h = VALUES(rain_24h), raw_json = VALUES(raw_json) "); // Mapping der Reihenfolge von getmeasure-Values auf Spalten: $types = array_map('trim', explode(',', $typesCsv)); for ($from = $dateBegin; $from <= $dateEnd; $from += $chunk + 1) { $to = min($from + $chunk, $dateEnd); $params = array_filter([ 'device_id' => $deviceId, 'module_id' => $moduleId, 'scale' => $scale, // 'max' = höchste Auflösung 'type' => $typesCsv, // z.B. "Temperature,Humidity,Pressure" 'date_begin' => $from, 'date_end' => $to, 'optimize' => 'false', 'real_time' => 'false', ], fn($v) => $v !== null && $v !== ''); $url = 'https://api.netatmo.com/api/getmeasure?'.http_build_query($params); $data = http_get_json($url, ['Authorization: Bearer '.$accessToken]); if (empty($data['body'])) continue; foreach ($data['body'] as $series) { $begTime = (int)($series['beg_time'] ?? 0); $stepSec = scaleToSeconds($scale); foreach (($series['value'] ?? []) as $i => $vals) { $ts = gmdate('Y-m-d H:i:s', $begTime + $i * $stepSec); // Messwerte initialisieren $row = [ 'temperature' => null, 'humidity' => null, 'pressure' => null, 'co2' => null, 'noise' => null, 'wind_strength' => null, 'wind_angle' => null, // Rain kommt als Intensität pro 5min-Step bei scale=max – wir speichern hier nur den Rohwert 'rain_60min' => null, 'rain_24h' => null, ]; // Werte den Typen entsprechend zuordnen foreach ($types as $idx => $t) { $v = $vals[$idx] ?? null; switch ($t) { case 'Temperature': $row['temperature'] = is_numeric($v) ? (float)$v : null; break; case 'Humidity': $row['humidity'] = is_numeric($v) ? (int)$v : null; break; case 'Pressure': $row['pressure'] = is_numeric($v) ? (float)$v : null; break; case 'CO2': $row['co2'] = is_numeric($v) ? (int)$v : null; break; case 'Noise': $row['noise'] = is_numeric($v) ? (int)$v : null; break; case 'WindStrength': $row['wind_strength'] = is_numeric($v) ? (int)$v : null; break; case 'WindAngle': $row['wind_angle'] = is_numeric($v) ? (int)$v : null; break; case 'Rain': // Optional: hier nur die 5-min Regenmenge (mm) ablegen – Aggregation (60min/24h) machst du in SQL/View $row['rain_60min'] = $row['rain_60min'] ?? null; // Platzhalter, wir lassen es hier leer break; } } // Rohdaten zusammenbauen (nützlich für spätere Auswertung) $raw = [ 'types' => $types, 'values'=> $vals, 'scale' => $scale, ]; $insert->execute([ ':station_id' => $deviceId, ':module_id' => $moduleId ?? $deviceId, ':time_utc' => $ts, ':temperature' => $row['temperature'], ':humidity' => $row['humidity'], ':pressure' => $row['pressure'], ':co2' => $row['co2'], ':noise' => $row['noise'], ':wind_strength'=> $row['wind_strength'], ':wind_angle' => $row['wind_angle'], ':rain_60min' => $row['rain_60min'], ':rain_24h' => $row['rain_24h'], ':raw_json' => json_encode($raw, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE), ]); } } } } function scaleToSeconds(string $s): int { // Bei scale=max sind es i.d.R. 5 Minuten Steps return match($s) { 'max' => 300, '30min' => 1800, '1hour' => 3600, '3hours' => 10800, '1day' => 86400, '1week' => 604800, '1month' => 2592000, default => 300, }; }