398 lines
11 KiB
PHP
398 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* Meteocat API handler (Servei Meteorològic de Catalunya).
|
|
*
|
|
* Endpoints used:
|
|
* - Predicció municipal: GET /prediccio/v1/municipi/081228/prediccio
|
|
* - XEMA station data: GET /xema/v1/estacions/{codi}/dades/{variable}
|
|
* - XEMA station catalog: GET /xema/v1/estacions/metadades
|
|
*
|
|
* Requires an API key passed as the X-Api-Key header.
|
|
*
|
|
* @package FontdelboscEltemps
|
|
*/
|
|
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
/**
|
|
* Handles Meteocat municipal forecast and XEMA station requests.
|
|
*/
|
|
final class Fontdelbosc_Api_Meteocat {
|
|
|
|
/**
|
|
* API base URL.
|
|
*/
|
|
private const BASE_URL = 'https://api.meteo.cat';
|
|
|
|
/**
|
|
* IDESCAT municipal code for Mediona.
|
|
*/
|
|
private const MUNICIPAL_CODE = '081228';
|
|
|
|
/**
|
|
* Returns the normalized hourly forecast from the municipal prediction,
|
|
* from the current hour onwards.
|
|
*
|
|
* @param string $api_key Meteocat API key.
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
public static function get_hourly( string $api_key ): ?array {
|
|
$payload = self::request( '/prediccio/v1/municipi/' . self::MUNICIPAL_CODE . '/prediccio', $api_key );
|
|
$series = array();
|
|
|
|
foreach ( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $payload, 0, 'prediccions' ) ) as $prediction ) {
|
|
foreach ( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $prediction, 'variables' ) ) as $name => $points ) {
|
|
foreach ( Fontdelbosc_Payload::arr( $points ) as $point ) {
|
|
$iso = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $point, 'data' ) );
|
|
$key = null === $iso ? null : self::hour_key( $iso );
|
|
|
|
if ( null !== $key ) {
|
|
$series[ (string) $name ][ $key ] = $point;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$keys = array();
|
|
|
|
foreach ( $series as $points ) {
|
|
$keys = array_merge( $keys, array_keys( $points ) );
|
|
}
|
|
|
|
$keys = array_values( array_unique( $keys ) );
|
|
sort( $keys );
|
|
|
|
$now = self::now_key();
|
|
$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::path( $series, 'temperatura', $key, 'valor' ) ),
|
|
'pop' => Fontdelbosc_Payload::int( self::coalesce( Fontdelbosc_Payload::path( $series, 'probabilitatPrecipitacio', $key, 'valor' ), Fontdelbosc_Payload::path( $series, 'probabilitatDePrecipitacio', $key, 'valor' ) ) ),
|
|
'condition' => self::condition( Fontdelbosc_Payload::path( $series, 'cel', $key ) ),
|
|
'wind' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $series, 'vent', $key, 'valor' ) ),
|
|
);
|
|
}
|
|
|
|
if ( array() === $hours ) {
|
|
return null;
|
|
}
|
|
|
|
return array(
|
|
'source' => 'meteocat',
|
|
'hours' => $hours,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns normalized current conditions from the configured XEMA station.
|
|
*
|
|
* @param string $api_key Meteocat API key.
|
|
* @param string $station_code XEMA station code.
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
public static function get_current( string $api_key, string $station_code ): ?array {
|
|
if ( '' === $station_code ) {
|
|
return null;
|
|
}
|
|
|
|
$temperature = self::station_variable( $api_key, $station_code, 't' );
|
|
$humidity = self::station_variable( $api_key, $station_code, 'hr' );
|
|
$wind = self::station_variable( $api_key, $station_code, 'v' );
|
|
|
|
if ( null === $temperature && null === $humidity && null === $wind ) {
|
|
return null;
|
|
}
|
|
|
|
return array(
|
|
'temperature' => $temperature,
|
|
'apparent' => null,
|
|
'humidity' => $humidity,
|
|
'precipitation' => null,
|
|
'wind_speed' => null === $wind ? null : round( $wind * 3.6, 1 ),
|
|
'wind_direction' => null,
|
|
'condition' => null,
|
|
'source' => 'meteocat',
|
|
'time' => time(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Finds the XEMA station closest to Font del Bosc.
|
|
*
|
|
* @param string $api_key Meteocat API key.
|
|
* @return array{codi:string,nom:string,distance_km:float}|null
|
|
*/
|
|
public static function find_nearest_station( string $api_key ): ?array {
|
|
$stations = self::request( '/xema/v1/estacions/metadades', $api_key );
|
|
|
|
if ( null === $stations ) {
|
|
return null;
|
|
}
|
|
|
|
$best = null;
|
|
$distance = INF;
|
|
|
|
foreach ( Fontdelbosc_Payload::arr( $stations ) as $station ) {
|
|
$codi = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $station, 'codi' ) );
|
|
$lat = self::to_float( Fontdelbosc_Payload::path( $station, 'coordenades', 'latitud' ) );
|
|
$lon = self::to_float( Fontdelbosc_Payload::path( $station, 'coordenades', 'longitud' ) );
|
|
|
|
if ( null === $codi || '' === $codi || null === $lat || null === $lon ) {
|
|
continue;
|
|
}
|
|
|
|
$current = self::haversine( FONTDELBOSC_ELTEMPS_LAT, FONTDELBOSC_ELTEMPS_LON, $lat, $lon );
|
|
|
|
if ( $current < $distance ) {
|
|
$distance = $current;
|
|
$best = array(
|
|
'codi' => $codi,
|
|
'nom' => Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $station, 'nom' ) ) ?? '',
|
|
'distance_km' => round( $current, 1 ),
|
|
);
|
|
}
|
|
}
|
|
|
|
return $best;
|
|
}
|
|
|
|
/**
|
|
* Returns the latest numeric value of a XEMA station variable.
|
|
*
|
|
* @param string $api_key Meteocat API key.
|
|
* @param string $station Station code.
|
|
* @param string $variable Variable code (t, hr, v).
|
|
* @return float|null
|
|
*/
|
|
private static function station_variable( string $api_key, string $station, string $variable ): ?float {
|
|
$data = self::request( '/xema/v1/estacions/' . rawurlencode( $station ) . '/dades/' . rawurlencode( $variable ), $api_key );
|
|
$measures = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'variables', 0, 'mesures' ) );
|
|
|
|
foreach ( array_reverse( $measures ) as $measure ) {
|
|
$value = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $measure, 'valor' ) );
|
|
|
|
if ( null !== $value ) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Maps a Meteocat "cel" data point to a condition key, preferring the
|
|
* descriptive Catalan "estat" text over the numeric symbol.
|
|
*
|
|
* @param mixed $point Cel data point.
|
|
* @return string|null
|
|
*/
|
|
private static function condition( mixed $point ): ?string {
|
|
if ( ! is_array( $point ) ) {
|
|
return null;
|
|
}
|
|
|
|
$estat = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $point, 'estat' ) ) ?? '';
|
|
|
|
if ( '' !== $estat ) {
|
|
$estat = mb_strtolower( $estat );
|
|
$keywords = array(
|
|
'tempesta' => 'thunderstorm',
|
|
'calabruix' => 'thunderstorm_hail',
|
|
'aiguaneu' => 'snow',
|
|
'neu' => 'snow',
|
|
'plugim' => 'drizzle',
|
|
'ruixat' => 'rain_showers',
|
|
'pluja' => 'rain',
|
|
'boira' => 'fog',
|
|
'parcialment' => 'partly_cloudy',
|
|
'molt ennuvolat' => 'overcast',
|
|
'cobert' => 'overcast',
|
|
'ennuvolat' => 'cloudy',
|
|
'núvol' => 'cloudy',
|
|
'sol' => 'clear',
|
|
'clar' => 'clear',
|
|
);
|
|
|
|
foreach ( $keywords as $needle => $condition ) {
|
|
if ( str_contains( $estat, $needle ) ) {
|
|
return $condition;
|
|
}
|
|
}
|
|
}
|
|
|
|
$symbol = Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $point, 'simbol' ) );
|
|
|
|
if ( null === $symbol ) {
|
|
return null;
|
|
}
|
|
|
|
$map = array(
|
|
1 => 'clear',
|
|
2 => 'partly_cloudy',
|
|
3 => 'partly_cloudy',
|
|
4 => 'cloudy',
|
|
5 => 'drizzle',
|
|
6 => 'drizzle',
|
|
7 => 'drizzle',
|
|
8 => 'overcast',
|
|
9 => 'rain_light',
|
|
10 => 'rain',
|
|
11 => 'thunderstorm',
|
|
12 => 'thunderstorm',
|
|
13 => 'rain_showers',
|
|
14 => 'rain_showers',
|
|
15 => 'rain_showers',
|
|
16 => 'rain',
|
|
);
|
|
|
|
return $map[ $symbol ] ?? 'cloudy';
|
|
}
|
|
|
|
/**
|
|
* Returns the first non-null value of a pair.
|
|
*
|
|
* @param mixed $first First value.
|
|
* @param mixed $second Second value.
|
|
* @return mixed
|
|
*/
|
|
private static function coalesce( mixed $first, mixed $second ): mixed {
|
|
return null !== $first ? $first : $second;
|
|
}
|
|
|
|
/**
|
|
* Converts a Meteocat ISO date-time to an hour key in local time.
|
|
*
|
|
* @param string $iso ISO date-time string.
|
|
* @return string|null Format: Y-m-d H:00.
|
|
*/
|
|
private static function hour_key( string $iso ): ?string {
|
|
try {
|
|
return ( new DateTimeImmutable( $iso ) )->setTimezone( new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) )->format( 'Y-m-d H:00' );
|
|
} catch ( Exception ) {
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the current hour key used to filter hourly series.
|
|
*
|
|
* @return string Format: Y-m-d H:00.
|
|
*/
|
|
private static function now_key(): string {
|
|
return ( new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->format( 'Y-m-d H:00' );
|
|
}
|
|
|
|
/**
|
|
* Converts a coordinate value to float, handling decimal commas.
|
|
*
|
|
* @param mixed $value Raw value.
|
|
* @return float|null
|
|
*/
|
|
private static function to_float( mixed $value ): ?float {
|
|
if ( is_int( $value ) || is_float( $value ) ) {
|
|
return (float) $value;
|
|
}
|
|
|
|
if ( is_string( $value ) ) {
|
|
$normalized = str_replace( ',', '.', trim( $value ) );
|
|
|
|
return is_numeric( $normalized ) ? (float) $normalized : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Haversine distance in kilometers between two coordinates.
|
|
*
|
|
* @param float $lat1 Latitude A.
|
|
* @param float $lon1 Longitude A.
|
|
* @param float $lat2 Latitude B.
|
|
* @param float $lon2 Longitude B.
|
|
* @return float
|
|
*/
|
|
private static function haversine( float $lat1, float $lon1, float $lat2, float $lon2 ): float {
|
|
$radians = M_PI / 180;
|
|
$radius = 6371;
|
|
|
|
$dlat = ( $lat2 - $lat1 ) * $radians;
|
|
$dlon = ( $lon2 - $lon1 ) * $radians;
|
|
|
|
$a = ( sin( $dlat / 2 ) ** 2 ) + ( cos( $lat1 * $radians ) * cos( $lat2 * $radians ) * ( sin( $dlon / 2 ) ** 2 ) );
|
|
|
|
return 2 * $radius * asin( sqrt( $a ) );
|
|
}
|
|
|
|
/**
|
|
* Performs an authenticated GET request and decodes the JSON response.
|
|
*
|
|
* @param string $path API path.
|
|
* @param string $api_key Meteocat API key.
|
|
* @return array<mixed>|null
|
|
*/
|
|
private static function request( string $path, string $api_key ): ?array {
|
|
if ( '' === $api_key ) {
|
|
return null;
|
|
}
|
|
|
|
$response = wp_remote_get(
|
|
self::BASE_URL . $path,
|
|
array(
|
|
'timeout' => 5,
|
|
'headers' => array(
|
|
'X-Api-Key' => $api_key,
|
|
'Accept' => 'application/json',
|
|
),
|
|
)
|
|
);
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
self::log( 'Meteocat request failed: ' . $response->get_error_message() );
|
|
|
|
return null;
|
|
}
|
|
|
|
$code = wp_remote_retrieve_response_code( $response );
|
|
|
|
if ( 200 !== $code ) {
|
|
self::log( 'Meteocat returned HTTP ' . $code . ' for ' . $path );
|
|
|
|
return null;
|
|
}
|
|
|
|
$data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
|
|
|
|
return is_array( $data ) ? $data : null;
|
|
}
|
|
|
|
/**
|
|
* 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 );
|
|
}
|
|
}
|