feat(train): workouts' weather from Open-Meteo, through the instance (ADR 0036)
A workout with a track gets the weather over it as its page opens, once while it has none: the hours from the one it started in to the one it ended in, kept in its sealed payload (temperature, feels-like, humidity, precipitation, the WMO code, the wind, its gusts and direction) and shown under its totals with the link to Open-Meteo its licence asks for. Train's server asks Open-Meteo in the person's place (the `weather` endpoint), so Open-Meteo never sees their address: the place rounded to two decimals (also on the phone), a span of at most 48 hours, the forecast API for recent spans and the archive for older ones, for people signed in, at most 30 requests a person a minute, nothing kept or logged. On by default; `WEATHER=off` switches it off, and `OPEN_METEO_API_KEY` moves it to the paid hosts for an instance used commercially. Health Connect's fingerprint leaves the weather out, so keeping it writes nothing again. No table and no migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
508994be3a
commit
3f7b409e80
4 changed files with 2503 additions and 7 deletions
|
|
@ -26,6 +26,8 @@ import 'package:halkyon_train_client/src/protocol/routing/route_profile.dart'
|
|||
as _isvxstnj;
|
||||
import 'package:halkyon_train_client/src/protocol/search/place.dart'
|
||||
as _inm997sc;
|
||||
import 'package:halkyon_train_client/src/protocol/weather/weather_hour.dart'
|
||||
as _ig77jxou;
|
||||
import 'package:http/http.dart' as _i85jenna;
|
||||
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
||||
as _iacc;
|
||||
|
|
@ -179,6 +181,46 @@ class EndpointSearch extends _isc.EndpointRef {
|
|||
);
|
||||
}
|
||||
|
||||
/// A workout's weather from Open-Meteo (ADR 0036), for those signed in: the
|
||||
/// server asks in their place, so Open-Meteo never sees their address.
|
||||
/// Neither the place nor the weather is kept or logged. Refusals are
|
||||
/// `WeatherRefused`.
|
||||
/// {@category Endpoint}
|
||||
class EndpointWeather extends _isc.EndpointRef {
|
||||
EndpointWeather(_isc.EndpointCaller caller) : super(caller);
|
||||
|
||||
@override
|
||||
String get name => 'weather';
|
||||
|
||||
/// Whether the instance answers the weather.
|
||||
_ida.Future<bool> offered() => caller.callServerEndpoint<bool>(
|
||||
'weather',
|
||||
'offered',
|
||||
{},
|
||||
);
|
||||
|
||||
/// The hours from [from] to [to] at [lat], [lon] (rounded to about a
|
||||
/// kilometre). Refused where the operator switched the weather off, for a
|
||||
/// place off the earth, a span empty, longer than `WorkoutWeather.maxSpan`,
|
||||
/// before 1940 or more than a day ahead, beyond `WorkoutWeather.perMinute`
|
||||
/// requests a minute, and where Open-Meteo doesn't answer.
|
||||
_ida.Future<List<_ig77jxou.WeatherHour>> hours({
|
||||
required double lat,
|
||||
required double lon,
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
}) => caller.callServerEndpoint<List<_ig77jxou.WeatherHour>>(
|
||||
'weather',
|
||||
'hours',
|
||||
{
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'from': from,
|
||||
'to': to,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class Modules {
|
||||
Modules(Client client) {
|
||||
base = _ihk4p5t7.Caller(client);
|
||||
|
|
@ -230,6 +272,7 @@ class Client extends _isc.ServerpodClientShared {
|
|||
map = EndpointMap(this);
|
||||
routing = EndpointRouting(this);
|
||||
search = EndpointSearch(this);
|
||||
weather = EndpointWeather(this);
|
||||
modules = Modules(this);
|
||||
}
|
||||
|
||||
|
|
@ -241,6 +284,8 @@ class Client extends _isc.ServerpodClientShared {
|
|||
|
||||
late final EndpointSearch search;
|
||||
|
||||
late final EndpointWeather weather;
|
||||
|
||||
late final Modules modules;
|
||||
|
||||
@override
|
||||
|
|
@ -249,6 +294,7 @@ class Client extends _isc.ServerpodClientShared {
|
|||
'map': map,
|
||||
'routing': routing,
|
||||
'search': search,
|
||||
'weather': weather,
|
||||
};
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import 'package:halkyon_train_client/src/protocol/routing/route_point.dart'
|
|||
as _iq1flhug;
|
||||
import 'package:halkyon_train_client/src/protocol/search/place.dart'
|
||||
as _inm997sc;
|
||||
import 'package:halkyon_train_client/src/protocol/weather/weather_hour.dart'
|
||||
as _ig77jxou;
|
||||
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
||||
as _iacc;
|
||||
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
|
||||
|
|
@ -45,6 +47,8 @@ import 'routing/route_stretch.dart' as _ic7f85bc;
|
|||
import 'search/place.dart' as _iadh29yw;
|
||||
import 'search/search_refused.dart' as _ihbxkwke;
|
||||
import 'strength/strength_plan.dart' as _imwvu2mn;
|
||||
import 'weather/weather_hour.dart' as _i9m2f53e;
|
||||
import 'weather/weather_refused.dart' as _ieu1vbq0;
|
||||
import 'workout/workout.dart' as _i5b9b17z;
|
||||
import 'workout/workout_series.dart' as _i92mefto;
|
||||
export 'day/day.dart';
|
||||
|
|
@ -67,6 +71,8 @@ export 'routing/route_stretch.dart';
|
|||
export 'search/place.dart';
|
||||
export 'search/search_refused.dart';
|
||||
export 'strength/strength_plan.dart';
|
||||
export 'weather/weather_hour.dart';
|
||||
export 'weather/weather_refused.dart';
|
||||
export 'workout/workout.dart';
|
||||
export 'workout/workout_series.dart';
|
||||
export 'client.dart';
|
||||
|
|
@ -980,6 +986,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _imwvu2mn.StrengthPlan) {
|
||||
return _imwvu2mn.StrengthPlan.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i9m2f53e.WeatherHour) {
|
||||
return _i9m2f53e.WeatherHour.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ieu1vbq0.WeatherRefused) {
|
||||
return _ieu1vbq0.WeatherRefused.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i5b9b17z.Workout) {
|
||||
return _i5b9b17z.Workout.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -1051,6 +1063,13 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _isc.getType<_imwvu2mn.StrengthPlan?>()) {
|
||||
return (data != null ? _imwvu2mn.StrengthPlan.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _isc.getType<_i9m2f53e.WeatherHour?>()) {
|
||||
return (data != null ? _i9m2f53e.WeatherHour.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _isc.getType<_ieu1vbq0.WeatherRefused?>()) {
|
||||
return (data != null ? _ieu1vbq0.WeatherRefused.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _isc.getType<_i5b9b17z.Workout?>()) {
|
||||
return (data != null ? _i5b9b17z.Workout.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -1089,6 +1108,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
return (data as List).map((e) => deserialize<_inm997sc.Place>(e)).toList()
|
||||
as T;
|
||||
}
|
||||
if (t == List<_ig77jxou.WeatherHour>) {
|
||||
return (data as List)
|
||||
.map((e) => deserialize<_ig77jxou.WeatherHour>(e))
|
||||
.toList()
|
||||
as T;
|
||||
}
|
||||
try {
|
||||
return _ihk4p5t7.Protocol().deserialize<T>(data, t);
|
||||
} on _isc.DeserializationTypeNotFoundException catch (_) {}
|
||||
|
|
@ -1129,6 +1154,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
_iadh29yw.Place => 'Place',
|
||||
_ihbxkwke.SearchRefused => 'SearchRefused',
|
||||
_imwvu2mn.StrengthPlan => 'StrengthPlan',
|
||||
_i9m2f53e.WeatherHour => 'WeatherHour',
|
||||
_ieu1vbq0.WeatherRefused => 'WeatherRefused',
|
||||
_i5b9b17z.Workout => 'Workout',
|
||||
_i92mefto.WorkoutSeries => 'WorkoutSeries',
|
||||
_ => null,
|
||||
|
|
@ -1188,6 +1215,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
return 'SearchRefused';
|
||||
case _imwvu2mn.StrengthPlan():
|
||||
return 'StrengthPlan';
|
||||
case _i9m2f53e.WeatherHour():
|
||||
return 'WeatherHour';
|
||||
case _ieu1vbq0.WeatherRefused():
|
||||
return 'WeatherRefused';
|
||||
case _i5b9b17z.Workout():
|
||||
return 'Workout';
|
||||
case _i92mefto.WorkoutSeries():
|
||||
|
|
@ -1288,6 +1319,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (dataClassName == 'StrengthPlan') {
|
||||
return deserialize<_imwvu2mn.StrengthPlan>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'WeatherHour') {
|
||||
return deserialize<_i9m2f53e.WeatherHour>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'WeatherRefused') {
|
||||
return deserialize<_ieu1vbq0.WeatherRefused>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Workout') {
|
||||
return deserialize<_i5b9b17z.Workout>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||
|
||||
/// An hour's weather at a place (ADR 0036): what Open-Meteo answers for the
|
||||
/// hour starting at [time], each figure null where it has none.
|
||||
abstract class WeatherHour
|
||||
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||
WeatherHour._({
|
||||
required this.time,
|
||||
this.temperature,
|
||||
this.feelsLike,
|
||||
this.humidity,
|
||||
this.precipitation,
|
||||
this.code,
|
||||
this.wind,
|
||||
this.gusts,
|
||||
this.windFrom,
|
||||
});
|
||||
|
||||
factory WeatherHour({
|
||||
required DateTime time,
|
||||
double? temperature,
|
||||
double? feelsLike,
|
||||
int? humidity,
|
||||
double? precipitation,
|
||||
int? code,
|
||||
double? wind,
|
||||
double? gusts,
|
||||
int? windFrom,
|
||||
}) = _WeatherHourImpl;
|
||||
|
||||
factory WeatherHour.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return WeatherHour(
|
||||
time: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['time']),
|
||||
temperature: (jsonSerialization['temperature'] as num?)?.toDouble(),
|
||||
feelsLike: (jsonSerialization['feelsLike'] as num?)?.toDouble(),
|
||||
humidity: jsonSerialization['humidity'] as int?,
|
||||
precipitation: (jsonSerialization['precipitation'] as num?)?.toDouble(),
|
||||
code: jsonSerialization['code'] as int?,
|
||||
wind: (jsonSerialization['wind'] as num?)?.toDouble(),
|
||||
gusts: (jsonSerialization['gusts'] as num?)?.toDouble(),
|
||||
windFrom: jsonSerialization['windFrom'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// The hour's start, in UTC.
|
||||
DateTime time;
|
||||
|
||||
/// At two metres, in °C.
|
||||
double? temperature;
|
||||
|
||||
/// How it felt, in °C.
|
||||
double? feelsLike;
|
||||
|
||||
/// Relative humidity, in percent.
|
||||
int? humidity;
|
||||
|
||||
/// What fell over the hour (rain, showers, snow as water), in mm.
|
||||
double? precipitation;
|
||||
|
||||
/// The WMO weather code (0 clear, 1–3 clouds, 45–48 fog, 51–67 drizzle and
|
||||
/// rain, 71–77 snow, 80–86 showers, 95–99 thunderstorms).
|
||||
int? code;
|
||||
|
||||
/// The wind at ten metres, in m/s, and its gusts.
|
||||
double? wind;
|
||||
|
||||
double? gusts;
|
||||
|
||||
/// Where the wind came from, in degrees from north.
|
||||
int? windFrom;
|
||||
|
||||
/// Returns a shallow copy of this [WeatherHour]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
WeatherHour copyWith({
|
||||
DateTime? time,
|
||||
double? temperature,
|
||||
double? feelsLike,
|
||||
int? humidity,
|
||||
double? precipitation,
|
||||
int? code,
|
||||
double? wind,
|
||||
double? gusts,
|
||||
int? windFrom,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'WeatherHour',
|
||||
'time': time.toJson(),
|
||||
if (temperature != null) 'temperature': temperature,
|
||||
if (feelsLike != null) 'feelsLike': feelsLike,
|
||||
if (humidity != null) 'humidity': humidity,
|
||||
if (precipitation != null) 'precipitation': precipitation,
|
||||
if (code != null) 'code': code,
|
||||
if (wind != null) 'wind': wind,
|
||||
if (gusts != null) 'gusts': gusts,
|
||||
if (windFrom != null) 'windFrom': windFrom,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'WeatherHour',
|
||||
'time': time.toJson(),
|
||||
if (temperature != null) 'temperature': temperature,
|
||||
if (feelsLike != null) 'feelsLike': feelsLike,
|
||||
if (humidity != null) 'humidity': humidity,
|
||||
if (precipitation != null) 'precipitation': precipitation,
|
||||
if (code != null) 'code': code,
|
||||
if (wind != null) 'wind': wind,
|
||||
if (gusts != null) 'gusts': gusts,
|
||||
if (windFrom != null) 'windFrom': windFrom,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _isc.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _WeatherHourImpl extends WeatherHour {
|
||||
_WeatherHourImpl({
|
||||
required DateTime time,
|
||||
double? temperature,
|
||||
double? feelsLike,
|
||||
int? humidity,
|
||||
double? precipitation,
|
||||
int? code,
|
||||
double? wind,
|
||||
double? gusts,
|
||||
int? windFrom,
|
||||
}) : super._(
|
||||
time: time,
|
||||
temperature: temperature,
|
||||
feelsLike: feelsLike,
|
||||
humidity: humidity,
|
||||
precipitation: precipitation,
|
||||
code: code,
|
||||
wind: wind,
|
||||
gusts: gusts,
|
||||
windFrom: windFrom,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [WeatherHour]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
@override
|
||||
WeatherHour copyWith({
|
||||
DateTime? time,
|
||||
Object? temperature = _Undefined,
|
||||
Object? feelsLike = _Undefined,
|
||||
Object? humidity = _Undefined,
|
||||
Object? precipitation = _Undefined,
|
||||
Object? code = _Undefined,
|
||||
Object? wind = _Undefined,
|
||||
Object? gusts = _Undefined,
|
||||
Object? windFrom = _Undefined,
|
||||
}) {
|
||||
return WeatherHour(
|
||||
time: time ?? this.time,
|
||||
temperature: temperature is double? ? temperature : this.temperature,
|
||||
feelsLike: feelsLike is double? ? feelsLike : this.feelsLike,
|
||||
humidity: humidity is int? ? humidity : this.humidity,
|
||||
precipitation: precipitation is double?
|
||||
? precipitation
|
||||
: this.precipitation,
|
||||
code: code is int? ? code : this.code,
|
||||
wind: wind is double? ? wind : this.wind,
|
||||
gusts: gusts is double? ? gusts : this.gusts,
|
||||
windFrom: windFrom is int? ? windFrom : this.windFrom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||
|
||||
/// Why the instance answers no weather (ADR 0036): `off` where the operator
|
||||
/// switched it off, `invalid` for a place off the earth or a span it doesn't
|
||||
/// take (empty, longer than `WorkoutWeather.maxSpan`, before 1940 or more
|
||||
/// than a day ahead), `tooOften` beyond `WorkoutWeather.perMinute` requests
|
||||
/// a minute, `unreachable` where Open-Meteo didn't answer.
|
||||
abstract class WeatherRefused
|
||||
implements
|
||||
_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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue