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:
parent
85a022af98
commit
a91aa98817
22 changed files with 1219 additions and 5 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -2,3 +2,6 @@ search:
|
|||
- offered:
|
||||
- places:
|
||||
- nearby:
|
||||
weather:
|
||||
- offered:
|
||||
- forecast:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
94
apps/todo/halkyon_todo_server/lib/src/weather/forecasts.dart
Normal file
94
apps/todo/halkyon_todo_server/lib/src/weather/forecasts.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
119
apps/todo/halkyon_todo_server/lib/src/weather/open_meteo.dart
Normal file
119
apps/todo/halkyon_todo_server/lib/src/weather/open_meteo.dart
Normal 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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue