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