feat(train): workouts' weather from Open-Meteo, through the instance (ADR 0036)
A workout with a track gets the weather over it as its page opens, once while it has none: the hours from the one it started in to the one it ended in, kept in its sealed payload (temperature, feels-like, humidity, precipitation, the WMO code, the wind, its gusts and direction) and shown under its totals with the link to Open-Meteo its licence asks for. Train's server asks Open-Meteo in the person's place (the `weather` endpoint), so Open-Meteo never sees their address: the place rounded to two decimals (also on the phone), a span of at most 48 hours, the forecast API for recent spans and the archive for older ones, for people signed in, at most 30 requests a person a minute, nothing kept or logged. On by default; `WEATHER=off` switches it off, and `OPEN_METEO_API_KEY` moves it to the paid hosts for an instance used commercially. Health Connect's fingerprint leaves the weather out, so keeping it writes nothing again. No table and no migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
508994be3a
commit
3f7b409e80
13 changed files with 2503 additions and 7 deletions
|
|
@ -29,6 +29,7 @@ import '../firmware/firmware_endpoint.dart' as _id2y0j3h;
|
|||
import '../map/map_endpoint.dart' as _i35zr6r3;
|
||||
import '../routing/routing_endpoint.dart' as _ivjydyye;
|
||||
import '../search/search_endpoint.dart' as _i2f0v2ey;
|
||||
import '../weather/weather_endpoint.dart' as _i8nd21di;
|
||||
export 'future_calls.dart' show ServerpodFutureCallsGetter;
|
||||
|
||||
class Endpoints extends _is.EndpointDispatch {
|
||||
|
|
@ -59,6 +60,12 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
'search',
|
||||
null,
|
||||
),
|
||||
'weather': _i8nd21di.WeatherEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'weather',
|
||||
null,
|
||||
),
|
||||
};
|
||||
connectors['firmware'] = _is.EndpointConnector(
|
||||
name: 'firmware',
|
||||
|
|
@ -236,6 +243,59 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
),
|
||||
},
|
||||
);
|
||||
connectors['weather'] = _is.EndpointConnector(
|
||||
name: 'weather',
|
||||
endpoint: endpoints['weather']!,
|
||||
methodConnectors: {
|
||||
'offered': _is.MethodConnector(
|
||||
name: 'offered',
|
||||
params: {},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async => (endpoints['weather'] as _i8nd21di.WeatherEndpoint)
|
||||
.offered(session),
|
||||
),
|
||||
'hours': _is.MethodConnector(
|
||||
name: 'hours',
|
||||
params: {
|
||||
'lat': _is.ParameterDescription(
|
||||
name: 'lat',
|
||||
type: _is.getType<double>(),
|
||||
nullable: false,
|
||||
),
|
||||
'lon': _is.ParameterDescription(
|
||||
name: 'lon',
|
||||
type: _is.getType<double>(),
|
||||
nullable: false,
|
||||
),
|
||||
'from': _is.ParameterDescription(
|
||||
name: 'from',
|
||||
type: _is.getType<DateTime>(),
|
||||
nullable: false,
|
||||
),
|
||||
'to': _is.ParameterDescription(
|
||||
name: 'to',
|
||||
type: _is.getType<DateTime>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async =>
|
||||
(endpoints['weather'] as _i8nd21di.WeatherEndpoint).hours(
|
||||
session,
|
||||
lat: params['lat'],
|
||||
lon: params['lon'],
|
||||
from: params['from'],
|
||||
to: params['to'],
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
modules['halkyon_base'] = _ik8molhd.Endpoints()
|
||||
..initializeEndpoints(server);
|
||||
modules['serverpod_auth_idp'] = _iais.Endpoints()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import 'package:halkyon_train_server/src/generated/routing/route_point.dart'
|
|||
as _is4pcfub;
|
||||
import 'package:halkyon_train_server/src/generated/search/place.dart'
|
||||
as _i6fosizj;
|
||||
import 'package:halkyon_train_server/src/generated/weather/weather_hour.dart'
|
||||
as _io7n438g;
|
||||
import 'package:serverpod/protocol.dart' as _isp;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
||||
|
|
@ -45,6 +47,8 @@ import 'routing/route_stretch.dart' as _ic7f85bc;
|
|||
import 'search/place.dart' as _iadh29yw;
|
||||
import 'search/search_refused.dart' as _ihbxkwke;
|
||||
import 'strength/strength_plan.dart' as _imwvu2mn;
|
||||
import 'weather/weather_hour.dart' as _i9m2f53e;
|
||||
import 'weather/weather_refused.dart' as _ieu1vbq0;
|
||||
import 'workout/workout.dart' as _i5b9b17z;
|
||||
import 'workout/workout_series.dart' as _i92mefto;
|
||||
export 'day/day.dart';
|
||||
|
|
@ -67,6 +71,8 @@ export 'routing/route_stretch.dart';
|
|||
export 'search/place.dart';
|
||||
export 'search/search_refused.dart';
|
||||
export 'strength/strength_plan.dart';
|
||||
export 'weather/weather_hour.dart';
|
||||
export 'weather/weather_refused.dart';
|
||||
export 'workout/workout.dart';
|
||||
export 'workout/workout_series.dart';
|
||||
export 'sync_tables.dart';
|
||||
|
|
@ -965,6 +971,12 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _imwvu2mn.StrengthPlan) {
|
||||
return _imwvu2mn.StrengthPlan.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i9m2f53e.WeatherHour) {
|
||||
return _i9m2f53e.WeatherHour.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ieu1vbq0.WeatherRefused) {
|
||||
return _ieu1vbq0.WeatherRefused.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i5b9b17z.Workout) {
|
||||
return _i5b9b17z.Workout.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -1036,6 +1048,13 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _is.getType<_imwvu2mn.StrengthPlan?>()) {
|
||||
return (data != null ? _imwvu2mn.StrengthPlan.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_i9m2f53e.WeatherHour?>()) {
|
||||
return (data != null ? _i9m2f53e.WeatherHour.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_ieu1vbq0.WeatherRefused?>()) {
|
||||
return (data != null ? _ieu1vbq0.WeatherRefused.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_i5b9b17z.Workout?>()) {
|
||||
return (data != null ? _i5b9b17z.Workout.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -1074,6 +1093,12 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return (data as List).map((e) => deserialize<_i6fosizj.Place>(e)).toList()
|
||||
as T;
|
||||
}
|
||||
if (t == List<_io7n438g.WeatherHour>) {
|
||||
return (data as List)
|
||||
.map((e) => deserialize<_io7n438g.WeatherHour>(e))
|
||||
.toList()
|
||||
as T;
|
||||
}
|
||||
try {
|
||||
return _ik8molhd.Protocol().deserialize<T>(data, t);
|
||||
} on _is.DeserializationTypeNotFoundException catch (_) {}
|
||||
|
|
@ -1117,6 +1142,8 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
_iadh29yw.Place => 'Place',
|
||||
_ihbxkwke.SearchRefused => 'SearchRefused',
|
||||
_imwvu2mn.StrengthPlan => 'StrengthPlan',
|
||||
_i9m2f53e.WeatherHour => 'WeatherHour',
|
||||
_ieu1vbq0.WeatherRefused => 'WeatherRefused',
|
||||
_i5b9b17z.Workout => 'Workout',
|
||||
_i92mefto.WorkoutSeries => 'WorkoutSeries',
|
||||
_ => null,
|
||||
|
|
@ -1176,6 +1203,10 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return 'SearchRefused';
|
||||
case _imwvu2mn.StrengthPlan():
|
||||
return 'StrengthPlan';
|
||||
case _i9m2f53e.WeatherHour():
|
||||
return 'WeatherHour';
|
||||
case _ieu1vbq0.WeatherRefused():
|
||||
return 'WeatherRefused';
|
||||
case _i5b9b17z.Workout():
|
||||
return 'Workout';
|
||||
case _i92mefto.WorkoutSeries():
|
||||
|
|
@ -1280,6 +1311,12 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (dataClassName == 'StrengthPlan') {
|
||||
return deserialize<_imwvu2mn.StrengthPlan>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'WeatherHour') {
|
||||
return deserialize<_i9m2f53e.WeatherHour>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'WeatherRefused') {
|
||||
return deserialize<_ieu1vbq0.WeatherRefused>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Workout') {
|
||||
return deserialize<_i5b9b17z.Workout>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,3 +10,6 @@ routing:
|
|||
search:
|
||||
- offered:
|
||||
- places:
|
||||
weather:
|
||||
- offered:
|
||||
- hours:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
||||
/// An hour's weather at a place (ADR 0036): what Open-Meteo answers for the
|
||||
/// hour starting at [time], each figure null where it has none.
|
||||
abstract class WeatherHour
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
WeatherHour._({
|
||||
required this.time,
|
||||
this.temperature,
|
||||
this.feelsLike,
|
||||
this.humidity,
|
||||
this.precipitation,
|
||||
this.code,
|
||||
this.wind,
|
||||
this.gusts,
|
||||
this.windFrom,
|
||||
});
|
||||
|
||||
factory WeatherHour({
|
||||
required DateTime time,
|
||||
double? temperature,
|
||||
double? feelsLike,
|
||||
int? humidity,
|
||||
double? precipitation,
|
||||
int? code,
|
||||
double? wind,
|
||||
double? gusts,
|
||||
int? windFrom,
|
||||
}) = _WeatherHourImpl;
|
||||
|
||||
factory WeatherHour.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return WeatherHour(
|
||||
time: _is.DateTimeJsonExtension.fromJson(jsonSerialization['time']),
|
||||
temperature: (jsonSerialization['temperature'] as num?)?.toDouble(),
|
||||
feelsLike: (jsonSerialization['feelsLike'] as num?)?.toDouble(),
|
||||
humidity: jsonSerialization['humidity'] as int?,
|
||||
precipitation: (jsonSerialization['precipitation'] as num?)?.toDouble(),
|
||||
code: jsonSerialization['code'] as int?,
|
||||
wind: (jsonSerialization['wind'] as num?)?.toDouble(),
|
||||
gusts: (jsonSerialization['gusts'] as num?)?.toDouble(),
|
||||
windFrom: jsonSerialization['windFrom'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// The hour's start, in UTC.
|
||||
DateTime time;
|
||||
|
||||
/// At two metres, in °C.
|
||||
double? temperature;
|
||||
|
||||
/// How it felt, in °C.
|
||||
double? feelsLike;
|
||||
|
||||
/// Relative humidity, in percent.
|
||||
int? humidity;
|
||||
|
||||
/// What fell over the hour (rain, showers, snow as water), in mm.
|
||||
double? precipitation;
|
||||
|
||||
/// The WMO weather code (0 clear, 1–3 clouds, 45–48 fog, 51–67 drizzle and
|
||||
/// rain, 71–77 snow, 80–86 showers, 95–99 thunderstorms).
|
||||
int? code;
|
||||
|
||||
/// The wind at ten metres, in m/s, and its gusts.
|
||||
double? wind;
|
||||
|
||||
double? gusts;
|
||||
|
||||
/// Where the wind came from, in degrees from north.
|
||||
int? windFrom;
|
||||
|
||||
/// Returns a shallow copy of this [WeatherHour]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
WeatherHour copyWith({
|
||||
DateTime? time,
|
||||
double? temperature,
|
||||
double? feelsLike,
|
||||
int? humidity,
|
||||
double? precipitation,
|
||||
int? code,
|
||||
double? wind,
|
||||
double? gusts,
|
||||
int? windFrom,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'WeatherHour',
|
||||
'time': time.toJson(),
|
||||
if (temperature != null) 'temperature': temperature,
|
||||
if (feelsLike != null) 'feelsLike': feelsLike,
|
||||
if (humidity != null) 'humidity': humidity,
|
||||
if (precipitation != null) 'precipitation': precipitation,
|
||||
if (code != null) 'code': code,
|
||||
if (wind != null) 'wind': wind,
|
||||
if (gusts != null) 'gusts': gusts,
|
||||
if (windFrom != null) 'windFrom': windFrom,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'WeatherHour',
|
||||
'time': time.toJson(),
|
||||
if (temperature != null) 'temperature': temperature,
|
||||
if (feelsLike != null) 'feelsLike': feelsLike,
|
||||
if (humidity != null) 'humidity': humidity,
|
||||
if (precipitation != null) 'precipitation': precipitation,
|
||||
if (code != null) 'code': code,
|
||||
if (wind != null) 'wind': wind,
|
||||
if (gusts != null) 'gusts': gusts,
|
||||
if (windFrom != null) 'windFrom': windFrom,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _WeatherHourImpl extends WeatherHour {
|
||||
_WeatherHourImpl({
|
||||
required DateTime time,
|
||||
double? temperature,
|
||||
double? feelsLike,
|
||||
int? humidity,
|
||||
double? precipitation,
|
||||
int? code,
|
||||
double? wind,
|
||||
double? gusts,
|
||||
int? windFrom,
|
||||
}) : super._(
|
||||
time: time,
|
||||
temperature: temperature,
|
||||
feelsLike: feelsLike,
|
||||
humidity: humidity,
|
||||
precipitation: precipitation,
|
||||
code: code,
|
||||
wind: wind,
|
||||
gusts: gusts,
|
||||
windFrom: windFrom,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [WeatherHour]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
WeatherHour copyWith({
|
||||
DateTime? time,
|
||||
Object? temperature = _Undefined,
|
||||
Object? feelsLike = _Undefined,
|
||||
Object? humidity = _Undefined,
|
||||
Object? precipitation = _Undefined,
|
||||
Object? code = _Undefined,
|
||||
Object? wind = _Undefined,
|
||||
Object? gusts = _Undefined,
|
||||
Object? windFrom = _Undefined,
|
||||
}) {
|
||||
return WeatherHour(
|
||||
time: time ?? this.time,
|
||||
temperature: temperature is double? ? temperature : this.temperature,
|
||||
feelsLike: feelsLike is double? ? feelsLike : this.feelsLike,
|
||||
humidity: humidity is int? ? humidity : this.humidity,
|
||||
precipitation: precipitation is double?
|
||||
? precipitation
|
||||
: this.precipitation,
|
||||
code: code is int? ? code : this.code,
|
||||
wind: wind is double? ? wind : this.wind,
|
||||
gusts: gusts is double? ? gusts : this.gusts,
|
||||
windFrom: windFrom is int? ? windFrom : this.windFrom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
||||
/// Why the instance answers no weather (ADR 0036): `off` where the operator
|
||||
/// switched it off, `invalid` for a place off the earth or a span it doesn't
|
||||
/// take (empty, longer than `WorkoutWeather.maxSpan`, before 1940 or more
|
||||
/// than a day ahead), `tooOften` beyond `WorkoutWeather.perMinute` requests
|
||||
/// a minute, `unreachable` where Open-Meteo didn't answer.
|
||||
abstract class WeatherRefused
|
||||
implements
|
||||
_is.SerializableException,
|
||||
_is.SerializableModel,
|
||||
_is.ProtocolSerialization {
|
||||
WeatherRefused._({required this.reason});
|
||||
|
||||
factory WeatherRefused({required String reason}) = _WeatherRefusedImpl;
|
||||
|
||||
factory WeatherRefused.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return WeatherRefused(reason: jsonSerialization['reason'] as String);
|
||||
}
|
||||
|
||||
String reason;
|
||||
|
||||
/// Returns a shallow copy of this [WeatherRefused]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
WeatherRefused copyWith({String? reason});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'WeatherRefused',
|
||||
'reason': reason,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'WeatherRefused',
|
||||
'reason': reason,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'WeatherRefused(reason: $reason)';
|
||||
}
|
||||
}
|
||||
|
||||
class _WeatherRefusedImpl extends WeatherRefused {
|
||||
_WeatherRefusedImpl({required String reason}) : super._(reason: reason);
|
||||
|
||||
/// Returns a shallow copy of this [WeatherRefused]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
WeatherRefused copyWith({String? reason}) {
|
||||
return WeatherRefused(reason: reason ?? this.reason);
|
||||
}
|
||||
}
|
||||
133
apps/train/halkyon_train_server/lib/src/weather/open_meteo.dart
Normal file
133
apps/train/halkyon_train_server/lib/src/weather/open_meteo.dart
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
|
||||
/// Answers the weather's hours (ADR 0036): Open-Meteo, or a fake in tests.
|
||||
/// Answers Open-Meteo's JSON, else throws [OpenMeteoFailed].
|
||||
abstract interface class WeatherSource {
|
||||
/// The hours of the UTC dates [from] to [to] at [lat], [lon], from the
|
||||
/// archive where [archive], else from the forecast API.
|
||||
Future<Map<String, Object?>> hourly({
|
||||
required double lat,
|
||||
required double lon,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
required bool archive,
|
||||
});
|
||||
}
|
||||
|
||||
/// Open-Meteo didn't answer, or not with its JSON.
|
||||
final class const OpenMeteoFailed(final String reason) implements Exception {
|
||||
@override
|
||||
String toString() => 'OpenMeteoFailed: $reason';
|
||||
}
|
||||
|
||||
/// What Open-Meteo is asked for each hour, in [WeatherHour]'s order.
|
||||
const hourlyVariables = [
|
||||
'temperature_2m',
|
||||
'apparent_temperature',
|
||||
'relative_humidity_2m',
|
||||
'precipitation',
|
||||
'weather_code',
|
||||
'wind_speed_10m',
|
||||
'wind_gusts_10m',
|
||||
'wind_direction_10m',
|
||||
];
|
||||
|
||||
/// Open-Meteo over HTTPS: its free hosts, or with [apiKey] its paid ones
|
||||
/// (`customer-api.open-meteo.com`, `customer-archive-api.open-meteo.com`),
|
||||
/// the same requests with the key.
|
||||
class HttpOpenMeteo({final String? apiKey}) implements WeatherSource {
|
||||
static const _timeout = Duration(seconds: 10);
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> hourly({
|
||||
required double lat,
|
||||
required double lon,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
required bool archive,
|
||||
}) async {
|
||||
final paid = apiKey != null;
|
||||
final host = switch ((archive, paid)) {
|
||||
(true, false) => 'archive-api.open-meteo.com',
|
||||
(true, true) => 'customer-archive-api.open-meteo.com',
|
||||
(false, false) => 'api.open-meteo.com',
|
||||
(false, true) => 'customer-api.open-meteo.com',
|
||||
};
|
||||
String date(DateTime day) => day.toIso8601String().substring(0, 10);
|
||||
final uri = Uri.https(host, archive ? 'v1/archive' : 'v1/forecast', {
|
||||
'latitude': '$lat',
|
||||
'longitude': '$lon',
|
||||
'start_date': date(from),
|
||||
'end_date': date(to),
|
||||
'hourly': hourlyVariables.join(','),
|
||||
'timezone': 'GMT',
|
||||
'timeformat': 'unixtime',
|
||||
'wind_speed_unit': 'ms',
|
||||
'apikey': ?apiKey,
|
||||
});
|
||||
final client = HttpClient()..connectionTimeout = _timeout;
|
||||
try {
|
||||
final HttpClientResponse response;
|
||||
final String body;
|
||||
try {
|
||||
response = await (await client.getUrl(uri)).close().timeout(_timeout);
|
||||
body = await utf8.decodeStream(response).timeout(_timeout);
|
||||
} on Object catch (error) {
|
||||
throw OpenMeteoFailed(error.runtimeType.toString());
|
||||
}
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw OpenMeteoFailed('HTTP ${response.statusCode}');
|
||||
}
|
||||
try {
|
||||
return jsonDecode(body) as Map<String, Object?>;
|
||||
} on Object {
|
||||
throw const OpenMeteoFailed('no JSON');
|
||||
}
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hours in Open-Meteo's [json] (`timeformat=unixtime`), in its order:
|
||||
/// each figure from its own array at the hour's index, null where it is
|
||||
/// missing or not a number; an answer without its times has none.
|
||||
List<WeatherHour> hoursOf(Map<String, Object?> json) {
|
||||
final hourly = switch (json['hourly']) {
|
||||
final Map<String, Object?> hourly => hourly,
|
||||
_ => const <String, Object?>{},
|
||||
};
|
||||
final times = switch (hourly['time']) {
|
||||
final List<Object?> times => times,
|
||||
_ => const <Object?>[],
|
||||
};
|
||||
num? at(String variable, int index) => switch (hourly[variable]) {
|
||||
final List<Object?> values when index < values.length =>
|
||||
switch (values[index]) {
|
||||
final num value when value.isFinite => value,
|
||||
_ => null,
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
return [
|
||||
for (final (index, time) in times.indexed)
|
||||
if (time case final int seconds)
|
||||
WeatherHour(
|
||||
time: DateTime.fromMillisecondsSinceEpoch(
|
||||
seconds * 1000,
|
||||
isUtc: true,
|
||||
),
|
||||
temperature: at('temperature_2m', index)?.toDouble(),
|
||||
feelsLike: at('apparent_temperature', index)?.toDouble(),
|
||||
humidity: at('relative_humidity_2m', index)?.round(),
|
||||
precipitation: at('precipitation', index)?.toDouble(),
|
||||
code: at('weather_code', index)?.round(),
|
||||
wind: at('wind_speed_10m', index)?.toDouble(),
|
||||
gusts: at('wind_gusts_10m', index)?.toDouble(),
|
||||
windFrom: at('wind_direction_10m', index)?.round(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/weather/workout_weather.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// A workout's weather from Open-Meteo (ADR 0036), for those signed in: the
|
||||
/// server asks in their place, so Open-Meteo never sees their address.
|
||||
/// Neither the place nor the weather is kept or logged. Refusals are
|
||||
/// `WeatherRefused`.
|
||||
class WeatherEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
||||
/// Whether the instance answers the weather.
|
||||
Future<bool> offered(Session session) async =>
|
||||
WorkoutWeather.of(session.serverpod).source != null;
|
||||
|
||||
/// The hours from [from] to [to] at [lat], [lon] (rounded to about a
|
||||
/// kilometre). Refused where the operator switched the weather off, for a
|
||||
/// place off the earth, a span empty, longer than `WorkoutWeather.maxSpan`,
|
||||
/// before 1940 or more than a day ahead, beyond `WorkoutWeather.perMinute`
|
||||
/// requests a minute, and where Open-Meteo doesn't answer.
|
||||
Future<List<WeatherHour>> hours(
|
||||
Session session, {
|
||||
required double lat,
|
||||
required double lon,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
}) => WorkoutWeather.of(session.serverpod).hours(
|
||||
session.authenticated!.authUserId,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
from: from,
|
||||
to: to,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
### An hour's weather at a place (ADR 0036): what Open-Meteo answers for the
|
||||
### hour starting at [time], each figure null where it has none.
|
||||
class: WeatherHour
|
||||
fields:
|
||||
### The hour's start, in UTC.
|
||||
time: DateTime
|
||||
### At two metres, in °C.
|
||||
temperature: double?
|
||||
### How it felt, in °C.
|
||||
feelsLike: double?
|
||||
### Relative humidity, in percent.
|
||||
humidity: int?
|
||||
### What fell over the hour (rain, showers, snow as water), in mm.
|
||||
precipitation: double?
|
||||
### The WMO weather code (0 clear, 1–3 clouds, 45–48 fog, 51–67 drizzle and
|
||||
### rain, 71–77 snow, 80–86 showers, 95–99 thunderstorms).
|
||||
code: int?
|
||||
### The wind at ten metres, in m/s, and its gusts.
|
||||
wind: double?
|
||||
gusts: double?
|
||||
### Where the wind came from, in degrees from north.
|
||||
windFrom: int?
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
### Why the instance answers no weather (ADR 0036): `off` where the operator
|
||||
### switched it off, `invalid` for a place off the earth or a span it doesn't
|
||||
### take (empty, longer than `WorkoutWeather.maxSpan`, before 1940 or more
|
||||
### than a day ahead), `tooOften` beyond `WorkoutWeather.perMinute` requests
|
||||
### a minute, `unreachable` where Open-Meteo didn't answer.
|
||||
exception: WeatherRefused
|
||||
fields:
|
||||
reason: String
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/weather/open_meteo.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// A workout's weather on this instance (ADR 0036), from Open-Meteo unless
|
||||
/// the operator switched it off, for a person as often as [perMinute]
|
||||
/// allows. Neither the place nor the weather is kept or logged; the counts
|
||||
/// live in memory alone.
|
||||
class WorkoutWeather({final WeatherSource? source}) {
|
||||
/// The weather of [pod], made the first time it is asked for: off where
|
||||
/// the operator sets `WEATHER=off`, else Open-Meteo, its paid hosts where
|
||||
/// `OPEN_METEO_API_KEY` is set. Tests set their own.
|
||||
factory WorkoutWeather.of(Serverpod pod) => _ofPod[pod] ??= WorkoutWeather(
|
||||
source: switch (Platform.environment['WEATHER']?.trim().toLowerCase()) {
|
||||
'off' => null,
|
||||
_ => HttpOpenMeteo(
|
||||
apiKey: switch (Platform.environment['OPEN_METEO_API_KEY']?.trim()) {
|
||||
final key? when key.isNotEmpty => key,
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
static final _ofPod = Expando<WorkoutWeather>('WorkoutWeather');
|
||||
|
||||
/// Makes [weather] the one of [pod].
|
||||
static void use(Serverpod pod, WorkoutWeather weather) =>
|
||||
_ofPod[pod] = weather;
|
||||
|
||||
/// The longest span asked for at once.
|
||||
static const maxSpan = Duration(hours: 48);
|
||||
|
||||
/// The most requests a person may make in a minute: a workout's page asks
|
||||
/// once as it opens.
|
||||
static const perMinute = 30;
|
||||
|
||||
/// Spans that ended longer ago than this are the archive's (ERA5, five
|
||||
/// days behind); the forecast API holds three months back.
|
||||
static const archiveAfter = Duration(days: 7);
|
||||
|
||||
/// The earliest the archive holds.
|
||||
static final earliest = DateTime.utc(1940);
|
||||
|
||||
final _asked = HashMap<UuidValue, Queue<DateTime>>();
|
||||
|
||||
/// The hours from the one [from] falls in to the one [to] falls in, at
|
||||
/// [lat], [lon] rounded to two decimals, for [person]. Refused
|
||||
/// ([WeatherRefused]) where it is off, for a place or a span it doesn't
|
||||
/// take, beyond [perMinute], where Open-Meteo doesn't answer.
|
||||
Future<List<WeatherHour>> hours(
|
||||
UuidValue person, {
|
||||
required double lat,
|
||||
required double lon,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
}) async {
|
||||
final source = this.source;
|
||||
if (source == null) {
|
||||
throw WeatherRefused(reason: 'off');
|
||||
}
|
||||
final now = clock.now().toUtc();
|
||||
final start = from.toUtc();
|
||||
final end = to.toUtc();
|
||||
if (!lat.isFinite ||
|
||||
!lon.isFinite ||
|
||||
lat.abs() > 90 ||
|
||||
lon.abs() > 180 ||
|
||||
!end.isAfter(start) ||
|
||||
end.difference(start) > maxSpan ||
|
||||
start.isBefore(earliest) ||
|
||||
end.isAfter(now.add(const Duration(days: 1)))) {
|
||||
throw WeatherRefused(reason: 'invalid');
|
||||
}
|
||||
_count(person, now);
|
||||
final firstHour = DateTime.utc(
|
||||
start.year,
|
||||
start.month,
|
||||
start.day,
|
||||
start.hour,
|
||||
);
|
||||
final List<WeatherHour> hours;
|
||||
try {
|
||||
hours = hoursOf(
|
||||
await source.hourly(
|
||||
lat: _rounded(lat),
|
||||
lon: _rounded(lon),
|
||||
from: DateTime.utc(start.year, start.month, start.day),
|
||||
to: DateTime.utc(end.year, end.month, end.day),
|
||||
archive: now.difference(end) > archiveAfter,
|
||||
),
|
||||
);
|
||||
} on OpenMeteoFailed {
|
||||
throw WeatherRefused(reason: 'unreachable');
|
||||
}
|
||||
return [
|
||||
for (final hour in hours)
|
||||
if (!hour.time.isBefore(firstHour) && !hour.time.isAfter(end)) hour,
|
||||
];
|
||||
}
|
||||
|
||||
static double _rounded(double degrees) => (degrees * 100).round() / 100;
|
||||
|
||||
/// Counts a request of [person]'s, refusing it beyond [perMinute] in the
|
||||
/// last minute.
|
||||
void _count(UuidValue person, DateTime now) {
|
||||
final asked = _asked.putIfAbsent(person, Queue.new);
|
||||
while (asked.isNotEmpty &&
|
||||
now.difference(asked.first) >= const Duration(minutes: 1)) {
|
||||
asked.removeFirst();
|
||||
}
|
||||
if (asked.length >= perMinute) {
|
||||
throw WeatherRefused(reason: 'tooOften');
|
||||
}
|
||||
asked.addLast(now);
|
||||
// Those who asked nothing for a minute are forgotten.
|
||||
_asked.removeWhere(
|
||||
(_, times) =>
|
||||
times.isEmpty ||
|
||||
now.difference(times.last) >= const Duration(minutes: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ import 'package:halkyon_train_server/src/generated/routing/route_profile.dart'
|
|||
as _iex9meso;
|
||||
import 'package:halkyon_train_server/src/generated/search/place.dart'
|
||||
as _i6fosizj;
|
||||
import 'package:halkyon_train_server/src/generated/weather/weather_hour.dart'
|
||||
as _io7n438g;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_test/serverpod_test.dart' as _ist;
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
|
|
@ -172,6 +174,8 @@ class TestEndpoints {
|
|||
late final _RoutingEndpoint routing;
|
||||
|
||||
late final _SearchEndpoint search;
|
||||
|
||||
late final _WeatherEndpoint weather;
|
||||
}
|
||||
|
||||
class _InternalTestEndpoints extends TestEndpoints
|
||||
|
|
@ -197,6 +201,10 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
weather = _WeatherEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -506,6 +514,84 @@ class _SearchEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
class _WeatherEndpoint {
|
||||
_WeatherEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<bool> offered(_ist.TestSessionBuilder sessionBuilder) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'weather',
|
||||
method: 'offered',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'weather',
|
||||
methodName: 'offered',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<bool>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<List<_io7n438g.WeatherHour>> hours(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required double lat,
|
||||
required double lon,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'weather',
|
||||
method: 'hours',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'weather',
|
||||
methodName: 'hours',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'from': from,
|
||||
'to': to,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<List<_io7n438g.WeatherHour>>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _PruningFutureCall {
|
||||
Future<void> pruneAccounts(_ist.TestSessionBuilder sessionBuilder) async {
|
||||
var _localUniqueSession =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/weather/open_meteo.dart';
|
||||
import 'package:halkyon_train_server/src/weather/workout_weather.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../unit/weather/open_meteo_test.dart' show openMeteoAnswer;
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
typedef _Asked = ({
|
||||
double lat,
|
||||
double lon,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
bool archive,
|
||||
});
|
||||
|
||||
/// Open-Meteo as the tests want it: a day's hours from midnight of the date
|
||||
/// asked for, and what it was asked.
|
||||
class _FakeOpenMeteo implements WeatherSource {
|
||||
final asked = <_Asked>[];
|
||||
bool failing = false;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> hourly({
|
||||
required double lat,
|
||||
required double lon,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
required bool archive,
|
||||
}) async {
|
||||
asked.add((lat: lat, lon: lon, from: from, to: to, archive: archive));
|
||||
if (failing) {
|
||||
throw const OpenMeteoFailed('down');
|
||||
}
|
||||
return openMeteoAnswer(from, 24 * (to.difference(from).inDays + 1));
|
||||
}
|
||||
}
|
||||
|
||||
Matcher _refused(String reason) =>
|
||||
throwsA(isA<WeatherRefused>().having((r) => r.reason, 'reason', reason));
|
||||
|
||||
/// A workout's weather from Open-Meteo (ADR 0036): for the people signed in,
|
||||
/// unless the operator switched it off, its place rounded, within a limit a
|
||||
/// minute.
|
||||
void main() {
|
||||
withServerpod("A workout's weather", (sessionBuilder, endpoints) {
|
||||
late _FakeOpenMeteo openMeteo;
|
||||
final now = DateTime.utc(2026, 11, 2, 12);
|
||||
final alice = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
);
|
||||
final bob = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
void useWeather(WorkoutWeather weather) =>
|
||||
WorkoutWeather.use(sessionBuilder.build().serverpod, weather);
|
||||
|
||||
Future<List<WeatherHour>> ask(
|
||||
TestSessionBuilder session, {
|
||||
double lat = 52.47123,
|
||||
double lon = 13.40456,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
}) => withClock(
|
||||
Clock.fixed(now),
|
||||
() => endpoints.weather.hours(
|
||||
session,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
from: from ?? DateTime.utc(2026, 11, 2, 7, 40),
|
||||
to: to ?? DateTime.utc(2026, 11, 2, 9, 5),
|
||||
),
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
openMeteo = _FakeOpenMeteo();
|
||||
useWeather(WorkoutWeather(source: openMeteo));
|
||||
});
|
||||
|
||||
test('answers the hours over a recent workout from the forecast, the '
|
||||
'place rounded to about a kilometre', () async {
|
||||
expect(await endpoints.weather.offered(alice), isTrue);
|
||||
final hours = await ask(alice);
|
||||
expect(
|
||||
[for (final hour in hours) hour.time.hour],
|
||||
[7, 8, 9],
|
||||
reason: 'from the hour it started in to the one it ended in',
|
||||
);
|
||||
final [asked] = openMeteo.asked;
|
||||
expect((asked.lat, asked.lon), (52.47, 13.4));
|
||||
expect(
|
||||
(asked.from, asked.to),
|
||||
(
|
||||
DateTime.utc(2026, 11, 2),
|
||||
DateTime.utc(2026, 11, 2),
|
||||
),
|
||||
);
|
||||
expect(asked.archive, isFalse);
|
||||
});
|
||||
|
||||
test('asks the archive for a workout more than a week ago, and every '
|
||||
'date a workout over midnight runs over', () async {
|
||||
final hours = await ask(
|
||||
alice,
|
||||
from: DateTime.utc(2025, 6, 21, 22, 30),
|
||||
to: DateTime.utc(2025, 6, 22, 1, 10),
|
||||
);
|
||||
expect([for (final hour in hours) hour.time.hour], [22, 23, 0, 1]);
|
||||
final [asked] = openMeteo.asked;
|
||||
expect(asked.archive, isTrue);
|
||||
expect(
|
||||
(asked.from, asked.to),
|
||||
(
|
||||
DateTime.utc(2025, 6, 21),
|
||||
DateTime.utc(2025, 6, 22),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('answers nobody signed in', () async {
|
||||
final nobody = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
await expectLater(
|
||||
ask(nobody),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.weather.offered(nobody),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
expect(openMeteo.asked, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses every request where the operator switched it off', () async {
|
||||
useWeather(WorkoutWeather());
|
||||
expect(await endpoints.weather.offered(alice), isFalse);
|
||||
await expectLater(ask(alice), _refused('off'));
|
||||
});
|
||||
|
||||
test('refuses a place off the earth and a span it does not take, '
|
||||
'without asking Open-Meteo', () async {
|
||||
for (final (lat, lon) in [(91.0, 0.0), (0.0, -181.0)]) {
|
||||
await expectLater(ask(alice, lat: lat, lon: lon), _refused('invalid'));
|
||||
}
|
||||
final start = DateTime.utc(2026, 11, 1, 8);
|
||||
for (final (from, to) in [
|
||||
(start, start),
|
||||
(start, start.subtract(const Duration(hours: 1))),
|
||||
(start, start.add(WorkoutWeather.maxSpan + const Duration(minutes: 1))),
|
||||
(DateTime.utc(1939, 12, 31, 23), DateTime.utc(1940, 1, 1, 1)),
|
||||
(
|
||||
now.add(const Duration(days: 1)),
|
||||
now.add(const Duration(days: 1, hours: 1)),
|
||||
),
|
||||
]) {
|
||||
await expectLater(
|
||||
ask(alice, from: from, to: to),
|
||||
_refused('invalid'),
|
||||
reason: '$from to $to',
|
||||
);
|
||||
}
|
||||
expect(openMeteo.asked, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses a person beyond the limit a minute, not others', () async {
|
||||
for (var i = 0; i < WorkoutWeather.perMinute; i++) {
|
||||
await ask(alice);
|
||||
}
|
||||
await expectLater(ask(alice), _refused('tooOften'));
|
||||
await ask(bob);
|
||||
});
|
||||
|
||||
test("says Open-Meteo didn't answer where it fails", () async {
|
||||
openMeteo.failing = true;
|
||||
await expectLater(ask(alice), _refused('unreachable'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import 'package:halkyon_train_server/src/weather/open_meteo.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Open-Meteo's answer (`timeformat=unixtime`) for [hours] starting at
|
||||
/// [start], each hour's figures as [figures] gives them.
|
||||
Map<String, Object?> openMeteoAnswer(
|
||||
DateTime start,
|
||||
int hours, {
|
||||
Object? Function(String variable, int hour)? figures,
|
||||
}) => {
|
||||
'latitude': 52.52,
|
||||
'longitude': 13.42,
|
||||
'utc_offset_seconds': 0,
|
||||
'timezone': 'GMT',
|
||||
'hourly': {
|
||||
'time': [
|
||||
for (var i = 0; i < hours; i++)
|
||||
start.add(Duration(hours: i)).millisecondsSinceEpoch ~/ 1000,
|
||||
],
|
||||
for (final variable in hourlyVariables)
|
||||
variable: [
|
||||
for (var i = 0; i < hours; i++) (figures ?? _figures)(variable, i),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Object? _figures(String variable, int hour) => switch (variable) {
|
||||
'temperature_2m' => 12.5 + hour,
|
||||
'apparent_temperature' => 10.1 + hour,
|
||||
'relative_humidity_2m' => 80,
|
||||
'precipitation' => 0.2,
|
||||
'weather_code' => 61,
|
||||
'wind_speed_10m' => 4.2,
|
||||
'wind_gusts_10m' => 9.8,
|
||||
'wind_direction_10m' => 225,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Open-Meteo's hours read (ADR 0036).
|
||||
void main() {
|
||||
test('reads each hour with its figures, in UTC', () {
|
||||
final start = DateTime.utc(2026, 10, 30, 7);
|
||||
final [first, second] = hoursOf(openMeteoAnswer(start, 2));
|
||||
expect(first.time, start);
|
||||
expect(first.time.isUtc, isTrue);
|
||||
expect(first.temperature, 12.5);
|
||||
expect(first.feelsLike, 10.1);
|
||||
expect(first.humidity, 80);
|
||||
expect(first.precipitation, 0.2);
|
||||
expect(first.code, 61);
|
||||
expect(first.wind, 4.2);
|
||||
expect(first.gusts, 9.8);
|
||||
expect(first.windFrom, 225);
|
||||
expect(second.time, start.add(const Duration(hours: 1)));
|
||||
expect(second.temperature, 13.5);
|
||||
});
|
||||
|
||||
test('leaves a figure out where it is missing, short or not a number', () {
|
||||
final answer = openMeteoAnswer(
|
||||
DateTime.utc(2026, 10, 30, 7),
|
||||
2,
|
||||
figures: (variable, hour) =>
|
||||
variable == 'temperature_2m' && hour == 1 ? null : 'x',
|
||||
);
|
||||
(answer['hourly']! as Map<String, Object?>)['wind_speed_10m'] = [3.0];
|
||||
final [first, second] = hoursOf(answer);
|
||||
expect(first.temperature, isNull);
|
||||
expect(first.wind, 3.0);
|
||||
expect(second.wind, isNull);
|
||||
expect(second.temperature, isNull);
|
||||
});
|
||||
|
||||
test('reads no hours from an answer without times', () {
|
||||
expect(hoursOf({}), isEmpty);
|
||||
expect(
|
||||
hoursOf({
|
||||
'hourly': {'time': 'x'},
|
||||
}),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
hoursOf({
|
||||
'hourly': {
|
||||
'time': ['2026-10-30T07:00'],
|
||||
},
|
||||
}),
|
||||
isEmpty,
|
||||
reason: 'only unixtime is asked for',
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue