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:
Tizian 「ティツィアーン」 2026-11-02 13:59:41 +01:00
commit 3f7b409e80
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 2503 additions and 7 deletions

View file

@ -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()

View file

@ -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']);
}

View file

@ -10,3 +10,6 @@ routing:
search:
- offered:
- places:
weather:
- offered:
- hours:

View file

@ -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,
);
}
}

View file

@ -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);
}
}

View 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(),
),
];
}

View file

@ -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,
);
}

View file

@ -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?

View file

@ -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

View file

@ -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),
);
}
}