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
3 changed files with 2503 additions and 7 deletions

View file

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

View file

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

View file

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