feat(train): routes planned through the instance's BRouter (ADR 0030, D44)

The routing endpoint plans a route through the waypoints for one of six
profiles, for the people signed in, at most 50 waypoints and 30 plans a
person a minute, nothing kept or logged; refused without BROUTER_URL.
BRouter's GeoJSON is read into points, elevations, climb and descent
above 3 m, turn hints and stretches by surface. Train's running profile
for BRouter, halkyon-run.brf, is a first guess not yet tried on BRouter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 23:37:47 +01:00
commit 781576726a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
18 changed files with 2563 additions and 1 deletions

View file

@ -14,6 +14,10 @@ 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_train_server/src/generated/future_calls.dart'
as _i59xasdy;
import 'package:halkyon_train_server/src/generated/routing/route_point.dart'
as _is4pcfub;
import 'package:halkyon_train_server/src/generated/routing/route_profile.dart'
as _iex9meso;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
@ -23,6 +27,7 @@ import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart
as _izehhkf5;
import '../firmware/firmware_endpoint.dart' as _id2y0j3h;
import '../map/map_endpoint.dart' as _i35zr6r3;
import '../routing/routing_endpoint.dart' as _ivjydyye;
export 'future_calls.dart' show ServerpodFutureCallsGetter;
class Endpoints extends _is.EndpointDispatch {
@ -41,6 +46,12 @@ class Endpoints extends _is.EndpointDispatch {
'map',
null,
),
'routing': _ivjydyye.RoutingEndpoint()
..initialize(
server,
'routing',
null,
),
};
connectors['firmware'] = _is.EndpointConnector(
name: 'firmware',
@ -130,6 +141,47 @@ class Endpoints extends _is.EndpointDispatch {
),
},
);
connectors['routing'] = _is.EndpointConnector(
name: 'routing',
endpoint: endpoints['routing']!,
methodConnectors: {
'offered': _is.MethodConnector(
name: 'offered',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['routing'] as _ivjydyye.RoutingEndpoint)
.offered(session),
),
'plan': _is.MethodConnector(
name: 'plan',
params: {
'waypoints': _is.ParameterDescription(
name: 'waypoints',
type: _is.getType<List<_is4pcfub.RoutePoint>>(),
nullable: false,
),
'profile': _is.ParameterDescription(
name: 'profile',
type: _is.getType<_iex9meso.RouteProfile>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['routing'] as _ivjydyye.RoutingEndpoint).plan(
session,
params['waypoints'],
params['profile'],
),
),
},
);
modules['halkyon_base'] = _ik8molhd.Endpoints()
..initializeEndpoints(server);
modules['serverpod_auth_idp'] = _iais.Endpoints()

View file

@ -13,6 +13,8 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
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_train_server/src/generated/routing/route_point.dart'
as _is4pcfub;
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'
@ -30,6 +32,12 @@ import 'map/map_source.dart' as _ikw0qg8x;
import 'plan/plan.dart' as _i45prw73;
import 'privacy/content_key.dart' as _imed3x5d;
import 'profile/profile.dart' as _ia5j8fch;
import 'routing/planned_route.dart' as _ikjgxraq;
import 'routing/route_hint.dart' as _if6974nb;
import 'routing/route_point.dart' as _isqtfus6;
import 'routing/route_profile.dart' as _ifl4j5ep;
import 'routing/route_refused.dart' as _ix4aepwk;
import 'routing/route_stretch.dart' as _ic7f85bc;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
export 'day/day.dart';
@ -41,6 +49,12 @@ export 'map/map_source.dart';
export 'plan/plan.dart';
export 'privacy/content_key.dart';
export 'profile/profile.dart';
export 'routing/planned_route.dart';
export 'routing/route_hint.dart';
export 'routing/route_point.dart';
export 'routing/route_profile.dart';
export 'routing/route_refused.dart';
export 'routing/route_stretch.dart';
export 'workout/workout.dart';
export 'workout/workout_series.dart';
export 'sync_tables.dart';
@ -705,6 +719,24 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _ia5j8fch.Profile) {
return _ia5j8fch.Profile.fromJson(data) as T;
}
if (t == _ikjgxraq.PlannedRoute) {
return _ikjgxraq.PlannedRoute.fromJson(data) as T;
}
if (t == _if6974nb.RouteHint) {
return _if6974nb.RouteHint.fromJson(data) as T;
}
if (t == _isqtfus6.RoutePoint) {
return _isqtfus6.RoutePoint.fromJson(data) as T;
}
if (t == _ifl4j5ep.RouteProfile) {
return _ifl4j5ep.RouteProfile.fromJson(data) as T;
}
if (t == _ix4aepwk.RouteRefused) {
return _ix4aepwk.RouteRefused.fromJson(data) as T;
}
if (t == _ic7f85bc.RouteStretch) {
return _ic7f85bc.RouteStretch.fromJson(data) as T;
}
if (t == _i5b9b17z.Workout) {
return _i5b9b17z.Workout.fromJson(data) as T;
}
@ -741,6 +773,24 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _is.getType<_ia5j8fch.Profile?>()) {
return (data != null ? _ia5j8fch.Profile.fromJson(data) : null) as T;
}
if (t == _is.getType<_ikjgxraq.PlannedRoute?>()) {
return (data != null ? _ikjgxraq.PlannedRoute.fromJson(data) : null) as T;
}
if (t == _is.getType<_if6974nb.RouteHint?>()) {
return (data != null ? _if6974nb.RouteHint.fromJson(data) : null) as T;
}
if (t == _is.getType<_isqtfus6.RoutePoint?>()) {
return (data != null ? _isqtfus6.RoutePoint.fromJson(data) : null) as T;
}
if (t == _is.getType<_ifl4j5ep.RouteProfile?>()) {
return (data != null ? _ifl4j5ep.RouteProfile.fromJson(data) : null) as T;
}
if (t == _is.getType<_ix4aepwk.RouteRefused?>()) {
return (data != null ? _ix4aepwk.RouteRefused.fromJson(data) : null) as T;
}
if (t == _is.getType<_ic7f85bc.RouteStretch?>()) {
return (data != null ? _ic7f85bc.RouteStretch.fromJson(data) : null) as T;
}
if (t == _is.getType<_i5b9b17z.Workout?>()) {
return (data != null ? _i5b9b17z.Workout.fromJson(data) : null) as T;
}
@ -748,6 +798,33 @@ class Protocol extends _is.DatabaseSerializationManager {
return (data != null ? _i92mefto.WorkoutSeries.fromJson(data) : null)
as T;
}
if (t == List<_isqtfus6.RoutePoint>) {
return (data as List)
.map((e) => deserialize<_isqtfus6.RoutePoint>(e))
.toList()
as T;
}
if (t == List<double>) {
return (data as List).map((e) => deserialize<double>(e)).toList() as T;
}
if (t == List<_if6974nb.RouteHint>) {
return (data as List)
.map((e) => deserialize<_if6974nb.RouteHint>(e))
.toList()
as T;
}
if (t == List<_ic7f85bc.RouteStretch>) {
return (data as List)
.map((e) => deserialize<_ic7f85bc.RouteStretch>(e))
.toList()
as T;
}
if (t == List<_is4pcfub.RoutePoint>) {
return (data as List)
.map((e) => deserialize<_is4pcfub.RoutePoint>(e))
.toList()
as T;
}
try {
return _ik8molhd.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}
@ -780,6 +857,12 @@ class Protocol extends _is.DatabaseSerializationManager {
_i45prw73.Plan => 'Plan',
_imed3x5d.ContentKey => 'ContentKey',
_ia5j8fch.Profile => 'Profile',
_ikjgxraq.PlannedRoute => 'PlannedRoute',
_if6974nb.RouteHint => 'RouteHint',
_isqtfus6.RoutePoint => 'RoutePoint',
_ifl4j5ep.RouteProfile => 'RouteProfile',
_ix4aepwk.RouteRefused => 'RouteRefused',
_ic7f85bc.RouteStretch => 'RouteStretch',
_i5b9b17z.Workout => 'Workout',
_i92mefto.WorkoutSeries => 'WorkoutSeries',
_ => null,
@ -817,6 +900,18 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'ContentKey';
case _ia5j8fch.Profile():
return 'Profile';
case _ikjgxraq.PlannedRoute():
return 'PlannedRoute';
case _if6974nb.RouteHint():
return 'RouteHint';
case _isqtfus6.RoutePoint():
return 'RoutePoint';
case _ifl4j5ep.RouteProfile():
return 'RouteProfile';
case _ix4aepwk.RouteRefused():
return 'RouteRefused';
case _ic7f85bc.RouteStretch():
return 'RouteStretch';
case _i5b9b17z.Workout():
return 'Workout';
case _i92mefto.WorkoutSeries():
@ -888,6 +983,24 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'Profile') {
return deserialize<_ia5j8fch.Profile>(data['data']);
}
if (dataClassName == 'PlannedRoute') {
return deserialize<_ikjgxraq.PlannedRoute>(data['data']);
}
if (dataClassName == 'RouteHint') {
return deserialize<_if6974nb.RouteHint>(data['data']);
}
if (dataClassName == 'RoutePoint') {
return deserialize<_isqtfus6.RoutePoint>(data['data']);
}
if (dataClassName == 'RouteProfile') {
return deserialize<_ifl4j5ep.RouteProfile>(data['data']);
}
if (dataClassName == 'RouteRefused') {
return deserialize<_ix4aepwk.RouteRefused>(data['data']);
}
if (dataClassName == 'RouteStretch') {
return deserialize<_ic7f85bc.RouteStretch>(data['data']);
}
if (dataClassName == 'Workout') {
return deserialize<_i5b9b17z.Workout>(data['data']);
}

View file

@ -4,3 +4,6 @@ firmware:
- download:
map:
- source:
routing:
- offered:
- plan:

View file

@ -0,0 +1,169 @@
/* 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:halkyon_train_server/src/generated/protocol.dart' as _iw76w3x7;
import 'package:serverpod/serverpod.dart' as _is;
import '../routing/route_hint.dart' as _iocqct4j;
import '../routing/route_point.dart' as _iafawe19;
import '../routing/route_stretch.dart' as _i8kofibb;
/// A route the instance's BRouter planned (ADR 0030, D44): its points in
/// order, their elevations in metres (empty where BRouter knows none; a point
/// without one takes the one before), its length, its climb and descent
/// (climbs above 3 m, as a workout's), its turns, and its stretches in order.
/// Nothing of it is kept on the instance: the app seals what it saves.
abstract class PlannedRoute
implements _is.SerializableModel, _is.ProtocolSerialization {
PlannedRoute._({
required this.points,
required this.elevations,
required this.distanceMetres,
required this.ascentMetres,
required this.descentMetres,
required this.hints,
required this.stretches,
});
factory PlannedRoute({
required List<_iafawe19.RoutePoint> points,
required List<double> elevations,
required int distanceMetres,
required int ascentMetres,
required int descentMetres,
required List<_iocqct4j.RouteHint> hints,
required List<_i8kofibb.RouteStretch> stretches,
}) = _PlannedRouteImpl;
factory PlannedRoute.fromJson(Map<String, dynamic> jsonSerialization) {
return PlannedRoute(
points: _iw76w3x7.Protocol().deserialize<List<_iafawe19.RoutePoint>>(
jsonSerialization['points'],
),
elevations: _iw76w3x7.Protocol().deserialize<List<double>>(
jsonSerialization['elevations'],
),
distanceMetres: jsonSerialization['distanceMetres'] as int,
ascentMetres: jsonSerialization['ascentMetres'] as int,
descentMetres: jsonSerialization['descentMetres'] as int,
hints: _iw76w3x7.Protocol().deserialize<List<_iocqct4j.RouteHint>>(
jsonSerialization['hints'],
),
stretches: _iw76w3x7.Protocol().deserialize<List<_i8kofibb.RouteStretch>>(
jsonSerialization['stretches'],
),
);
}
List<_iafawe19.RoutePoint> points;
List<double> elevations;
int distanceMetres;
int ascentMetres;
int descentMetres;
List<_iocqct4j.RouteHint> hints;
List<_i8kofibb.RouteStretch> stretches;
/// Returns a shallow copy of this [PlannedRoute]
/// with some or all fields replaced by the given arguments.
@_is.useResult
PlannedRoute copyWith({
List<_iafawe19.RoutePoint>? points,
List<double>? elevations,
int? distanceMetres,
int? ascentMetres,
int? descentMetres,
List<_iocqct4j.RouteHint>? hints,
List<_i8kofibb.RouteStretch>? stretches,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'PlannedRoute',
'points': points.toJson(valueToJson: (v) => v.toJson()),
'elevations': elevations.toJson(),
'distanceMetres': distanceMetres,
'ascentMetres': ascentMetres,
'descentMetres': descentMetres,
'hints': hints.toJson(valueToJson: (v) => v.toJson()),
'stretches': stretches.toJson(valueToJson: (v) => v.toJson()),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'PlannedRoute',
'points': points.toJson(valueToJson: (v) => v.toJsonForProtocol()),
'elevations': elevations.toJson(),
'distanceMetres': distanceMetres,
'ascentMetres': ascentMetres,
'descentMetres': descentMetres,
'hints': hints.toJson(valueToJson: (v) => v.toJsonForProtocol()),
'stretches': stretches.toJson(valueToJson: (v) => v.toJsonForProtocol()),
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _PlannedRouteImpl extends PlannedRoute {
_PlannedRouteImpl({
required List<_iafawe19.RoutePoint> points,
required List<double> elevations,
required int distanceMetres,
required int ascentMetres,
required int descentMetres,
required List<_iocqct4j.RouteHint> hints,
required List<_i8kofibb.RouteStretch> stretches,
}) : super._(
points: points,
elevations: elevations,
distanceMetres: distanceMetres,
ascentMetres: ascentMetres,
descentMetres: descentMetres,
hints: hints,
stretches: stretches,
);
/// Returns a shallow copy of this [PlannedRoute]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
PlannedRoute copyWith({
List<_iafawe19.RoutePoint>? points,
List<double>? elevations,
int? distanceMetres,
int? ascentMetres,
int? descentMetres,
List<_iocqct4j.RouteHint>? hints,
List<_i8kofibb.RouteStretch>? stretches,
}) {
return PlannedRoute(
points: points ?? this.points.map((e0) => e0.copyWith()).toList(),
elevations: elevations ?? this.elevations.map((e0) => e0).toList(),
distanceMetres: distanceMetres ?? this.distanceMetres,
ascentMetres: ascentMetres ?? this.ascentMetres,
descentMetres: descentMetres ?? this.descentMetres,
hints: hints ?? this.hints.map((e0) => e0.copyWith()).toList(),
stretches:
stretches ?? this.stretches.map((e0) => e0.copyWith()).toList(),
);
}
}

View file

@ -0,0 +1,133 @@
/* 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;
/// A turn of a route planned, as BRouter hints it (ADR 0030, D45): at the
/// point [index] of the route, BRouter's command (1 straight on, 2 left, 3
/// slightly left, 4 sharply left, 5 right, 6 slightly right, 7 sharply
/// right, 8 keep left, 9 keep right, 10 and 11 U-turns, 13 and 14
/// roundabouts, 15 a 180° turn, 17 and 18 exits, 16 a beeline), the exit of
/// a roundabout, the metres to the next hint and the turn's angle.
abstract class RouteHint
implements _is.SerializableModel, _is.ProtocolSerialization {
RouteHint._({
required this.index,
required this.command,
required this.exit,
required this.metres,
required this.angle,
});
factory RouteHint({
required int index,
required int command,
required int exit,
required int metres,
required int angle,
}) = _RouteHintImpl;
factory RouteHint.fromJson(Map<String, dynamic> jsonSerialization) {
return RouteHint(
index: jsonSerialization['index'] as int,
command: jsonSerialization['command'] as int,
exit: jsonSerialization['exit'] as int,
metres: jsonSerialization['metres'] as int,
angle: jsonSerialization['angle'] as int,
);
}
int index;
int command;
int exit;
int metres;
int angle;
/// Returns a shallow copy of this [RouteHint]
/// with some or all fields replaced by the given arguments.
@_is.useResult
RouteHint copyWith({
int? index,
int? command,
int? exit,
int? metres,
int? angle,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'RouteHint',
'index': index,
'command': command,
'exit': exit,
'metres': metres,
'angle': angle,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'RouteHint',
'index': index,
'command': command,
'exit': exit,
'metres': metres,
'angle': angle,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _RouteHintImpl extends RouteHint {
_RouteHintImpl({
required int index,
required int command,
required int exit,
required int metres,
required int angle,
}) : super._(
index: index,
command: command,
exit: exit,
metres: metres,
angle: angle,
);
/// Returns a shallow copy of this [RouteHint]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
RouteHint copyWith({
int? index,
int? command,
int? exit,
int? metres,
int? angle,
}) {
return RouteHint(
index: index ?? this.index,
command: command ?? this.command,
exit: exit ?? this.exit,
metres: metres ?? this.metres,
angle: angle ?? this.angle,
);
}
}

View file

@ -0,0 +1,93 @@
/* 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;
/// A point on the earth, in degrees (WGS 84): a waypoint asked for, or a
/// point of a route planned (ADR 0030, D44).
abstract class RoutePoint
implements _is.SerializableModel, _is.ProtocolSerialization {
RoutePoint._({
required this.lat,
required this.lon,
});
factory RoutePoint({
required double lat,
required double lon,
}) = _RoutePointImpl;
factory RoutePoint.fromJson(Map<String, dynamic> jsonSerialization) {
return RoutePoint(
lat: (jsonSerialization['lat'] as num).toDouble(),
lon: (jsonSerialization['lon'] as num).toDouble(),
);
}
double lat;
double lon;
/// Returns a shallow copy of this [RoutePoint]
/// with some or all fields replaced by the given arguments.
@_is.useResult
RoutePoint copyWith({
double? lat,
double? lon,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'RoutePoint',
'lat': lat,
'lon': lon,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'RoutePoint',
'lat': lat,
'lon': lon,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _RoutePointImpl extends RoutePoint {
_RoutePointImpl({
required double lat,
required double lon,
}) : super._(
lat: lat,
lon: lon,
);
/// Returns a shallow copy of this [RoutePoint]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
RoutePoint copyWith({
double? lat,
double? lon,
}) {
return RoutePoint(
lat: lat ?? this.lat,
lon: lon ?? this.lon,
);
}
}

View file

@ -0,0 +1,52 @@
/* 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;
/// How a route is planned (ADR 0030, D44): running on Train's own BRouter
/// profile, hiking (`hiking-mountain`), a road bike (`fastbike`), a trekking
/// bike (`trekking`), gravel (`gravel`) or a mountain bike (`mtb`).
enum RouteProfile implements _is.SerializableModel {
run,
hike,
roadBike,
trekkingBike,
gravel,
mtb;
static RouteProfile fromJson(String name) {
switch (name) {
case 'run':
return RouteProfile.run;
case 'hike':
return RouteProfile.hike;
case 'roadBike':
return RouteProfile.roadBike;
case 'trekkingBike':
return RouteProfile.trekkingBike;
case 'gravel':
return RouteProfile.gravel;
case 'mtb':
return RouteProfile.mtb;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "RouteProfile"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -0,0 +1,72 @@
/* 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 plans no route (ADR 0030, D44): `off` where it has no
/// BRouter, `invalid` for waypoints it doesn't take (fewer than two, more
/// than `Routing.maxWaypoints`, off the earth), `tooOften` beyond
/// `Routing.perMinute` plans a minute, `noRoute` where BRouter found none
/// (outside the region it holds, or no way between), `unreachable` where it
/// didn't answer.
abstract class RouteRefused
implements
_is.SerializableException,
_is.SerializableModel,
_is.ProtocolSerialization {
RouteRefused._({required this.reason});
factory RouteRefused({required String reason}) = _RouteRefusedImpl;
factory RouteRefused.fromJson(Map<String, dynamic> jsonSerialization) {
return RouteRefused(reason: jsonSerialization['reason'] as String);
}
String reason;
/// Returns a shallow copy of this [RouteRefused]
/// with some or all fields replaced by the given arguments.
@_is.useResult
RouteRefused copyWith({String? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'RouteRefused',
'reason': reason,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'RouteRefused',
'reason': reason,
};
}
@override
String toString() {
return 'RouteRefused(reason: $reason)';
}
}
class _RouteRefusedImpl extends RouteRefused {
_RouteRefusedImpl({required String reason}) : super._(reason: reason);
/// Returns a shallow copy of this [RouteRefused]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
RouteRefused copyWith({String? reason}) {
return RouteRefused(reason: reason ?? this.reason);
}
}

View file

@ -0,0 +1,107 @@
/* 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;
/// A stretch of a route planned with what it runs on (ADR 0030, D44): its
/// length, its OpenStreetMap `surface` and `highway` where tagged.
abstract class RouteStretch
implements _is.SerializableModel, _is.ProtocolSerialization {
RouteStretch._({
required this.metres,
this.surface,
this.highway,
});
factory RouteStretch({
required int metres,
String? surface,
String? highway,
}) = _RouteStretchImpl;
factory RouteStretch.fromJson(Map<String, dynamic> jsonSerialization) {
return RouteStretch(
metres: jsonSerialization['metres'] as int,
surface: jsonSerialization['surface'] as String?,
highway: jsonSerialization['highway'] as String?,
);
}
int metres;
String? surface;
String? highway;
/// Returns a shallow copy of this [RouteStretch]
/// with some or all fields replaced by the given arguments.
@_is.useResult
RouteStretch copyWith({
int? metres,
String? surface,
String? highway,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'RouteStretch',
'metres': metres,
if (surface != null) 'surface': surface,
if (highway != null) 'highway': highway,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'RouteStretch',
'metres': metres,
if (surface != null) 'surface': surface,
if (highway != null) 'highway': highway,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _RouteStretchImpl extends RouteStretch {
_RouteStretchImpl({
required int metres,
String? surface,
String? highway,
}) : super._(
metres: metres,
surface: surface,
highway: highway,
);
/// Returns a shallow copy of this [RouteStretch]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
RouteStretch copyWith({
int? metres,
Object? surface = _Undefined,
Object? highway = _Undefined,
}) {
return RouteStretch(
metres: metres ?? this.metres,
surface: surface is String? ? surface : this.surface,
highway: highway is String? ? highway : this.highway,
);
}
}

View file

@ -0,0 +1,243 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_train_server/src/generated/protocol.dart';
/// BRouter's name for each of Train's profiles (ADR 0030, D44): its own
/// files, and `halkyon-run`, Train's, which the sidecar's profiles hold.
String brouterProfileOf(RouteProfile profile) => switch (profile) {
RouteProfile.run => 'halkyon-run',
RouteProfile.hike => 'hiking-mountain',
RouteProfile.roadBike => 'fastbike',
RouteProfile.trekkingBike => 'trekking',
RouteProfile.gravel => 'gravel',
RouteProfile.mtb => 'mtb',
};
/// BRouter found no route: outside the segments it holds, or no way.
class NoRoute() implements Exception;
/// BRouter didn't answer, or answered nothing it reads.
class BRouterFailed(final String message) implements Exception {
@override
String toString() => 'BRouterFailed: $message';
}
/// The router (ADR 0030, D11, D44): the instance's BRouter.
abstract interface class RoutePlanner() {
/// The route through [waypoints] for [profile], as BRouter's GeoJSON.
/// Throws [NoRoute] where there is none, [BRouterFailed] where it didn't
/// answer.
Future<Map<String, Object?>> route(
List<RoutePoint> waypoints,
RouteProfile profile,
);
}
/// [RoutePlanner] on BRouter's HTTP server at [address] (`http://brouter:17777/`),
/// which only the server reaches; what it is asked is never logged.
class HttpBRouter(final Uri address) implements RoutePlanner {
/// How long a route may take: a long one across a country takes seconds.
static const _timeout = Duration(seconds: 30);
@override
Future<Map<String, Object?>> route(
List<RoutePoint> waypoints,
RouteProfile profile,
) async {
final client = HttpClient()..connectionTimeout = _timeout;
try {
final uri = address
.resolve('brouter')
.replace(
queryParameters: {
'lonlats': [
for (final point in waypoints) '${point.lon},${point.lat}',
].join('|'),
'profile': brouterProfileOf(profile),
'alternativeidx': '0',
'format': 'geojson',
// Locus' turn hints: exits told apart from keeping left or right.
'timode': '2',
},
);
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 BRouterFailed(error.runtimeType.toString());
}
// BRouter answers a route it can't find with an error and a message.
if (response.statusCode != HttpStatus.ok) {
throw NoRoute();
}
try {
return jsonDecode(body) as Map<String, Object?>;
} on Object {
throw BRouterFailed('no GeoJSON');
}
} finally {
client.close(force: true);
}
}
}
/// The climb that counts, as a workout's (D24): GPS's or the elevation
/// model's noise is a few metres.
const _climbThreshold = 3;
/// The route [geojson] holds, as [HttpBRouter] answers it: BRouter's one
/// feature, its coordinates `[lon, lat, elevation]`, its `voicehints`
/// `[index, command, exit, metres to the next, angle]`, and its `messages`,
/// a table of stretches whose first row names the columns (`Distance`,
/// `WayTags`). Numbers in its properties come as strings.
PlannedRoute plannedRouteOf(Map<String, Object?> geojson) {
final feature = switch (geojson['features']) {
[final Map<String, Object?> feature, ...] => feature,
_ => throw BRouterFailed('no feature'),
};
final properties = feature['properties'] as Map<String, Object?>? ?? {};
final coordinates = switch (feature['geometry']) {
{'coordinates': final List<Object?> coordinates} => coordinates,
_ => throw BRouterFailed('no coordinates'),
};
final points = <RoutePoint>[];
final elevations = <double>[];
var anyElevation = false;
for (final coordinate in coordinates) {
if (coordinate case [final num lon, final num lat, ...final rest]) {
points.add(RoutePoint(lat: lat.toDouble(), lon: lon.toDouble()));
final elevation = switch (rest) {
[final num elevation, ...] => elevation.toDouble(),
_ => null,
};
anyElevation |= elevation != null;
elevations.add(elevation ?? elevations.lastOrNull ?? double.nan);
}
}
if (points.length < 2) {
throw BRouterFailed('no route');
}
// A start without an elevation takes the first known.
final known = elevations.firstWhere((e) => !e.isNaN, orElse: () => 0);
final filled = [
for (final e in elevations)
if (e.isNaN) known else e,
];
final (ascent, descent) = _climbs(filled);
return PlannedRoute(
points: points,
elevations: anyElevation ? filled : const [],
distanceMetres: _int(properties['track-length']) ?? 0,
ascentMetres: anyElevation ? ascent : 0,
descentMetres: anyElevation ? descent : 0,
hints: [
for (final hint in properties['voicehints'] as List<Object?>? ?? [])
if (hint case [
final Object index,
final Object command,
final Object exit,
final Object metres,
final Object angle,
...,
])
if ((
_int(index),
_int(command),
_int(exit),
_int(metres),
_int(angle),
)
case (
final index?,
final command?,
final exit?,
final metres?,
final angle?,
)
when index >= 0 && index < points.length)
RouteHint(
index: index,
command: command,
exit: exit,
metres: metres,
angle: angle,
),
],
stretches: _stretches(properties['messages']),
);
}
/// The climbs and descents above [_climbThreshold], each added up.
(int, int) _climbs(List<double> elevations) {
if (elevations.isEmpty) {
return (0, 0);
}
var reference = elevations.first;
var (up, down) = (0.0, 0.0);
for (final elevation in elevations.skip(1)) {
if (elevation > reference + _climbThreshold) {
up += elevation - reference;
reference = elevation;
} else if (elevation < reference - _climbThreshold) {
down += reference - elevation;
reference = elevation;
}
}
return (up.round(), down.round());
}
/// The stretches of BRouter's messages: each row's length and way's tags,
/// rows of the same surface and highway joined.
List<RouteStretch> _stretches(Object? messages) {
if (messages is! List<Object?> || messages.isEmpty) {
return const [];
}
final header = messages.first;
if (header is! List<Object?>) {
return const [];
}
final (distance, tags) = (
header.indexOf('Distance'),
header.indexOf('WayTags'),
);
if (distance < 0 || tags < 0) {
return const [];
}
final stretches = <RouteStretch>[];
for (final row in messages.skip(1)) {
if (row is! List<Object?> ||
row.length <= (distance > tags ? distance : tags)) {
continue;
}
final metres = _int(row[distance]);
if (metres == null || metres <= 0) {
continue;
}
final way = {
for (final tag in '${row[tags]}'.split(' '))
if (tag.split('=') case [final key, final value]) key: value,
};
final (surface, highway) = (way['surface'], way['highway']);
final last = stretches.lastOrNull;
if (last != null && last.surface == surface && last.highway == highway) {
stretches[stretches.length - 1] = last.copyWith(
metres: last.metres + metres,
);
} else {
stretches.add(
RouteStretch(metres: metres, surface: surface, highway: highway),
);
}
}
return stretches;
}
int? _int(Object? value) => switch (value) {
final int value => value,
final num value => value.round(),
final String value => num.tryParse(value)?.round(),
_ => null,
};

View file

@ -0,0 +1,14 @@
### A route the instance's BRouter planned (ADR 0030, D44): its points in
### order, their elevations in metres (empty where BRouter knows none; a point
### without one takes the one before), its length, its climb and descent
### (climbs above 3 m, as a workout's), its turns, and its stretches in order.
### Nothing of it is kept on the instance: the app seals what it saves.
class: PlannedRoute
fields:
points: List<RoutePoint>
elevations: List<double>
distanceMetres: int
ascentMetres: int
descentMetres: int
hints: List<RouteHint>
stretches: List<RouteStretch>

View file

@ -0,0 +1,13 @@
### A turn of a route planned, as BRouter hints it (ADR 0030, D45): at the
### point [index] of the route, BRouter's command (1 straight on, 2 left, 3
### slightly left, 4 sharply left, 5 right, 6 slightly right, 7 sharply
### right, 8 keep left, 9 keep right, 10 and 11 U-turns, 13 and 14
### roundabouts, 15 a 180° turn, 17 and 18 exits, 16 a beeline), the exit of
### a roundabout, the metres to the next hint and the turn's angle.
class: RouteHint
fields:
index: int
command: int
exit: int
metres: int
angle: int

View file

@ -0,0 +1,6 @@
### A point on the earth, in degrees (WGS 84): a waypoint asked for, or a
### point of a route planned (ADR 0030, D44).
class: RoutePoint
fields:
lat: double
lon: double

View file

@ -0,0 +1,12 @@
### How a route is planned (ADR 0030, D44): running on Train's own BRouter
### profile, hiking (`hiking-mountain`), a road bike (`fastbike`), a trekking
### bike (`trekking`), gravel (`gravel`) or a mountain bike (`mtb`).
enum: RouteProfile
serialized: byName
values:
- run
- hike
- roadBike
- trekkingBike
- gravel
- mtb

View file

@ -0,0 +1,9 @@
### Why the instance plans no route (ADR 0030, D44): `off` where it has no
### BRouter, `invalid` for waypoints it doesn't take (fewer than two, more
### than `Routing.maxWaypoints`, off the earth), `tooOften` beyond
### `Routing.perMinute` plans a minute, `noRoute` where BRouter found none
### (outside the region it holds, or no way between), `unreachable` where it
### didn't answer.
exception: RouteRefused
fields:
reason: String

View file

@ -0,0 +1,7 @@
### A stretch of a route planned with what it runs on (ADR 0030, D44): its
### length, its OpenStreetMap `surface` and `highway` where tagged.
class: RouteStretch
fields:
metres: int
surface: String?
highway: String?

View file

@ -0,0 +1,94 @@
import 'dart:collection';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_train_server/src/generated/protocol.dart';
import 'package:halkyon_train_server/src/routing/brouter.dart';
import 'package:serverpod/serverpod.dart';
/// Planning routes on this instance (ADR 0030, D11, D44), through its
/// BRouter where it has one, for a person as often as [perMinute] allows.
/// Neither the waypoints nor the route are kept or logged; the counts live
/// in memory alone.
class Routing {
Routing({this.router});
/// The routing of [pod], made the first time it is asked for, from the
/// operator's `BROUTER_URL` (empty: none). Tests set their own.
factory Routing.of(Serverpod pod) => _ofPod[pod] ??= Routing(
router: switch (Platform.environment['BROUTER_URL']?.trim()) {
final url? when url.isNotEmpty => HttpBRouter(Uri.parse(url)),
_ => null,
},
);
static final _ofPod = Expando<Routing>('Routing');
/// Makes [routing] the one of [pod].
static void use(Serverpod pod, Routing routing) => _ofPod[pod] = routing;
/// The instance's router; none: it plans nothing.
final RoutePlanner? router;
/// The most waypoints a route takes.
static const maxWaypoints = 50;
/// The most routes a person may ask for in a minute: the planner asks
/// again as each waypoint is set or dragged.
static const perMinute = 30;
final _asked = HashMap<UuidValue, Queue<DateTime>>();
/// The route through [waypoints] for [profile], planned for [person].
/// Refused ([RouteRefused]) without a router, for waypoints it doesn't
/// take, beyond [perMinute], where BRouter finds none or doesn't answer.
Future<PlannedRoute> plan(
UuidValue person,
List<RoutePoint> waypoints,
RouteProfile profile,
) async {
final router = this.router;
if (router == null) {
throw RouteRefused(reason: 'off');
}
if (waypoints.length < 2 ||
waypoints.length > maxWaypoints ||
!waypoints.every(_onEarth)) {
throw RouteRefused(reason: 'invalid');
}
_count(person);
try {
return plannedRouteOf(await router.route(waypoints, profile));
} on NoRoute {
throw RouteRefused(reason: 'noRoute');
} on BRouterFailed {
throw RouteRefused(reason: 'unreachable');
}
}
static bool _onEarth(RoutePoint point) =>
point.lat.isFinite &&
point.lon.isFinite &&
point.lat.abs() <= 90 &&
point.lon.abs() <= 180;
/// Counts a plan of [person]'s, refusing it beyond [perMinute] in the
/// last minute.
void _count(UuidValue person) {
final now = clock.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 RouteRefused(reason: 'tooOften');
}
asked.addLast(now);
// Those who planned nothing for a minute are forgotten.
_asked.removeWhere(
(_, times) =>
times.isEmpty ||
now.difference(times.last) >= const Duration(minutes: 1),
);
}
}

View file

@ -0,0 +1,29 @@
import 'package:halkyon_train_server/src/generated/protocol.dart';
import 'package:halkyon_train_server/src/routing/routing.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Routes planned by the instance's BRouter (ADR 0030, D11, D44), for those
/// signed in: BRouter has no sign-in of its own, so the server alone reaches
/// it. Neither the waypoints nor the route are kept or logged. Refusals are
/// `RouteRefused`.
class RoutingEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// Whether the instance plans routes.
Future<bool> offered(Session session) async =>
Routing.of(session.serverpod).router != null;
/// The route through [waypoints], in order, for [profile]. Refused where
/// the instance has no BRouter or it fails, for fewer than two or more
/// than `Routing.maxWaypoints` waypoints, and beyond `Routing.perMinute`
/// plans a minute.
Future<PlannedRoute> plan(
Session session,
List<RoutePoint> waypoints,
RouteProfile profile,
) => Routing.of(
session.serverpod,
).plan(session.authenticated!.authUserId, waypoints, profile);
}