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
10 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue