feat(todo): Todo's server answers a place's forecast (ADR 0033, D40)

The weather of D40 comes through the instance, as Train's workouts'
(ADR 0036, D1): Todo's server gets a `weather` endpoint, `offered` and
`forecast`, answering a place's hours for the next 16 days from
Open-Meteo for people signed in alone, the place rounded to about a
kilometre, nothing kept or logged, 30 requests a person a minute.
Refusals are `WeatherRefused` (off, invalid, tooOften, unreachable).
`WEATHER=off` and `OPEN_METEO_API_KEY` switch it as they switch Train's,
and production's compose file passes them to Todo's server too.

D40 records the developer's decisions of 10 Nov 2026 on the weather and
the agent's details (what dry, sunny, a temperature range and calm mean
over a task's hours).

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-10 14:43:15 +01:00
commit a91aa98817
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 1219 additions and 5 deletions

File diff suppressed because one or more lines are too long

View file

@ -19,6 +19,8 @@ import 'package:halkyon_todo_client/src/protocol/search/place.dart'
as _i15767wh;
import 'package:halkyon_todo_client/src/protocol/search/place_point.dart'
as _icyq0v3n;
import 'package:halkyon_todo_client/src/protocol/weather/forecast_hour.dart'
as _id8ywefo;
import 'package:http/http.dart' as _i85jenna;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
@ -86,6 +88,41 @@ class EndpointSearch extends _isc.EndpointRef {
);
}
/// A place's forecast from Open-Meteo (ADR 0033, D40), for those signed in:
/// the server asks in their place, so Open-Meteo never sees their address.
/// Neither the place nor the forecast is kept or logged. Refusals are
/// `WeatherRefused`.
/// {@category Endpoint}
class EndpointWeather extends _isc.EndpointRef {
EndpointWeather(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'weather';
/// Whether the instance answers forecasts.
_ida.Future<bool> offered() => caller.callServerEndpoint<bool>(
'weather',
'offered',
{},
);
/// The hours of the next `Forecasts.days` days at [lat], [lon] (rounded to
/// about a kilometre). Refused where the operator switched the weather
/// off, for a place off the earth, beyond `Forecasts.perMinute` requests a
/// minute, and where Open-Meteo doesn't answer.
_ida.Future<List<_id8ywefo.ForecastHour>> forecast({
required double lat,
required double lon,
}) => caller.callServerEndpoint<List<_id8ywefo.ForecastHour>>(
'weather',
'forecast',
{
'lat': lat,
'lon': lon,
},
);
}
class Modules {
Modules(Client client) {
base = _ihk4p5t7.Caller(client);
@ -134,15 +171,21 @@ class Client extends _isc.ServerpodClientShared {
httpClientOverride: httpClientOverride,
) {
search = EndpointSearch(this);
weather = EndpointWeather(this);
modules = Modules(this);
}
late final EndpointSearch search;
late final EndpointWeather weather;
late final Modules modules;
@override
Map<String, _isc.EndpointRef> get endpointRefLookup => {'search': search};
Map<String, _isc.EndpointRef> get endpointRefLookup => {
'search': search,
'weather': weather,
};
@override
Map<String, _isc.ModuleEndpointCaller> get moduleLookup => {

View file

@ -15,6 +15,8 @@ import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
import 'package:halkyon_todo_client/src/protocol/search/place.dart'
as _i15767wh;
import 'package:halkyon_todo_client/src/protocol/weather/forecast_hour.dart'
as _id8ywefo;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
as _iacc;
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
@ -35,6 +37,8 @@ import 'search/place_point.dart' as _itf0ur2u;
import 'search/search_refused.dart' as _ihbxkwke;
import 'settings/todo_settings.dart' as _icak60vd;
import 'task/task.dart' as _ib4f8uyw;
import 'weather/forecast_hour.dart' as _i1l0g3o2;
import 'weather/weather_refused.dart' as _ieu1vbq0;
export 'comment/task_comment.dart';
export 'filter/saved_filter.dart';
export 'label/label.dart';
@ -47,6 +51,8 @@ export 'search/place_point.dart';
export 'search/search_refused.dart';
export 'settings/todo_settings.dart';
export 'task/task.dart';
export 'weather/forecast_hour.dart';
export 'weather/weather_refused.dart';
export 'client.dart';
export 'sync_tables.dart';
@ -673,6 +679,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _ib4f8uyw.Task) {
return _ib4f8uyw.Task.fromJson(data) as T;
}
if (t == _i1l0g3o2.ForecastHour) {
return _i1l0g3o2.ForecastHour.fromJson(data) as T;
}
if (t == _ieu1vbq0.WeatherRefused) {
return _ieu1vbq0.WeatherRefused.fromJson(data) as T;
}
if (t == _isc.getType<_i68ws957.TaskComment?>()) {
return (data != null ? _i68ws957.TaskComment.fromJson(data) : null) as T;
}
@ -710,6 +722,13 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_ib4f8uyw.Task?>()) {
return (data != null ? _ib4f8uyw.Task.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i1l0g3o2.ForecastHour?>()) {
return (data != null ? _i1l0g3o2.ForecastHour.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ieu1vbq0.WeatherRefused?>()) {
return (data != null ? _ieu1vbq0.WeatherRefused.fromJson(data) : null)
as T;
}
if (t == List<_iadh29yw.Place>) {
return (data as List).map((e) => deserialize<_iadh29yw.Place>(e)).toList()
as T;
@ -718,6 +737,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
return (data as List).map((e) => deserialize<_i15767wh.Place>(e)).toList()
as T;
}
if (t == List<_id8ywefo.ForecastHour>) {
return (data as List)
.map((e) => deserialize<_id8ywefo.ForecastHour>(e))
.toList()
as T;
}
try {
return _ihk4p5t7.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {}
@ -750,6 +775,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
_ihbxkwke.SearchRefused => 'SearchRefused',
_icak60vd.TodoSettings => 'TodoSettings',
_ib4f8uyw.Task => 'Task',
_i1l0g3o2.ForecastHour => 'ForecastHour',
_ieu1vbq0.WeatherRefused => 'WeatherRefused',
_ => null,
};
}
@ -791,6 +818,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'TodoSettings';
case _ib4f8uyw.Task():
return 'Task';
case _i1l0g3o2.ForecastHour():
return 'ForecastHour';
case _ieu1vbq0.WeatherRefused():
return 'WeatherRefused';
}
className = _ihk4p5t7.Protocol().getClassNameForObject(data);
if (className != null) {
@ -863,6 +894,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'Task') {
return deserialize<_ib4f8uyw.Task>(data['data']);
}
if (dataClassName == 'ForecastHour') {
return deserialize<_i1l0g3o2.ForecastHour>(data['data']);
}
if (dataClassName == 'WeatherRefused') {
return deserialize<_ieu1vbq0.WeatherRefused>(data['data']);
}
if (dataClassName.startsWith('halkyon_base.')) {
data['className'] = dataClassName.substring(13);
return _ihk4p5t7.Protocol().deserializeByClassName(data);

View file

@ -0,0 +1,168 @@
/* 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_client/serverpod_client.dart' as _isc;
/// An hour of a place's forecast (ADR 0033, D40): what Open-Meteo answers for
/// the hour starting at [time], each figure null where it has none.
abstract class ForecastHour
implements _isc.SerializableModel, _isc.ProtocolSerialization {
ForecastHour._({
required this.time,
this.temperature,
this.precipitation,
this.precipitationChance,
this.code,
this.wind,
this.gusts,
});
factory ForecastHour({
required DateTime time,
double? temperature,
double? precipitation,
int? precipitationChance,
int? code,
double? wind,
double? gusts,
}) = _ForecastHourImpl;
factory ForecastHour.fromJson(Map<String, dynamic> jsonSerialization) {
return ForecastHour(
time: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['time']),
temperature: (jsonSerialization['temperature'] as num?)?.toDouble(),
precipitation: (jsonSerialization['precipitation'] as num?)?.toDouble(),
precipitationChance: jsonSerialization['precipitationChance'] as int?,
code: jsonSerialization['code'] as int?,
wind: (jsonSerialization['wind'] as num?)?.toDouble(),
gusts: (jsonSerialization['gusts'] as num?)?.toDouble(),
);
}
/// The hour's start, in UTC.
DateTime time;
/// At two metres, in °C.
double? temperature;
/// What is to fall over the hour (rain, showers, snow as water), in mm.
double? precipitation;
/// The chance that anything falls, in percent.
int? precipitationChance;
/// 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;
/// Returns a shallow copy of this [ForecastHour]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
ForecastHour copyWith({
DateTime? time,
double? temperature,
double? precipitation,
int? precipitationChance,
int? code,
double? wind,
double? gusts,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'ForecastHour',
'time': time.toJson(),
if (temperature != null) 'temperature': temperature,
if (precipitation != null) 'precipitation': precipitation,
if (precipitationChance != null)
'precipitationChance': precipitationChance,
if (code != null) 'code': code,
if (wind != null) 'wind': wind,
if (gusts != null) 'gusts': gusts,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'ForecastHour',
'time': time.toJson(),
if (temperature != null) 'temperature': temperature,
if (precipitation != null) 'precipitation': precipitation,
if (precipitationChance != null)
'precipitationChance': precipitationChance,
if (code != null) 'code': code,
if (wind != null) 'wind': wind,
if (gusts != null) 'gusts': gusts,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _ForecastHourImpl extends ForecastHour {
_ForecastHourImpl({
required DateTime time,
double? temperature,
double? precipitation,
int? precipitationChance,
int? code,
double? wind,
double? gusts,
}) : super._(
time: time,
temperature: temperature,
precipitation: precipitation,
precipitationChance: precipitationChance,
code: code,
wind: wind,
gusts: gusts,
);
/// Returns a shallow copy of this [ForecastHour]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
ForecastHour copyWith({
DateTime? time,
Object? temperature = _Undefined,
Object? precipitation = _Undefined,
Object? precipitationChance = _Undefined,
Object? code = _Undefined,
Object? wind = _Undefined,
Object? gusts = _Undefined,
}) {
return ForecastHour(
time: time ?? this.time,
temperature: temperature is double? ? temperature : this.temperature,
precipitation: precipitation is double?
? precipitation
: this.precipitation,
precipitationChance: precipitationChance is int?
? precipitationChance
: this.precipitationChance,
code: code is int? ? code : this.code,
wind: wind is double? ? wind : this.wind,
gusts: gusts is double? ? gusts : this.gusts,
);
}
}

View file

@ -0,0 +1,70 @@
/* 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_client/serverpod_client.dart' as _isc;
/// Why the instance answers no forecast (ADR 0033, D40): `off` where the
/// operator switched it off, `invalid` for a place off the earth, `tooOften`
/// beyond `Forecasts.perMinute` requests a minute, `unreachable` where
/// Open-Meteo didn't answer.
abstract class WeatherRefused
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.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.
@_isc.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.
@_isc.useResult
@override
WeatherRefused copyWith({String? reason}) {
return WeatherRefused(reason: reason ?? this.reason);
}
}

View file

@ -24,6 +24,7 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
as _izehhkf5;
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 {
@ -36,6 +37,12 @@ class Endpoints extends _is.EndpointDispatch {
'search',
null,
),
'weather': _i8nd21di.WeatherEndpoint()
..initialize(
server,
'weather',
null,
),
};
connectors['search'] = _is.EndpointConnector(
name: 'search',
@ -109,6 +116,47 @@ 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),
),
'forecast': _is.MethodConnector(
name: 'forecast',
params: {
'lat': _is.ParameterDescription(
name: 'lat',
type: _is.getType<double>(),
nullable: false,
),
'lon': _is.ParameterDescription(
name: 'lon',
type: _is.getType<double>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['weather'] as _i8nd21di.WeatherEndpoint).forecast(
session,
lat: params['lat'],
lon: params['lon'],
),
),
},
);
modules['halkyon_base'] = _ik8molhd.Endpoints()
..initializeEndpoints(server);
modules['serverpod_auth_idp'] = _iais.Endpoints()

View file

@ -15,6 +15,8 @@ import 'package:halkyon_base_server/halkyon_base_server.dart' as _ik8molhd;
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
import 'package:halkyon_todo_server/src/generated/search/place.dart'
as _ih6de0l6;
import 'package:halkyon_todo_server/src/generated/weather/forecast_hour.dart'
as _i9va4aku;
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'
@ -35,6 +37,8 @@ import 'search/place_point.dart' as _itf0ur2u;
import 'search/search_refused.dart' as _ihbxkwke;
import 'settings/todo_settings.dart' as _icak60vd;
import 'task/task.dart' as _ib4f8uyw;
import 'weather/forecast_hour.dart' as _i1l0g3o2;
import 'weather/weather_refused.dart' as _ieu1vbq0;
export 'comment/task_comment.dart';
export 'filter/saved_filter.dart';
export 'label/label.dart';
@ -47,6 +51,8 @@ export 'search/place_point.dart';
export 'search/search_refused.dart';
export 'settings/todo_settings.dart';
export 'task/task.dart';
export 'weather/forecast_hour.dart';
export 'weather/weather_refused.dart';
export 'sync_tables.dart';
class Protocol extends _is.DatabaseSerializationManager {
@ -658,6 +664,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _ib4f8uyw.Task) {
return _ib4f8uyw.Task.fromJson(data) as T;
}
if (t == _i1l0g3o2.ForecastHour) {
return _i1l0g3o2.ForecastHour.fromJson(data) as T;
}
if (t == _ieu1vbq0.WeatherRefused) {
return _ieu1vbq0.WeatherRefused.fromJson(data) as T;
}
if (t == _is.getType<_i68ws957.TaskComment?>()) {
return (data != null ? _i68ws957.TaskComment.fromJson(data) : null) as T;
}
@ -695,6 +707,13 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _is.getType<_ib4f8uyw.Task?>()) {
return (data != null ? _ib4f8uyw.Task.fromJson(data) : null) as T;
}
if (t == _is.getType<_i1l0g3o2.ForecastHour?>()) {
return (data != null ? _i1l0g3o2.ForecastHour.fromJson(data) : null) as T;
}
if (t == _is.getType<_ieu1vbq0.WeatherRefused?>()) {
return (data != null ? _ieu1vbq0.WeatherRefused.fromJson(data) : null)
as T;
}
if (t == List<_iadh29yw.Place>) {
return (data as List).map((e) => deserialize<_iadh29yw.Place>(e)).toList()
as T;
@ -703,6 +722,12 @@ class Protocol extends _is.DatabaseSerializationManager {
return (data as List).map((e) => deserialize<_ih6de0l6.Place>(e)).toList()
as T;
}
if (t == List<_i9va4aku.ForecastHour>) {
return (data as List)
.map((e) => deserialize<_i9va4aku.ForecastHour>(e))
.toList()
as T;
}
try {
return _ik8molhd.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}
@ -738,6 +763,8 @@ class Protocol extends _is.DatabaseSerializationManager {
_ihbxkwke.SearchRefused => 'SearchRefused',
_icak60vd.TodoSettings => 'TodoSettings',
_ib4f8uyw.Task => 'Task',
_i1l0g3o2.ForecastHour => 'ForecastHour',
_ieu1vbq0.WeatherRefused => 'WeatherRefused',
_ => null,
};
}
@ -779,6 +806,10 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'TodoSettings';
case _ib4f8uyw.Task():
return 'Task';
case _i1l0g3o2.ForecastHour():
return 'ForecastHour';
case _ieu1vbq0.WeatherRefused():
return 'WeatherRefused';
}
className = _ik8molhd.Protocol().getClassNameForObject(data);
if (className != null) {
@ -855,6 +886,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'Task') {
return deserialize<_ib4f8uyw.Task>(data['data']);
}
if (dataClassName == 'ForecastHour') {
return deserialize<_i1l0g3o2.ForecastHour>(data['data']);
}
if (dataClassName == 'WeatherRefused') {
return deserialize<_ieu1vbq0.WeatherRefused>(data['data']);
}
if (dataClassName.startsWith('halkyon_base.')) {
data['className'] = dataClassName.substring(13);
return _ik8molhd.Protocol().deserializeByClassName(data);

View file

@ -2,3 +2,6 @@ search:
- offered:
- places:
- nearby:
weather:
- offered:
- forecast:

View file

@ -0,0 +1,168 @@
/* 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 of a place's forecast (ADR 0033, D40): what Open-Meteo answers for
/// the hour starting at [time], each figure null where it has none.
abstract class ForecastHour
implements _is.SerializableModel, _is.ProtocolSerialization {
ForecastHour._({
required this.time,
this.temperature,
this.precipitation,
this.precipitationChance,
this.code,
this.wind,
this.gusts,
});
factory ForecastHour({
required DateTime time,
double? temperature,
double? precipitation,
int? precipitationChance,
int? code,
double? wind,
double? gusts,
}) = _ForecastHourImpl;
factory ForecastHour.fromJson(Map<String, dynamic> jsonSerialization) {
return ForecastHour(
time: _is.DateTimeJsonExtension.fromJson(jsonSerialization['time']),
temperature: (jsonSerialization['temperature'] as num?)?.toDouble(),
precipitation: (jsonSerialization['precipitation'] as num?)?.toDouble(),
precipitationChance: jsonSerialization['precipitationChance'] as int?,
code: jsonSerialization['code'] as int?,
wind: (jsonSerialization['wind'] as num?)?.toDouble(),
gusts: (jsonSerialization['gusts'] as num?)?.toDouble(),
);
}
/// The hour's start, in UTC.
DateTime time;
/// At two metres, in °C.
double? temperature;
/// What is to fall over the hour (rain, showers, snow as water), in mm.
double? precipitation;
/// The chance that anything falls, in percent.
int? precipitationChance;
/// 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;
/// Returns a shallow copy of this [ForecastHour]
/// with some or all fields replaced by the given arguments.
@_is.useResult
ForecastHour copyWith({
DateTime? time,
double? temperature,
double? precipitation,
int? precipitationChance,
int? code,
double? wind,
double? gusts,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'ForecastHour',
'time': time.toJson(),
if (temperature != null) 'temperature': temperature,
if (precipitation != null) 'precipitation': precipitation,
if (precipitationChance != null)
'precipitationChance': precipitationChance,
if (code != null) 'code': code,
if (wind != null) 'wind': wind,
if (gusts != null) 'gusts': gusts,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'ForecastHour',
'time': time.toJson(),
if (temperature != null) 'temperature': temperature,
if (precipitation != null) 'precipitation': precipitation,
if (precipitationChance != null)
'precipitationChance': precipitationChance,
if (code != null) 'code': code,
if (wind != null) 'wind': wind,
if (gusts != null) 'gusts': gusts,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _ForecastHourImpl extends ForecastHour {
_ForecastHourImpl({
required DateTime time,
double? temperature,
double? precipitation,
int? precipitationChance,
int? code,
double? wind,
double? gusts,
}) : super._(
time: time,
temperature: temperature,
precipitation: precipitation,
precipitationChance: precipitationChance,
code: code,
wind: wind,
gusts: gusts,
);
/// Returns a shallow copy of this [ForecastHour]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
ForecastHour copyWith({
DateTime? time,
Object? temperature = _Undefined,
Object? precipitation = _Undefined,
Object? precipitationChance = _Undefined,
Object? code = _Undefined,
Object? wind = _Undefined,
Object? gusts = _Undefined,
}) {
return ForecastHour(
time: time ?? this.time,
temperature: temperature is double? ? temperature : this.temperature,
precipitation: precipitation is double?
? precipitation
: this.precipitation,
precipitationChance: precipitationChance is int?
? precipitationChance
: this.precipitationChance,
code: code is int? ? code : this.code,
wind: wind is double? ? wind : this.wind,
gusts: gusts is double? ? gusts : this.gusts,
);
}
}

View file

@ -0,0 +1,70 @@
/* 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 forecast (ADR 0033, D40): `off` where the
/// operator switched it off, `invalid` for a place off the earth, `tooOften`
/// beyond `Forecasts.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,18 @@
### An hour of a place's forecast (ADR 0033, D40): what Open-Meteo answers for
### the hour starting at [time], each figure null where it has none.
class: ForecastHour
fields:
### The hour's start, in UTC.
time: DateTime
### At two metres, in °C.
temperature: double?
### What is to fall over the hour (rain, showers, snow as water), in mm.
precipitation: double?
### The chance that anything falls, in percent.
precipitationChance: int?
### 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?

View file

@ -0,0 +1,94 @@
import 'dart:collection';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_todo_server/src/generated/protocol.dart';
import 'package:halkyon_todo_server/src/weather/open_meteo.dart';
import 'package:serverpod/serverpod.dart';
/// Forecasts on this instance (ADR 0033, D40), from Open-Meteo unless the
/// operator switched them off, for a person as often as [perMinute] allows.
/// Neither the place nor the forecast is kept or logged; the counts live in
/// memory alone.
class Forecasts({final ForecastSource? source}) {
/// The forecasts of [pod], made the first time they are asked for: off
/// where the operator sets `WEATHER=off`, else Open-Meteo, its paid host
/// where `OPEN_METEO_API_KEY` is set. Tests set their own.
factory Forecasts.of(Serverpod pod) => _ofPod[pod] ??= Forecasts(
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<Forecasts>('Forecasts');
/// Makes [forecasts] those of [pod].
static void use(Serverpod pod, Forecasts forecasts) =>
_ofPod[pod] = forecasts;
/// How many days a forecast runs over: Open-Meteo's furthest.
static const days = 16;
/// The most requests a person may make in a minute: a device asks once a
/// place as the app opens and hourly, for ten places at most.
static const perMinute = 30;
final _asked = HashMap<UuidValue, Queue<DateTime>>();
/// The hours of the next [days] days at [lat], [lon] rounded to two
/// decimals, for [person]. Refused ([WeatherRefused]) where it is off, for
/// a place off the earth, beyond [perMinute], where Open-Meteo doesn't
/// answer.
Future<List<ForecastHour>> forecast(
UuidValue person, {
required double lat,
required double lon,
}) async {
final source = this.source;
if (source == null) {
throw WeatherRefused(reason: 'off');
}
if (!lat.isFinite || !lon.isFinite || lat.abs() > 90 || lon.abs() > 180) {
throw WeatherRefused(reason: 'invalid');
}
_count(person, clock.now());
try {
return forecastOf(
await source.forecast(
lat: _rounded(lat),
lon: _rounded(lon),
days: days,
),
);
} on OpenMeteoFailed {
throw WeatherRefused(reason: 'unreachable');
}
}
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),
);
}
}

View file

@ -0,0 +1,119 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_todo_server/src/generated/protocol.dart';
/// Answers a place's forecast (ADR 0033, D40): Open-Meteo, or a fake in
/// tests. Answers Open-Meteo's JSON, else throws [OpenMeteoFailed].
abstract interface class ForecastSource {
/// The hours of the next [days] days at [lat], [lon].
Future<Map<String, Object?>> forecast({
required double lat,
required double lon,
required int days,
});
}
/// 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 [ForecastHour]'s order.
const hourlyVariables = [
'temperature_2m',
'precipitation',
'precipitation_probability',
'weather_code',
'wind_speed_10m',
'wind_gusts_10m',
];
/// Open-Meteo's forecast over HTTPS: its free host, or with [apiKey] its
/// paid one (`customer-api.open-meteo.com`), the same request with the key,
/// as Train's (ADR 0036).
class HttpOpenMeteo({final String? apiKey}) implements ForecastSource {
static const _timeout = Duration(seconds: 10);
@override
Future<Map<String, Object?>> forecast({
required double lat,
required double lon,
required int days,
}) async {
final uri = Uri.https(
apiKey == null ? 'api.open-meteo.com' : 'customer-api.open-meteo.com',
'v1/forecast',
{
'latitude': '$lat',
'longitude': '$lon',
'forecast_days': '$days',
'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<ForecastHour> forecastOf(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)
ForecastHour(
time: DateTime.fromMillisecondsSinceEpoch(
seconds * 1000,
isUtc: true,
),
temperature: at('temperature_2m', index)?.toDouble(),
precipitation: at('precipitation', index)?.toDouble(),
precipitationChance: at('precipitation_probability', index)?.round(),
code: at('weather_code', index)?.round(),
wind: at('wind_speed_10m', index)?.toDouble(),
gusts: at('wind_gusts_10m', index)?.toDouble(),
),
];
}

View file

@ -0,0 +1,29 @@
import 'package:halkyon_todo_server/src/generated/protocol.dart';
import 'package:halkyon_todo_server/src/weather/forecasts.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// A place's forecast from Open-Meteo (ADR 0033, D40), for those signed in:
/// the server asks in their place, so Open-Meteo never sees their address.
/// Neither the place nor the forecast is kept or logged. Refusals are
/// `WeatherRefused`.
class WeatherEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// Whether the instance answers forecasts.
Future<bool> offered(Session session) async =>
Forecasts.of(session.serverpod).source != null;
/// The hours of the next `Forecasts.days` days at [lat], [lon] (rounded to
/// about a kilometre). Refused where the operator switched the weather
/// off, for a place off the earth, beyond `Forecasts.perMinute` requests a
/// minute, and where Open-Meteo doesn't answer.
Future<List<ForecastHour>> forecast(
Session session, {
required double lat,
required double lon,
}) => Forecasts.of(
session.serverpod,
).forecast(session.authenticated!.authUserId, lat: lat, lon: lon);
}

View file

@ -0,0 +1,7 @@
### Why the instance answers no forecast (ADR 0033, D40): `off` where the
### operator switched it off, `invalid` for a place off the earth, `tooOften`
### beyond `Forecasts.perMinute` requests a minute, `unreachable` where
### Open-Meteo didn't answer.
exception: WeatherRefused
fields:
reason: String

View file

@ -21,6 +21,8 @@ import 'package:halkyon_todo_server/src/generated/search/place.dart'
as _ih6de0l6;
import 'package:halkyon_todo_server/src/generated/search/place_point.dart'
as _iywv9say;
import 'package:halkyon_todo_server/src/generated/weather/forecast_hour.dart'
as _i9va4aku;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_test/serverpod_test.dart' as _ist;
import 'package:halkyon_todo_server/src/generated/protocol.dart';
@ -159,6 +161,8 @@ class TestEndpoints {
late final futureCalls = _FutureCalls();
late final _SearchEndpoint search;
late final _WeatherEndpoint weather;
}
class _InternalTestEndpoints extends TestEndpoints
@ -172,6 +176,10 @@ class _InternalTestEndpoints extends TestEndpoints
endpoints,
serializationManager,
);
weather = _WeatherEndpoint(
endpoints,
serializationManager,
);
}
}
@ -290,6 +298,80 @@ 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<_i9va4aku.ForecastHour>> forecast(
_ist.TestSessionBuilder sessionBuilder, {
required double lat,
required double lon,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'weather',
method: 'forecast',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'weather',
methodName: 'forecast',
parameters: _ist.testObjectToJson({
'lat': lat,
'lon': lon,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<_i9va4aku.ForecastHour>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _PruningFutureCall {
Future<void> pruneAccounts(_ist.TestSessionBuilder sessionBuilder) async {
var _localUniqueSession =

View file

@ -0,0 +1,119 @@
import 'package:clock/clock.dart';
import 'package:halkyon_todo_server/src/generated/protocol.dart';
import 'package:halkyon_todo_server/src/weather/forecasts.dart';
import 'package:halkyon_todo_server/src/weather/open_meteo.dart';
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
import '../../unit/weather/open_meteo_test.dart' show openMeteoForecast;
import '../test_tools/serverpod_test_tools.dart';
/// Open-Meteo as the tests want it: the hours of the days asked for from
/// midnight, and what it was asked.
class _FakeOpenMeteo implements ForecastSource {
final asked = <({double lat, double lon, int days})>[];
bool failing = false;
@override
Future<Map<String, Object?>> forecast({
required double lat,
required double lon,
required int days,
}) async {
asked.add((lat: lat, lon: lon, days: days));
if (failing) {
throw const OpenMeteoFailed('down');
}
return openMeteoForecast(DateTime.utc(2026, 11, 10), 24 * days);
}
}
Matcher _refused(String reason) =>
throwsA(isA<WeatherRefused>().having((r) => r.reason, 'reason', reason));
/// A place's forecast from Open-Meteo (ADR 0033, D40): for the people signed
/// in, unless the operator switched it off, its place rounded, within a
/// limit a minute.
void main() {
withServerpod("A place's forecast", (sessionBuilder, endpoints) {
late _FakeOpenMeteo openMeteo;
final now = DateTime.utc(2026, 11, 10, 12);
TestSessionBuilder signedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
);
final alice = signedIn();
final bob = signedIn();
void useForecasts(Forecasts forecasts) =>
Forecasts.use(sessionBuilder.build().serverpod, forecasts);
Future<List<ForecastHour>> ask(
TestSessionBuilder session, {
double lat = 47.54567,
double lon = 9.68321,
}) => withClock(
Clock.fixed(now),
() => endpoints.weather.forecast(session, lat: lat, lon: lon),
);
setUp(() {
openMeteo = _FakeOpenMeteo();
useForecasts(Forecasts(source: openMeteo));
});
test("answers the next 16 days' hours, the place rounded to about a "
'kilometre', () async {
expect(await endpoints.weather.offered(alice), isTrue);
final hours = await ask(alice);
expect(hours, hasLength(24 * Forecasts.days));
expect(hours.first.time, DateTime.utc(2026, 11, 10));
final [asked] = openMeteo.asked;
expect((asked.lat, asked.lon, asked.days), (47.55, 9.68, 16));
});
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 {
useForecasts(Forecasts());
expect(await endpoints.weather.offered(alice), isFalse);
await expectLater(ask(alice), _refused('off'));
});
test('refuses a place off the earth without asking Open-Meteo', () async {
// NaN and infinity can't be sent: the protocol's JSON refuses them.
for (final (lat, lon) in [(91.0, 0.0), (0.0, -181.0)]) {
await expectLater(ask(alice, lat: lat, lon: lon), _refused('invalid'));
}
expect(openMeteo.asked, isEmpty);
});
test('refuses a person beyond the limit a minute, not others', () async {
for (var i = 0; i < Forecasts.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,84 @@
import 'package:halkyon_todo_server/src/weather/open_meteo.dart';
import 'package:test/test.dart';
/// Open-Meteo's forecast (`timeformat=unixtime`) for [hours] starting at
/// [start], each hour's figures as [figures] gives them.
Map<String, Object?> openMeteoForecast(
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,
'precipitation' => 0.2,
'precipitation_probability' => 65,
'weather_code' => 61,
'wind_speed_10m' => 4.2,
'wind_gusts_10m' => 9.8,
_ => null,
};
/// Open-Meteo's forecast read (ADR 0033, D40).
void main() {
test('reads each hour with its figures, in UTC', () {
final start = DateTime.utc(2026, 11, 10);
final [first, second] = forecastOf(openMeteoForecast(start, 2));
expect(first.time, start);
expect(first.time.isUtc, isTrue);
expect(first.temperature, 12.5);
expect(first.precipitation, 0.2);
expect(first.precipitationChance, 65);
expect(first.code, 61);
expect(first.wind, 4.2);
expect(first.gusts, 9.8);
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 = openMeteoForecast(
DateTime.utc(2026, 11, 10),
2,
figures: (variable, hour) => switch ((variable, hour)) {
('temperature_2m', 0) => null,
('weather_code', _) => 'rain',
_ => 1,
},
);
(answer['hourly']! as Map<String, Object?>)['wind_gusts_10m'] = [3];
final [first, second] = forecastOf(answer);
expect(first.temperature, isNull);
expect(first.code, isNull);
expect(first.gusts, 3);
expect(second.gusts, isNull);
expect(second.temperature, 1);
});
test('has no hours where the answer has no times', () {
expect(
forecastOf({
'hourly': {
'temperature_2m': [1],
},
}),
isEmpty,
);
expect(forecastOf({}), isEmpty);
});
}

View file

@ -69,9 +69,10 @@ COMPOSE_FILE=compose.learn.yaml
# The Loop's firmware through the instance (Train's ADR 0030, D40): `off` to stop
# relaying it; empty: on.
#LOOP_FIRMWARE_RELAY=
# Workouts' weather from Open-Meteo (Train's ADR 0036): `off` to stop asking; empty: on,
# on Open-Meteo's free API, which is for non-commercial use alone. An instance with ads
# or subscriptions needs a paid plan: its key.
# Workouts' weather (Train's ADR 0036) and Todo's forecasts (ADR 0033, D40) from
# Open-Meteo: `off` to stop asking; empty: on, on Open-Meteo's free API, which is for
# non-commercial use alone. An instance with ads or subscriptions needs a paid plan:
# its key.
#WEATHER=
#OPEN_METEO_API_KEY=
# Train's map (ADR 0030, D22): a MapLibre style over HTTPS, with its dark one for a

View file

@ -37,6 +37,10 @@ services:
SOURCE_URL: ${SOURCE_URL:-}
DATA_LOCATION: ${DATA_LOCATION:-}
BACKUP_LOCATION: ${BACKUP_LOCATION:-}
# Forecasts from Open-Meteo (ADR 0033, D40), as Train's workouts' weather:
# on unless `off`; a key for its paid host where used commercially.
WEATHER: ${WEATHER:-}
OPEN_METEO_API_KEY: ${OPEN_METEO_API_KEY:-}
networks:
- default
- edge

Some files were not shown because too many files have changed in this diff Show more