v1.0.0
This commit is contained in:
commit
06153e387d
47 changed files with 5116 additions and 0 deletions
398
includes/class-fontdelbosc-api-meteocat.php
Normal file
398
includes/class-fontdelbosc-api-meteocat.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue