fontdelbosc-eltemps/includes/class-fontdelbosc-api-aemet.php
2026-10-12 11:55:23 +00:00

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 );
}
}