mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
add open charging data and polish roadtrip warnings
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get, Param, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { ChargingService } from './charging.service';
|
||||
|
||||
@Controller('api/trips/:tripId/roadtrip/charging')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip')
|
||||
export class ChargingController {
|
||||
constructor(private readonly charging: ChargingService) {}
|
||||
@Get(':placeId')
|
||||
read(@Param('tripId', ParseIntPipe) tripId: number, @Param('placeId', ParseIntPipe) placeId: number) { return this.charging.read(tripId, placeId); }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from 'zod';
|
||||
import type { ChargingInfo } from '@trek/shared';
|
||||
|
||||
const evse = z.object({
|
||||
uid: z.string(), evse_id: z.string().optional(), status: z.string(), last_updated: z.string(),
|
||||
status_last_updated: z.string().optional(),
|
||||
connectors: z.array(z.object({ tariff_ids: z.array(z.string()).optional() })).default([]),
|
||||
});
|
||||
export const chargingLocation = z.object({
|
||||
id: z.string(), source: z.string(), name: z.string().nullish(), address: z.string(),
|
||||
city: z.string(), last_updated: z.string(),
|
||||
coordinates: z.object({ latitude: z.number(), longitude: z.number() }),
|
||||
operator: z.object({ name: z.string() }).optional(),
|
||||
charging_pool: z.array(z.object({ evses: z.array(evse).default([]) })).default([]),
|
||||
});
|
||||
export const chargingSource = z.object({
|
||||
uid: z.string(), name: z.string(), public_url: z.string().nullable(),
|
||||
attribution_license: z.string().nullable(), attribution_contributor: z.string().nullable(),
|
||||
realtime_data_updated_at: z.string().nullable(), realtime_status: z.string(),
|
||||
});
|
||||
export const chargingTariff = z.object({
|
||||
id: z.string(), original_id: z.string().optional(), source: z.string(), currency: z.string().regex(/^[A-Z]{3}$/), last_updated: z.string(),
|
||||
start_date_time: z.string().nullish(), end_date_time: z.string().nullish(),
|
||||
elements: z.array(z.object({
|
||||
restrictions: z.record(z.string(), z.unknown()).nullish(),
|
||||
price_components: z.array(z.object({
|
||||
type: z.string(), price: z.number().nonnegative(),
|
||||
taxes: z.array(z.object({ percentage: z.coerce.number().nonnegative() })).optional(),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
export type ChargingLocation = z.infer<typeof chargingLocation>;
|
||||
export type ChargingSource = z.infer<typeof chargingSource>;
|
||||
export type ChargingTariff = z.infer<typeof chargingTariff>;
|
||||
|
||||
export function distanceMeters(lat: number, lng: number, location: ChargingLocation) {
|
||||
const rad = Math.PI / 180;
|
||||
const dLat = (location.coordinates.latitude - lat) * rad;
|
||||
const dLng = (location.coordinates.longitude - lng) * rad;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat * rad) * Math.cos(location.coordinates.latitude * rad) * Math.sin(dLng / 2) ** 2;
|
||||
return 12742000 * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0, 1 - a)));
|
||||
}
|
||||
const words = (name: string) => name.toLowerCase().normalize('NFKD').replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(word => word.length > 2 && !['charging', 'station', 'ladestation', 'charger'].includes(word));
|
||||
|
||||
export function matchChargingLocation(locations: ChargingLocation[], lat: number, lng: number, name: string) {
|
||||
const candidates = locations.map(location => ({ location, distance: distanceMeters(lat, lng, location),
|
||||
named: words(name).some(word => words(`${location.name ?? ''} ${location.operator?.name ?? ''}`).includes(word)),
|
||||
})).filter(hit => hit.distance <= 100).sort((a, b) => Number(b.named) - Number(a.named) || a.distance - b.distance);
|
||||
if (!candidates.length) return null;
|
||||
const best = candidates[0];
|
||||
if (!best.named && best.distance > 30) return 'ambiguous';
|
||||
const competing = candidates.find(hit => hit !== best && hit.named === best.named &&
|
||||
hit.location.operator?.name !== best.location.operator?.name && hit.distance < best.distance + 25);
|
||||
return competing ? 'ambiguous' : best.location;
|
||||
}
|
||||
|
||||
export function normalizeCharging(location: ChargingLocation, source: ChargingSource, tariffs: ChargingTariff[], now = Date.now()): ChargingInfo {
|
||||
const evses = [...new Map(location.charging_pool.flatMap(pool => pool.evses).map(point => [point.evse_id || point.uid, point])).values()];
|
||||
const timestamps = [source.realtime_data_updated_at, ...evses.map(point => point.status_last_updated)].filter((value): value is string => !!value && Number.isFinite(Date.parse(value)));
|
||||
const updatedAt = timestamps.sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? null;
|
||||
const stale = !updatedAt || now - Date.parse(updatedAt) > 20 * 60 * 1000 || Date.parse(updatedAt) > now + 60000 || source.realtime_status !== 'ACTIVE';
|
||||
const known = evses.filter(point => ['AVAILABLE', 'CHARGING', 'RESERVED', 'BLOCKED', 'INOPERATIVE', 'OUTOFORDER'].includes(point.status));
|
||||
const ids = new Set(evses.flatMap(point => point.connectors.flatMap(connector => connector.tariff_ids ?? [])));
|
||||
const matching = tariffs.filter(tariff => tariff.source === location.source && (ids.has(tariff.id) || ids.has(tariff.original_id ?? '')) &&
|
||||
(!tariff.start_date_time || Date.parse(tariff.start_date_time) <= now) && (!tariff.end_date_time || Date.parse(tariff.end_date_time) > now));
|
||||
return {
|
||||
checkedAt: new Date(now).toISOString(), status: 'ok', station: location.name || `${location.operator?.name ?? ''} ${location.address}, ${location.city}`.trim(),
|
||||
source: source.attribution_contributor || source.name, sourceUrl: source.public_url, license: source.attribution_license || 'Datenlizenz Deutschland Namensnennung 2.0 (MobiData BW)',
|
||||
updatedAt, stale, available: stale || !known.length ? null : known.filter(point => point.status === 'AVAILABLE').length,
|
||||
total: evses.length, unknown: evses.length - known.length, pricesUnavailable: false,
|
||||
tariffs: matching.map(tariff => ({ currency: tariff.currency, updatedAt: tariff.last_updated,
|
||||
components: tariff.elements.flatMap(element => element.price_components.map(component => ({
|
||||
kind: component.type, price: component.price * (1 + (component.taxes?.reduce((sum, tax) => sum + tax.percentage, 0) ?? 0) / 100),
|
||||
taxIncluded: component.taxes !== undefined,
|
||||
conditional: !!element.restrictions && Object.keys(element.restrictions).length > 0,
|
||||
afterSeconds: typeof element.restrictions?.min_duration === 'number' ? element.restrictions.min_duration : null,
|
||||
}))),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { ChargingService } from './charging.service';
|
||||
|
||||
@McpController()
|
||||
export class ChargingMcp {
|
||||
constructor(private readonly charging: ChargingService, private readonly db: DatabaseService, readonly addons: AddonsService) {}
|
||||
@Tool({ name: 'get_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a saved charging stop. No API key required. Coverage varies by region and operator. Unknown or stale status is not available capacity. Prices can have conditions and extra fees and are not personalized roaming quotes.',
|
||||
inputSchema: { tripId: z.number().int().positive(), placeId: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) })
|
||||
async read({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
return ok(await this.charging.read(tripId, placeId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import type { ChargingInfo } from '@trek/shared';
|
||||
import { z } from 'zod';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { readCappedJson } from '../../utils/cappedFetch';
|
||||
import { chargingLocation, chargingSource, chargingTariff, matchChargingLocation, normalizeCharging, type ChargingSource, type ChargingTariff } from './charging.helpers';
|
||||
|
||||
const empty = (status: ChargingInfo['status']): ChargingInfo => ({ checkedAt: new Date().toISOString(), status, station: null, source: null, sourceUrl: null, license: null, updatedAt: null, stale: false, available: null, total: 0, unknown: 0, tariffs: [], pricesUnavailable: false });
|
||||
async function request(path: string) {
|
||||
const fetched = await fetch(`https://api.mobidata-bw.de/ocpdb${path}`, { headers: { 'User-Agent': 'TREK Roadtrip (https://github.com/liketrek/TREK)' }, signal: AbortSignal.timeout(12000), redirect: 'error' });
|
||||
if (!fetched.ok) { await fetched.body?.cancel(); throw new Error('Charging source unavailable'); }
|
||||
return await readCappedJson<unknown>(fetched, 8_000_000);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChargingService {
|
||||
private cached = new Map<string, { expires: number; value: Promise<ChargingInfo> }>();
|
||||
private sources?: { expires: number; value: Promise<ChargingSource[]> };
|
||||
private tariffs = new Map<string, { expires: number; value: Promise<ChargingTariff[]> }>();
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
async read(tripId: number, placeId: number) {
|
||||
const place = this.db.get<{ name: string; lat: number | null; lng: number | null; stop_type: string }>('SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?', placeId, tripId);
|
||||
if (!place) throw new HttpException({ error: 'Place not found' }, 404);
|
||||
if (place.stop_type !== 'charging' || place.lat == null || place.lng == null) return empty('unknown');
|
||||
const key = `${place.lat},${place.lng}:${place.name}`;
|
||||
const cached = this.cached.get(key);
|
||||
if (cached && cached.expires > Date.now()) return cached.value;
|
||||
if (this.cached.size >= 300) this.cached.delete(this.cached.keys().next().value!);
|
||||
const value = this.load(place.lat, place.lng, place.name).catch(() => empty('unavailable'));
|
||||
this.cached.set(key, { expires: Date.now() + 60000, value });
|
||||
return value;
|
||||
}
|
||||
private async load(lat: number, lng: number, name: string): Promise<ChargingInfo> {
|
||||
if (!this.sources || this.sources.expires < Date.now()) this.sources = { expires: Date.now() + 60000, value: request('/api/public/v1/sources').then(raw => z.object({ items: z.array(chargingSource) }).parse(raw).items) };
|
||||
const [sources, nearby] = await Promise.all([this.sources.value, request(`/api/ocpi/3.0/locations?lat=${lat}&lon=${lng}&radius=100&limit=100&exclude_source_uid=bnetza_api`)]);
|
||||
const page = z.object({ items: z.array(z.unknown()), total_count: z.number() }).parse(nearby);
|
||||
if (page.total_count > 100) return empty('ambiguous');
|
||||
const locations = page.items.flatMap(item => { const parsed = chargingLocation.safeParse(item); return parsed.success ? [parsed.data] : []; });
|
||||
const location = matchChargingLocation(locations, lat, lng, name);
|
||||
if (!location) return empty('unknown');
|
||||
if (location === 'ambiguous') return empty('ambiguous');
|
||||
const source = sources.find(source => source.uid === location.source);
|
||||
if (!source) return empty('unknown');
|
||||
let pricesUnavailable = false;
|
||||
const tariffs = await this.loadTariffs(source.uid).catch(() => { pricesUnavailable = true; return []; });
|
||||
return { ...normalizeCharging(location, source, tariffs), pricesUnavailable };
|
||||
}
|
||||
private loadTariffs(source: string): Promise<ChargingTariff[]> {
|
||||
const cached = this.tariffs.get(source);
|
||||
if (cached && cached.expires > Date.now()) return cached.value;
|
||||
const value = this.fetchTariffs(source);
|
||||
this.tariffs.set(source, { expires: Date.now() + 300000, value });
|
||||
return value;
|
||||
}
|
||||
private async fetchTariffs(source: string) {
|
||||
const tariffs: ChargingTariff[] = [];
|
||||
for (let offset = 0; offset < 5000; offset += 1000) {
|
||||
const page = z.object({ items: z.array(z.unknown()), total_count: z.number() }).parse(await request(`/api/ocpi/3.0/tariffs?source_uid=${encodeURIComponent(source)}&limit=1000&offset=${offset}`));
|
||||
for (const item of page.items) { const parsed = chargingTariff.safeParse(item); if (parsed.success) tariffs.push(parsed.data); }
|
||||
if (offset + page.items.length >= page.total_count) return tariffs;
|
||||
}
|
||||
throw new Error('Incomplete charging tariffs');
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ const dwdProperties = z.object({
|
||||
});
|
||||
const gdacsProperties = z.object({
|
||||
eventtype: z.string().regex(/^[A-Z]{2}$/), eventid: z.number().int().positive(), episodeid: z.number().int().nonnegative(),
|
||||
episodealertscore: z.number().finite().nonnegative().optional().catch(undefined),
|
||||
alertscore: z.number().finite().nonnegative().optional().catch(undefined),
|
||||
name: z.string(), description: z.string(), datemodified: z.string(), iscurrent: z.union([z.string(), z.boolean()]),
|
||||
});
|
||||
const utc = (date: string) => new Date(/(?:Z|[+-]\d\d:\d\d)$/.test(date) ? date : `${date}Z`).toISOString();
|
||||
@@ -76,6 +78,7 @@ export class RoadtripHazardsService {
|
||||
title: props.name, description: props.description, updatedAt: utc(props.datemodified), validUntil: null,
|
||||
url: `https://www.gdacs.org/report.aspx?eventtype=${props.eventtype}&eventid=${props.eventid}&episodeid=${props.episodeid}`,
|
||||
geometry: feature.geometry,
|
||||
alertScore: props.episodealertscore ?? props.alertscore,
|
||||
}));
|
||||
} catch { partial = true; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { ChargingMcp } from './charging.mcp';
|
||||
import { ChargingService } from './charging.service';
|
||||
import { ChargingController } from './charging.controller';
|
||||
import { GoogleRouteService } from './google-route.service';
|
||||
import { GoogleRouteController } from './google-route.controller';
|
||||
import { GoogleRouteMcp } from './google-route.mcp';
|
||||
@@ -44,8 +47,8 @@ import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp';
|
||||
PluginsRuntimeModule,
|
||||
MapsModule, PlacesModule, AssignmentsModule,
|
||||
],
|
||||
controllers: [GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
|
||||
providers: [GoogleRouteService, GoogleRouteMcp,
|
||||
controllers: [ChargingController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
|
||||
providers: [ChargingMcp, ChargingService, GoogleRouteService, GoogleRouteMcp,
|
||||
RoadtripSearchService,
|
||||
RoadtripHazardsService,
|
||||
RoadtripHazardsMcp,
|
||||
|
||||
Reference in New Issue
Block a user