This commit is contained in:
Javier Casares 2026-10-12 11:55:23 +00:00
commit 06153e387d
47 changed files with 5116 additions and 0 deletions

View file

@ -0,0 +1,398 @@
<?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 );
}
}