440 lines
12 KiB
PHP
440 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* AEMET OpenData API handler.
|
|
*
|
|
* Endpoints used:
|
|
* - Daily municipal forecast: GET /prediccion/especifica/municipio/diaria/08122
|
|
* - Hourly municipal forecast: GET /prediccion/especifica/municipio/horaria/08122
|
|
*
|
|
* AEMET uses a double-call pattern: the first request returns a JSON with a
|
|
* temporary "datos" URL; the second request returns the actual data. Data
|
|
* responses are encoded as ISO-8859-15, so a UTF-8 re-decode fallback is
|
|
* applied.
|
|
*
|
|
* Response shape (both endpoints): the payload is an array whose first
|
|
* element carries "origen" metadata and a "prediccion" object holding the
|
|
* "dia" array. Period formats: "00-24" (daily), "08" (single hour) and
|
|
* "0814" (hour range). Sky-state codes may carry a trailing "n" for the
|
|
* night variant.
|
|
*
|
|
* @package FontdelboscEltemps
|
|
*/
|
|
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
/**
|
|
* Handles AEMET municipal forecast requests.
|
|
*/
|
|
final class Fontdelbosc_Api_Aemet {
|
|
|
|
/**
|
|
* API base URL.
|
|
*/
|
|
private const BASE_URL = 'https://opendata.aemet.es/opendata/api';
|
|
|
|
/**
|
|
* INE municipal code for Mediona.
|
|
*/
|
|
private const MUNICIPAL_CODE = '08122';
|
|
|
|
/**
|
|
* Returns the normalized daily forecast.
|
|
*
|
|
* @param string $api_key AEMET API key.
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
public static function get_daily( string $api_key ): ?array {
|
|
$payload = self::fetch_data( '/prediccion/especifica/municipio/diaria/' . self::MUNICIPAL_CODE, $api_key );
|
|
$dias = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $payload, 0, 'prediccion', 'dia' ) );
|
|
$days = array();
|
|
|
|
foreach ( $dias as $day ) {
|
|
$date = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $day, 'fecha' ) );
|
|
|
|
if ( null === $date ) {
|
|
continue;
|
|
}
|
|
|
|
$date = substr( $date, 0, 10 );
|
|
$pop = self::period_value( Fontdelbosc_Payload::get( $day, 'probPrecipitacion' ) );
|
|
$velocity = self::period_value( Fontdelbosc_Payload::get( $day, 'viento' ), 'velocidad' );
|
|
|
|
$days[ $date ] = array(
|
|
'date' => $date,
|
|
'condition' => self::sky_condition( Fontdelbosc_Payload::get( $day, 'estadoCielo' ) ),
|
|
'temp_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $day, 'temperatura', 'maxima' ) ),
|
|
'temp_min' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $day, 'temperatura', 'minima' ) ),
|
|
'precipitation' => null,
|
|
'pop' => Fontdelbosc_Payload::int( $pop ),
|
|
'wind_max' => Fontdelbosc_Payload::float( $velocity ),
|
|
);
|
|
}
|
|
|
|
if ( array() === $days ) {
|
|
return null;
|
|
}
|
|
|
|
ksort( $days );
|
|
|
|
return array(
|
|
'source' => 'aemet',
|
|
'days' => array_values( $days ),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns the normalized hourly forecast, from the current hour onwards.
|
|
*
|
|
* @param string $api_key AEMET API key.
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
public static function get_hourly( string $api_key ): ?array {
|
|
$payload = self::fetch_data( '/prediccion/especifica/municipio/horaria/' . self::MUNICIPAL_CODE, $api_key );
|
|
$dias = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $payload, 0, 'prediccion', 'dia' ) );
|
|
|
|
$temperatures = array();
|
|
$pops = array();
|
|
$cielos = array();
|
|
$winds = array();
|
|
|
|
foreach ( $dias as $day ) {
|
|
$date = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $day, 'fecha' ) );
|
|
|
|
if ( null === $date ) {
|
|
continue;
|
|
}
|
|
|
|
$date = substr( $date, 0, 10 );
|
|
|
|
self::collect_entries( Fontdelbosc_Payload::get( $day, 'temperatura' ), $date, 'value', $temperatures );
|
|
self::collect_entries( Fontdelbosc_Payload::get( $day, 'estadoCielo' ), $date, 'value', $cielos );
|
|
self::collect_entries( Fontdelbosc_Payload::get( $day, 'probPrecipitacion' ), $date, 'value', $pops );
|
|
self::collect_entries( Fontdelbosc_Payload::get( $day, 'vientoAndRachaMax' ), $date, 'velocidad', $winds );
|
|
}
|
|
|
|
$keys = array_unique( array_merge( array_keys( $temperatures ), array_keys( $cielos ) ) );
|
|
sort( $keys );
|
|
|
|
$now = ( new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->format( 'Y-m-d H:00' );
|
|
$hours = array();
|
|
|
|
foreach ( $keys as $key ) {
|
|
if ( $key < $now ) {
|
|
continue;
|
|
}
|
|
|
|
$hours[] = array(
|
|
'time' => self::key_timestamp( $key ),
|
|
'hour' => (int) substr( $key, 11, 2 ),
|
|
'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $temperatures, $key ) ),
|
|
'pop' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $pops, $key ) ),
|
|
'condition' => self::condition( Fontdelbosc_Payload::get( $cielos, $key ) ),
|
|
'wind' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $winds, $key ) ),
|
|
);
|
|
}
|
|
|
|
if ( array() === $hours ) {
|
|
return null;
|
|
}
|
|
|
|
return array(
|
|
'source' => 'aemet',
|
|
'hours' => $hours,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Collects period-based values into an hour-keyed map. Handles the
|
|
* single-hour ("08"), hour-range ("0814") and dashed ("00-24") period
|
|
* formats; ranges are applied end-exclusive.
|
|
*
|
|
* @param mixed $entries Raw element entries.
|
|
* @param string $date Day prefix (Y-m-d).
|
|
* @param string $value_key Key holding the value.
|
|
* @param array<string,mixed> $map Output map, modified by reference.
|
|
* @return void
|
|
*/
|
|
private static function collect_entries( mixed $entries, string $date, string $value_key, array &$map ): void {
|
|
foreach ( Fontdelbosc_Payload::arr( $entries ) as $entry ) {
|
|
$period = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'periodo' ) );
|
|
$range = null === $period ? null : self::expand_period( $period );
|
|
|
|
if ( null === $range ) {
|
|
continue;
|
|
}
|
|
|
|
for ( $hour = $range[0]; $hour < $range[1]; $hour++ ) {
|
|
if ( $hour < 0 || $hour > 23 ) {
|
|
continue;
|
|
}
|
|
|
|
$map[ sprintf( '%s %02d:00', $date, $hour ) ] = Fontdelbosc_Payload::get( $entry, $value_key );
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Expands an AEMET period string to an end-exclusive hour range.
|
|
*
|
|
* @param string $period Period ("08", "0814" or "00-24").
|
|
* @return array{0:int,1:int}|null
|
|
*/
|
|
private static function expand_period( string $period ): ?array {
|
|
$period = str_replace( '-', '', $period );
|
|
|
|
if ( 2 === strlen( $period ) ) {
|
|
return array( (int) $period, (int) $period + 1 );
|
|
}
|
|
|
|
if ( 4 === strlen( $period ) ) {
|
|
return array( (int) substr( $period, 0, 2 ), (int) substr( $period, 2, 2 ) );
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Returns the "00-24" value of a daily element, falling back to the first entry.
|
|
*
|
|
* @param mixed $entries Raw element entries.
|
|
* @param string $value_key Key holding the value.
|
|
* @return mixed
|
|
*/
|
|
private static function period_value( mixed $entries, string $value_key = 'value' ): mixed {
|
|
foreach ( Fontdelbosc_Payload::arr( $entries ) as $entry ) {
|
|
$period = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'periodo' ) );
|
|
|
|
if ( '00-24' === $period ) {
|
|
return Fontdelbosc_Payload::get( $entry, $value_key );
|
|
}
|
|
}
|
|
|
|
return Fontdelbosc_Payload::path( $entries, 0, $value_key );
|
|
}
|
|
|
|
/**
|
|
* Picks the best sky-state code from daily estadoCielo entries: the
|
|
* "00-24" period when present, otherwise the first non-empty value.
|
|
* Empty values (distant days, night-only periods) are skipped.
|
|
*
|
|
* @param mixed $entries estadoCielo entries.
|
|
* @return string|null
|
|
*/
|
|
private static function sky_condition( mixed $entries ): ?string {
|
|
$fallback = null;
|
|
|
|
foreach ( Fontdelbosc_Payload::arr( $entries ) as $entry ) {
|
|
$value = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'value' ) ) ?? '';
|
|
$period = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $entry, 'periodo' ) );
|
|
|
|
if ( '' === $value ) {
|
|
continue;
|
|
}
|
|
|
|
if ( null === $fallback ) {
|
|
$fallback = $value;
|
|
}
|
|
|
|
if ( '00-24' === $period ) {
|
|
return self::condition( $value );
|
|
}
|
|
}
|
|
|
|
return null === $fallback ? null : self::condition( $fallback );
|
|
}
|
|
|
|
/**
|
|
* Maps an AEMET sky-state code to a condition key. Codes may carry a
|
|
* trailing "n" (night variant), which is stripped first.
|
|
*
|
|
* @param mixed $value AEMET sky-state code.
|
|
* @return string|null
|
|
*/
|
|
private static function condition( mixed $value ): ?string {
|
|
$code_str = is_string( $value ) || is_numeric( $value ) ? rtrim( (string) $value, 'n' ) : '';
|
|
|
|
if ( ! is_numeric( $code_str ) ) {
|
|
return null;
|
|
}
|
|
|
|
$code = (int) $code_str;
|
|
|
|
if ( 11 === $code ) {
|
|
return 'clear';
|
|
}
|
|
|
|
if ( 12 === $code ) {
|
|
return 'mainly_clear';
|
|
}
|
|
|
|
if ( 13 === $code ) {
|
|
return 'partly_cloudy';
|
|
}
|
|
|
|
if ( 14 === $code || 15 === $code ) {
|
|
return 'cloudy';
|
|
}
|
|
|
|
if ( 16 === $code || 17 === $code ) {
|
|
return 'overcast';
|
|
}
|
|
|
|
if ( 18 === $code || 19 === $code ) {
|
|
return 'fog';
|
|
}
|
|
|
|
if ( $code >= 20 && $code <= 27 ) {
|
|
return 'rain_light';
|
|
}
|
|
|
|
if ( ( $code >= 28 && $code <= 37 ) || ( $code >= 61 && $code <= 66 ) ) {
|
|
return 'thunderstorm';
|
|
}
|
|
|
|
if ( $code >= 38 && $code <= 44 ) {
|
|
return 'rain';
|
|
}
|
|
|
|
if ( ( $code >= 45 && $code <= 47 ) || ( $code >= 70 && $code <= 77 ) ) {
|
|
return 'snow';
|
|
}
|
|
|
|
if ( $code >= 51 && $code <= 56 ) {
|
|
return 'drizzle';
|
|
}
|
|
|
|
if ( 81 === $code || 82 === $code ) {
|
|
return 'fog';
|
|
}
|
|
|
|
return 'cloudy';
|
|
}
|
|
|
|
/**
|
|
* Performs the AEMET double call and returns the decoded data payload.
|
|
*
|
|
* @param string $path API path.
|
|
* @param string $api_key AEMET API key.
|
|
* @return array<mixed>|null
|
|
*/
|
|
private static function fetch_data( string $path, string $api_key ): ?array {
|
|
if ( '' === $api_key ) {
|
|
return null;
|
|
}
|
|
|
|
$meta = self::request_json( self::BASE_URL . $path . '?api_key=' . rawurlencode( $api_key ), false );
|
|
$data_url = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $meta, 'datos' ) ) ?? '';
|
|
|
|
if ( '' === $data_url ) {
|
|
return null;
|
|
}
|
|
|
|
return self::request_json( $data_url, true );
|
|
}
|
|
|
|
/**
|
|
* Performs a GET request and decodes the JSON response, with an
|
|
* ISO-8859-1 to UTF-8 fallback for AEMET data payloads. Transient
|
|
* gateway 5xx errors are retried once.
|
|
*
|
|
* @param string $url Request URL.
|
|
* @param bool $latin1_fallback Whether to retry with converted encoding.
|
|
* @return array<mixed>|null
|
|
*/
|
|
private static function request_json( string $url, bool $latin1_fallback ): ?array {
|
|
$response = self::http_get( $url );
|
|
$code = wp_remote_retrieve_response_code( $response );
|
|
|
|
if ( $code >= 500 && wp_doing_cron() ) {
|
|
sleep( 1 );
|
|
|
|
$response = self::http_get( $url );
|
|
$code = wp_remote_retrieve_response_code( $response );
|
|
}
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
self::log( 'AEMET request failed: ' . $response->get_error_message() );
|
|
|
|
return null;
|
|
}
|
|
|
|
if ( 200 !== $code ) {
|
|
self::log( 'AEMET returned HTTP ' . $code );
|
|
|
|
return null;
|
|
}
|
|
|
|
$body = (string) wp_remote_retrieve_body( $response );
|
|
$data = json_decode( $body, true );
|
|
|
|
if ( null === $data && $latin1_fallback ) {
|
|
$converted = self::to_utf8( $body );
|
|
$data = null === $converted ? null : json_decode( $converted, true );
|
|
}
|
|
|
|
return is_array( $data ) ? $data : null;
|
|
}
|
|
|
|
/**
|
|
* Performs the HTTP GET request.
|
|
*
|
|
* @param string $url Request URL.
|
|
* @return array<string,mixed>|WP_Error
|
|
*/
|
|
private static function http_get( string $url ): array|WP_Error {
|
|
return wp_remote_get(
|
|
$url,
|
|
array(
|
|
'timeout' => 5,
|
|
'headers' => array(
|
|
'Accept' => 'application/json',
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Converts an ISO-8859-1 payload to UTF-8 using the first available
|
|
* extension (mbstring, then iconv).
|
|
*
|
|
* @param string $body Raw body.
|
|
* @return string|null
|
|
*/
|
|
private static function to_utf8( string $body ): ?string {
|
|
if ( function_exists( 'mb_convert_encoding' ) ) {
|
|
return mb_convert_encoding( $body, 'UTF-8', 'ISO-8859-1' );
|
|
}
|
|
|
|
if ( function_exists( 'iconv' ) ) {
|
|
$converted = iconv( 'ISO-8859-1', 'UTF-8//IGNORE', $body );
|
|
|
|
return false === $converted ? null : $converted;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Converts an hour key to a Unix timestamp.
|
|
*
|
|
* @param string $key Hour key (Y-m-d H:00).
|
|
* @return int
|
|
*/
|
|
private static function key_timestamp( string $key ): int {
|
|
try {
|
|
return ( new DateTimeImmutable( $key, new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->getTimestamp();
|
|
} catch ( Exception ) {
|
|
return time();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes a message to the error log with the plugin prefix.
|
|
*
|
|
* @param string $message Message.
|
|
* @return void
|
|
*/
|
|
private static function log( string $message ): void {
|
|
error_log( '[fontdelbosc-eltemps] ' . $message );
|
|
}
|
|
}
|