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
10 changed files with 1219 additions and 5 deletions
|
|
@ -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
|
||||
Loading…
Reference in a new issue