This commit is contained in:
Mark 2025-03-02 03:27:51 +01:00
commit 3d3754b116
9 changed files with 3513 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

2550
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

13
Cargo.toml Normal file
View File

@ -0,0 +1,13 @@
[package]
name = "unisuedzeiger"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = { version = "0.4.39", features = ["serde"] }
html-escape = "0.2.13"
reqwest = "0.12.9"
rocket = "0.5.1"
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.133"
tokio = { version = "1.42.0", features = ["sync"] }

357
src/api.rs Normal file
View File

@ -0,0 +1,357 @@
use std::{
collections::HashMap,
time::{Duration, Instant},
};
use chrono::DateTime;
use reqwest::get;
use serde::Deserialize;
pub struct ApiClient {
pub swu_stop_number: u32,
pub swu_api_limit: u8,
pub departures: Vec<Departure>,
pub directions: HashMap<String, HashMap<String, Option<(Instant, i32, u8)>>>,
pub directions_last_save: Option<Instant>,
}
impl ApiClient {
pub fn new() -> Self {
Self::new_custom(1240)
}
pub fn new_custom(swu_stop_number: u32) -> Self {
Self::new_custom_with_limit(swu_stop_number, 30)
}
pub fn new_custom_with_limit(swu_stop_number: u32, swu_api_limit: u8) -> Self {
Self {
swu_stop_number,
swu_api_limit,
departures: vec![],
directions: HashMap::new(),
directions_last_save: None,
}
}
pub async fn api_get(&mut self) -> Result<(), String> {
// get departures
let departures = self.api_get_departures().await?;
let mut should_save = false;
// fetch trip data (-> direction) for vehicles which have departed and cache it for next vehicles
for departure in self.departures.iter() {
if let Some(route_name) = &departure.route_name {
if let Some(departure_direction_text) = &departure.departure_direction_text {
if let Some(vehicle_number) = departure.vehicle_number {
if departures.iter().all(|dep| {
dep.route_name.as_ref().is_none_or(|v| v != route_name)
|| dep.vehicle_number.is_none_or(|v| v != vehicle_number)
|| dep
.departure_direction_text
.as_ref()
.is_none_or(|v| v != departure_direction_text)
}) {
// vehicle has just departed, check direction via api
let dir_value = self
.directions
.entry(route_name.clone())
.or_insert_with(HashMap::new)
.entry(departure_direction_text.clone())
.or_insert(None);
if dir_value.is_none_or(|(when, _, success_rate)| {
when.elapsed().as_secs()
>= (success_rate * 15).saturating_sub(5) as u64
}) {
match Self::api_get_trip(vehicle_number).await {
Ok(res) => {
if res.route_number == departure.route_number
&& res
.departure_direction_text
.as_ref()
.is_some_and(|ddt| ddt == departure_direction_text)
{
if let Some(dir) = res.direction {
if let Some((when, prev, success_rate)) = dir_value
{
*when = Instant::now();
should_save = true;
if dir == *prev {
*success_rate =
success_rate.saturating_add(1).min(60);
eprintln!("[CACHE/dirs] {route_name} -> {departure_direction_text} => d{dir} (confirmed, success now {})", *success_rate);
} else {
if *success_rate <= 2 {
eprintln!("[CACHE/dirs] {route_name} -> {departure_direction_text} => d{dir} (changed from {})", *prev);
*prev = dir;
*success_rate = 1;
} else {
*success_rate /= 2;
eprintln!("[CACHE/dirs] {route_name} -> {departure_direction_text} => d{dir} (contradicted, success now {})", *success_rate);
}
}
} else {
eprintln!("[CACHE/dirs] {route_name} -> {departure_direction_text} => d{dir} (new info)");
*dir_value = Some((Instant::now(), dir, 1));
}
}
} else {
// ignore cuz is wrong route or wrong direction
}
}
Err(e) => eprintln!("{e}"),
}
}
}
}
}
}
}
if should_save
&& self
.directions_last_save
.is_none_or(|t| t.elapsed() > Duration::from_secs(60 * 60))
{
if let Err(e) = self.directions_save().await {
eprintln!("Couldn't save directions data: {e}");
}
}
self.departures = departures;
Ok(())
}
async fn api_get_departures(&self) -> Result<Vec<Departure>, String> {
match get(format!(
"https://api.swu.de/mobility/v1/stop/passage/Departures?StopNumber={}&Limit={}",
self.swu_stop_number, self.swu_api_limit
))
.await
{
Ok(response) => match response.text().await {
Ok(response) => match serde_json::from_str::<ResDepartures>(&response) {
Ok(response) => Ok(response.stop_passage.departure_data),
Err(e) => Err(format!("{}\n{}", e, response)),
},
Err(e) => Err(e.to_string()),
},
Err(e) => Err(e.to_string()),
}
}
async fn api_get_trip(vehicle_number: u32) -> Result<JourneyData, String> {
match get(format!(
"https://api.swu.de/mobility/v1/vehicle/trip/Trip?VehicleNumber={vehicle_number}"
))
.await
{
Ok(response) => match response.text().await {
Ok(response) => match serde_json::from_str::<ResTrip>(&response) {
Ok(trip) => Ok(trip.vehicle_trip.trip_data.journey_data),
Err(e) => Err(format!("{}\n{}", e, response)),
},
Err(e) => Err(e.to_string()),
},
Err(e) => return Err(e.to_string()),
}
}
}
pub async fn bahnhof_get_departures() -> Result<String, String> {
match get(
"https://www.bahnhof.de/api/boards/departures?evaNumbers=8000170&filterTransports=HIGH_SPEED_TRAIN&filterTransports=INTERCITY_TRAIN&filterTransports=INTER_REGIONAL_TRAIN&filterTransports=REGIONAL_TRAIN&filterTransports=CITY_TRAIN&filterTransports=UNKNOWN&duration=120&locale=de",
)
.await
{
Ok(response) => match response.text().await {
Ok(response) => match serde_json::from_str::<BahnhofResDepartures>(&response) {
Ok(response) => {
let mut o = format!("<div class=\"dbflex\" style=\"display:flex;flex-wrap:wrap;padding:1%;\">\n");
for departure in response.entries.into_iter().flat_map(|v| v.into_iter()) {
if let Some(line_name) = &departure.line_name {
o.push_str(if departure.canceled {
r#"<div style="text-decoration: line-through wavy DarkRed; color:gray;">"#
} else {
r#"<div>"#
});
o.push_str("<b>");
html_escape::encode_safe_to_string(line_name, &mut o);
o.push_str("</b>");
if let Some(departure_time) =
departure.time_delayed.or(departure.time_schedule)
{
o.push_str(" ");
o.push_str(
&departure_time.naive_local().format("%H:%M").to_string(),
);
}
if let Some(destination) =
departure.destination.as_ref().and_then(|v| v.name.as_ref())
{
o.push_str("<br>");
html_escape::encode_safe_to_string(destination, &mut o);
}
o.push_str("</div>\n");
}
}
o.push_str("</div>");
Ok(o)
}
Err(e) => Err(format!("{e}\n{response}")),
},
Err(e) => Err(e.to_string()),
},
Err(e) => Err(e.to_string()),
}
}
impl ApiClient {
pub async fn directions_save(&mut self) -> Result<(), String> {
let file_name = format!("unisuedzeiger_directions_{}.txt", self.swu_stop_number);
let mut out = String::new();
for (line, directions) in &self.directions {
out.push_str(&format!("[{line}]\n"));
for (direction_text, value) in directions {
if let Some((_, direction_number, success_rate)) = value {
out.push_str(&format!(
"> {direction_text}={direction_number}/{success_rate}\n"
));
}
}
}
tokio::fs::write(&file_name, out)
.await
.map_err(|e| format!("Couldn't save file {file_name}: {e}"))?;
self.directions_last_save = Some(Instant::now());
Ok(())
}
pub async fn directions_load(&mut self) -> Result<(), String> {
let prev_directions = std::mem::replace(&mut self.directions, HashMap::new());
match self.directions_load_impl().await {
Ok(v) => {
dbg!(&self.directions);
Ok(v)
}
Err(e) => {
self.directions = prev_directions;
Err(e)
}
}
}
pub async fn directions_load_impl(&mut self) -> Result<(), String> {
let now = Instant::now();
self.directions_last_save = Some(now);
let file_name = format!("unisuedzeiger_directions_{}.txt", self.swu_stop_number);
let text = tokio::fs::read_to_string(&file_name)
.await
.map_err(|e| format!("Couldn't read file {file_name}: {e}"))?;
let mut line_name = String::new();
for line in text.lines() {
if line.starts_with('[') && line.ends_with(']') {
line_name = line[1..line.len() - 1].to_owned();
if !self.directions.contains_key(&line_name) {
self.directions.insert(line_name.to_owned(), HashMap::new());
}
} else if line.starts_with("> ") {
if let Some((direction_text, data_text)) = line[2..].rsplit_once('=') {
if let Some((direction_number, success_rate)) = data_text.split_once('/') {
if let (Some(direction_number), Some(success_rate)) =
(direction_number.parse().ok(), success_rate.parse().ok())
{
let line_map = self.directions.get_mut(&line_name).unwrap();
if line_map
.insert(
direction_text.to_owned(),
Some((now, direction_number, success_rate)),
)
.is_some()
{
return Err(format!(
"Duplicate direction texts for line {line_name}: {direction_text}"
));
}
}
}
}
}
}
Ok(())
}
}
#[derive(Deserialize)]
struct ResDepartures {
#[serde(rename = "StopPassage")]
pub stop_passage: ResStopPassage,
}
#[derive(Deserialize)]
struct ResStopPassage {
#[serde(rename = "DepartureData")]
pub departure_data: Vec<Departure>,
}
#[derive(Debug, Deserialize)]
pub struct Departure {
#[serde(default, rename = "RouteName")]
pub route_name: Option<String>,
#[serde(default, rename = "RouteNumber")]
pub route_number: Option<i32>,
#[serde(default, rename = "DepartureDirectionText")]
pub departure_direction_text: Option<String>,
#[serde(default, rename = "VehicleNumber")]
pub vehicle_number: Option<u32>,
// #[serde(default, rename = "DepartureCountdown")]
// pub departure_countdown: Option<isize>,
#[serde(default, rename = "DepartureTimeActual")]
pub departure_time_actual: Option<DateTime<chrono::Local>>,
#[serde(default, rename = "DepartureTimeScheduled")]
pub departure_time_scheduled: Option<DateTime<chrono::Local>>,
}
#[derive(Deserialize)]
struct ResTrip {
#[serde(rename = "VehicleTrip")]
pub vehicle_trip: ResVehicleTrip,
}
#[derive(Deserialize)]
struct ResVehicleTrip {
#[serde(rename = "TripData")]
trip_data: ResTripData,
}
#[derive(Deserialize)]
struct ResTripData {
#[serde(rename = "JourneyData")]
journey_data: JourneyData,
}
#[derive(Deserialize)]
pub struct JourneyData {
#[serde(default, rename = "RouteNumber")]
pub route_number: Option<i32>,
#[serde(default, rename = "DepartureDirectionText")]
pub departure_direction_text: Option<String>,
#[serde(default, rename = "Direction")]
pub direction: Option<i32>,
}
#[derive(Deserialize)]
struct BahnhofResDepartures {
entries: Vec<Vec<BahnhofResDeparture>>,
}
#[derive(Deserialize)]
struct BahnhofResDeparture {
#[serde(default, rename = "timeSchedule")]
time_schedule: Option<DateTime<chrono::Local>>,
#[serde(default, rename = "timeDelayed")]
time_delayed: Option<DateTime<chrono::Local>>,
#[serde(default, rename = "canceled")]
canceled: bool,
// #[serde(default, rename = "platform")]
// platform: Option<String>,
#[serde(default, rename = "lineName")]
line_name: Option<String>,
#[serde(default, rename = "destination")]
destination: Option<BahnhofResDepartureDestination>,
}
#[derive(Deserialize)]
struct BahnhofResDepartureDestination {
#[serde(default, rename = "name")]
name: Option<String>,
}

128
src/cache.rs Normal file
View File

@ -0,0 +1,128 @@
use std::time::Duration;
use chrono::DateTime;
use tokio::{sync::Mutex, time::Instant};
use crate::api::{bahnhof_get_departures, ApiClient};
pub struct Cache {
pub index: String,
pub nojs: String,
pub data: Mutex<Data>,
pub api_client: Mutex<ApiClient>,
pub api_next_update: Mutex<Instant>,
pub bahnhof_html: Mutex<String>,
pub bahnhof_next_update: Mutex<Instant>,
}
pub struct Data {
pub seq: u8,
pub departures: Vec<Departure>,
// pub changes: [Vec<Change>; 10],
}
#[derive(Debug)]
pub struct Departure {
pub route_name: Option<String>,
pub departure_direction_text: Option<String>,
pub vehicle_number: Option<u32>,
pub departure_time: Option<DateTime<chrono::Local>>,
pub scheduled_time: Option<DateTime<chrono::Local>>,
pub direction: Option<i32>,
}
impl Cache {
pub fn new(index: String, nojs: String, swu_stop_number: Option<u32>) -> Self {
let now = Instant::now();
Self {
index,
nojs,
data: Mutex::new(Data {
seq: 0,
departures: vec![],
}),
api_client: Mutex::new(if let Some(swu_stop_number) = swu_stop_number {
ApiClient::new_custom(swu_stop_number)
} else {
ApiClient::new()
}),
api_next_update: Mutex::new(now),
bahnhof_html: Mutex::new(String::new()),
bahnhof_next_update: Mutex::new(now),
}
}
pub async fn update_swu(&self) -> Duration {
let mut next_update = self.api_next_update.lock().await;
let now = Instant::now();
if now >= *next_update {
let delay = Duration::from_secs(14);
let mut api = self.api_client.lock().await;
match api.api_get().await {
Ok(()) => {
// TODO: find changes
// increment sequence number thingy (ze counting boi)
let mut data = self.data.lock().await;
data.seq += 1;
if data.seq >= 10 {
data.seq = 0;
}
// store departures
let departures = api
.departures
.iter()
.map(|departure| {
let direction = departure
.route_name
.as_ref()
.and_then(|rn| {
departure.departure_direction_text.as_ref().and_then(|dt| {
api.directions
.get(rn)
.and_then(|v| v.get(dt).and_then(|v| v.as_ref()))
})
})
.filter(|(_, _, success)| *success > 2)
.map(|(_, dir, _)| *dir);
Departure {
route_name: departure.route_name.clone(),
departure_direction_text: departure
.departure_direction_text
.clone(),
vehicle_number: departure.vehicle_number,
departure_time: departure
.departure_time_actual
.as_ref()
.or(departure.departure_time_scheduled.as_ref())
.cloned(),
scheduled_time: departure.departure_time_scheduled.clone(),
direction,
}
})
.collect();
data.departures = departures;
}
Err(e) => eprintln!("Couldn't get departures from swu: {e}"),
}
*next_update = Instant::now() + delay;
delay
} else {
*next_update - now
}
}
pub async fn update_bahn(&self) -> Duration {
let mut next_update = self.bahnhof_next_update.lock().await;
let now = Instant::now();
if now >= *next_update {
let delay = Duration::from_secs(59);
// bahnhof api request
match bahnhof_get_departures().await {
Ok(v) => {
*self.bahnhof_html.lock().await = v;
}
Err(e) => eprintln!("Couldn't get departures from bahnhof: {e}"),
}
*next_update = Instant::now() + delay;
delay
} else {
*next_update - now
}
}
}

BIN
src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

221
src/index.html Normal file
View File

@ -0,0 +1,221 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>ulm öpnv abfahrten</title>
<style>
.dbflex {
display: flex;
flex-wrap: wrap;
}
.dbflex > * {
white-space: nowrap;
color: silver;
margin-top: 0.5em;
margin-bottom: 0.5em;
margin-left: 0.7em;
padding-left: 0.3em;
margin-right: 1em;
border-left: solid gray;
}
</style>
</head>
<body>
<a id="nojslink" href="./nojs" style="position:fixed"><br>no javascript? use the nojs version</a>
<div style="background:darkviolet; width: 100%; height:4px;" id="untilNextUpdateProgressBarBackground"><span style="display:block; background:purple; width:100%; height: 100%;" id="untilNextUpdateProgressBar"></span></div>
<table style="width:99vw; height: 72vh; max-height: 72vh; display: block; overflow: hidden;" id="routesTable"></table>
<div style="width:99vw; height: 24vh; max-height: 24vh; display:block; overflow: hidden;" id="bahnhofPart"></div>
<script>
// https://stackoverflow.com/questions/951021/what-is-the-javascript-version-of-sleep
const sleep = ms => new Promise(r => setTimeout(r, ms));
setInterval(doRepeatedFast, 500);
fetchUniAllRepeated();
async function doRepeatedFast() {
await rebuildDepartureElements();
}
function getDirectionColor(direction) {
switch (direction) {
case 0: return "LightPink";
case 1: return "LightSkyBlue";
case 2: return "LightGreen";
case 3: return "LightPink";
default: return "Silver";
}
}
function getRouteColumn(route) {
const desiredId = "routeColumn" + route;
const column = document.getElementById(desiredId);
if (column) {
return column;
} else {
const newColumnWrapper = document.createElement("td");
const newColumn = document.createElement("table");
const columnHeaderRow = document.createElement("tr");
const columnHeaderData = document.createElement("td");
columnHeaderData.innerText = "Linie " + route;
columnHeaderData.style.fontSize = "3em";
columnHeaderData.style.textDecoration = "underline gray";
columnHeaderRow.appendChild(columnHeaderData);
newColumn.appendChild(columnHeaderRow);
newColumn.id = desiredId;
const routesTable = document.getElementById("routesTable");
newColumnWrapper.appendChild(newColumn);
var beforeWhich = null;
for (const otherColumn of routesTable.children) {
const otherId = otherColumn.firstChild.id;
if (otherId.length > desiredId.length || (otherId.length == desiredId.length && otherId > desiredId)) {
beforeWhich = otherColumn;
break;
}
}
routesTable.insertBefore(newColumnWrapper, beforeWhich);
let columnWidthPercent = (99 / routesTable.childElementCount) + "vw";
for (const column of routesTable.children) {
column.style.width = columnWidthPercent;
}
return newColumn;
}
}
function getTripRow(route, vehicle, scheduledTime) {
const desiredId = "tripRow" + route + "#" + vehicle + "#" + scheduledTime;
const tripRow = document.getElementById(desiredId);
if (tripRow) {
return tripRow;
} else {
const newTripRow = document.createElement("tr");
const column = getRouteColumn(route);
let isFirst = true;
let beforeWhich = null;
for (row of column.children) {
if (!isFirst) {
let split = row.id.split("#");
if (split[split.length - 1] > scheduledTime) {
beforeWhich = row;
break;
}
}
isFirst = false;
}
newTripRow.id = desiredId;
column.insertBefore(newTripRow, beforeWhich);
return newTripRow;
}
}
var currentSeq = 0;
var resUniAll = [];
var secondsUntilNextUpdate = 0;
async function fetchUniAllRepeated() {
while (true) {
var doneFetching = false;
while (!doneFetching) {
try {
await fetchUniAll();
doneFetching = true;
} catch (e) {
console.log(e);
console.log("Retrying in 5s...");
await sleep(5000);
}
}
if (!(secondsUntilNextUpdate >= 1)) {
secondsUntilNextUpdate = 1;
}
const updateBarSteps = secondsUntilNextUpdate * 50;
const progressBarFg = document.getElementById("untilNextUpdateProgressBar");
const progressBarBg = document.getElementById("untilNextUpdateProgressBarBackground");
const tempColor = progressBarFg.style.background;
progressBarFg.style.background = progressBarBg.style.background;
progressBarBg.style.background = tempColor;
let startTime = performance.now();
while (true) {
let elapsed = Math.min(1000, (performance.now() - startTime) / secondsUntilNextUpdate);
document.getElementById("untilNextUpdateProgressBar").style.width = (elapsed / 10) + "%";
if (elapsed >= 1000) {
break;
}
await sleep(20);
}
}
}
async function fetchUniAll() {
const resUniAllText = await (await fetch("./uni/all")).text();
const prevResUniAll = resUniAll;
resUniAll = [];
var resUniThis = {};
const now = new Date();
for (const departure of prevResUniAll) {
departure.elem.remove();
}
for (const resUniAllLine of resUniAllText.split("\n")) {
if (resUniAllLine.startsWith("#")) {
const furtherSplitLine = resUniAllLine.substring(1).split("#");
currentSeq = parseInt(furtherSplitLine[0]);
secondsUntilNextUpdate = parseInt(furtherSplitLine[1]);
} else if (resUniAllLine.startsWith("-")) {
resUniThis.elem = document.createElement("td");
getTripRow(resUniThis.route, resUniThis.vehicle, resUniThis.scheduledTime).replaceChildren(resUniThis.elem);
resUniAll.push(resUniThis);
resUniThis = {};
} else if (resUniAllLine.startsWith("R")) {
resUniThis.route = resUniAllLine.substring(1);
} else if (resUniAllLine.startsWith("D")) {
resUniThis.target = resUniAllLine.substring(1);
} else if (resUniAllLine.startsWith("d")) {
resUniThis.direction = parseInt(resUniAllLine.substring(1));
} else if (resUniAllLine.startsWith("v")) {
resUniThis.vehicle = parseInt(resUniAllLine.substring(1));
} else if (resUniAllLine.startsWith("t")) {
resUniThis.time = Date.parse(resUniAllLine.substring(1));
} else if (resUniAllLine.startsWith("s")) {
resUniThis.scheduledTime = Date.parse(resUniAllLine.substring(1));
} else {
resUniThis[resUniAllLine.substring(0, 1)] = resUniAllLine.substring(1);
}
}
for (const column of document.getElementById("routesTable").firstChild.children) {
if (column.childElementCount <= 1) {
column.remove();
let columnWidthPercent = (99 / routesTable.childElementCount) + "vw";
for (const column of routesTable.children) {
column.style.width = columnWidthPercent;
}
}
}
let nojslink = document.getElementById("nojslink");
if (nojslink) {
nojslink.remove();
}
await rebuildDepartureElements();
await updateBahnhofAll();
}
async function updateBahnhofAll() {
document.getElementById("bahnhofPart").innerHTML = await (await fetch("./bf/all")).text();
}
async function rebuildDepartureElements() {
const now = new Date();
for (resUniThis of resUniAll) {
const remainingSecondsTotal = Math.round((resUniThis.time - now) / 1000);
const remainingSeconds = remainingSecondsTotal % 60;
const remainingMinutes = Math.round((remainingSecondsTotal - remainingSeconds) / 60);
resUniThis.elem.innerHTML = "<span></span><span style=\"color:gray;\"> | </span><span></span>";
resUniThis.elem.children[0].innerText = resUniThis.target;
resUniThis.elem.children[0].style.color = getDirectionColor(resUniThis.direction);
resUniThis.elem.children[2].innerText = remainingSecondsTotal > 0 ? (remainingMinutes > 0 ? remainingMinutes + "m" : "") + (remainingMinutes < 5 ? (remainingSeconds < 10 ? "0" + remainingSeconds : remainingSeconds) + "s" : "") : "now";
const timeScaled = Math.min(Math.max(remainingSecondsTotal, 0) / 900, 1);
const timeScaledP = Math.sqrt(timeScaled);
resUniThis.elem.children[2].style.color = "hsl(" + Math.round(100 * timeScaledP) + "," + Math.round(100 - 100 * timeScaledP) + "%,50%)";
}
}
</script>
</body>
</html>

190
src/main.rs Normal file
View File

@ -0,0 +1,190 @@
mod api;
mod cache;
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use cache::Cache;
use rocket::{get, response::content::RawHtml, routes, State};
#[get("/")]
fn index(cache: &State<Arc<Cache>>) -> RawHtml<&str> {
RawHtml(&cache.index)
}
#[get("/favicon.ico")]
fn favicon() -> &'static [u8] {
include_bytes!("favicon.ico")
}
#[get("/uni/all")]
async fn uni_all(cache: &State<Arc<Cache>>) -> RawHtml<String> {
let seconds_until_next_update = cache.update_swu().await.as_secs() + 1;
let data = cache.data.lock().await;
let mut o = format!("#{}#{}", data.seq, seconds_until_next_update);
for departure in data.departures.iter() {
if let Some(route) = &departure.route_name {
o.push_str("\nR");
o.push_str(route);
}
if let Some(dir) = &departure.departure_direction_text {
o.push_str("\nD");
o.push_str(dir);
}
if let Some(dir) = departure.direction {
o.push_str(&format!("\nd{dir}"));
}
if let Some(vehicle) = departure.vehicle_number {
o.push_str(&format!("\nv{vehicle}"));
}
if let Some(time) = &departure.departure_time {
o.push_str("\nt");
o.push_str(&format!("{time}"));
}
if let Some(time) = &departure.scheduled_time {
o.push_str("\ns");
o.push_str(&format!("{time}"));
}
o.push_str("\n-");
// v.route_name
}
RawHtml(o)
}
#[get("/bf/all")]
async fn bf_all(cache: &State<Arc<Cache>>) -> String {
cache.update_bahn().await;
cache.bahnhof_html.lock().await.clone()
}
#[get("/nojs")]
async fn nojs(cache: &State<Arc<Cache>>) -> RawHtml<String> {
let mut html = cache.nojs.clone();
html.push_str("<body>\n");
cache.update_swu().await;
let data = cache.data.lock().await;
let mut routes = BTreeMap::<StrOrderedByLen, Vec<&'_ _>>::new();
#[derive(PartialEq, Eq)]
struct StrOrderedByLen<'a>(&'a str);
impl<'a> std::cmp::PartialOrd for StrOrderedByLen<'a> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl<'a> std::cmp::Ord for StrOrderedByLen<'a> {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0
.len()
.cmp(&other.0.len())
.then_with(|| self.0.cmp(other.0))
}
}
for departure in data.departures.iter() {
if let Some(route_name) = &departure.route_name {
if let Some(route) = routes.get_mut(&StrOrderedByLen(route_name.as_str())) {
route.push(departure);
} else {
routes.insert(StrOrderedByLen(route_name.as_str()), vec![departure]);
}
}
}
let now = chrono::Local::now().naive_local() + Duration::from_secs(10);
for (route_name, departures) in routes {
html.push_str("<h3>Linie ");
html_escape::encode_safe_to_string(&route_name.0, &mut html);
html.push_str("</h3>\n");
for departure in departures {
html.push_str(r#"<div class="dir"#);
if let Some(direction) = departure.direction {
html.push_str(&format!("{direction}"));
} else {
html.push_str("unknown");
}
html.push_str(r#""><span class="ddest">"#);
html_escape::encode_safe_to_string(
departure
.departure_direction_text
.as_ref()
.map(|v| v.as_str())
.unwrap_or("[?direction?]"),
&mut html,
);
html.push_str(r#"</span><span class="dsep"> in </span><span class="dtime dtime"#);
let time = departure
.departure_time
.or(departure.scheduled_time)
.as_ref()
.map(|v| {
let time = v.naive_local();
if time > now {
let remaining_time = time.signed_duration_since(now);
let mins = remaining_time.num_minutes();
let secs = remaining_time.num_seconds() % 60;
let soon = match (mins, secs) {
(0, _) | (1, 0) => "1min",
(1, _) | (2, 0) => "2min",
(2..=4, _) | (5, 0) => "5min",
(5..=9, _) | (10, 0) => "10min",
(10..=14, _) | (15, 0) => "15min",
(15.., _) => "future",
(..=-1, _) => "now",
};
if mins > 0 {
(soon, format!("{}m{}s ({})", mins, secs, time.time()))
} else {
(
soon,
format!("{}s ({})", remaining_time.num_seconds(), time.time()),
)
}
} else {
("now", format!("now ({})", time.time()))
}
});
html.push_str(time.as_ref().map(|v| v.0).unwrap_or("unknown"));
html.push_str(r#"">"#);
html_escape::encode_safe_to_string(
time.map(|v| v.1).unwrap_or("[?time?]".to_owned()),
&mut html,
);
html.push_str("</span></div>\n");
}
}
drop(data);
html.push_str("</body>\n");
html.push_str("</html>\n");
RawHtml(html)
}
#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
let cache = Arc::new(Cache::new(
std::fs::read_to_string("index.html")
.ok()
.unwrap_or_else(|| include_str!("index.html").to_owned()),
std::fs::read_to_string("nojs.html")
.ok()
.unwrap_or_else(|| include_str!("nojs.html").to_owned()),
std::env::args().nth(1).map(|v| {
v.parse()
.expect("argument must be an integer (Haltestelle als SWU Stop-Number)")
}),
));
if let Err(e) = cache.api_client.lock().await.directions_load().await {
eprintln!("No direction data loaded: {e}");
}
let rocket = rocket::build()
.manage(Arc::clone(&cache))
.mount("/", routes![index, nojs, favicon, uni_all, bf_all])
.ignite()
.await?;
// should not be necessary anymore
// let _direction_updater_task = tokio::task::spawn(async move {
// let start = Instant::now();
// while start.elapsed().as_secs() < 120 * 60 {
// tokio::time::sleep(Duration::from_secs(15)).await;
// tokio::time::sleep(cache.update_swu().await).await;
// }
// });
rocket.launch().await?;
Ok(())
}

53
src/nojs.html Normal file
View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta http-equiv="refresh" content="15">
<title>ulm öpnv abfahrten</title>
<style>
.dsep {
color: gray;
}
.dir0 > .ddest {
color: LightPink;
}
.dir1 > .ddest {
color: LightSkyBlue;
}
.dir2 > .ddest {
color: LightGreen;
}
.dir3 > .ddest {
color: LightPink;
}
.dirunknown {
color: Silver;
}
.dtimenow {
color: Red;
}
.dtime1min {
color: OrangeRed;
}
.dtime2min {
color: MediumVioletRed;
}
.dtime5min {
color: MediumOrchid;
}
.dtime10min {
color: MediumSlateBlue;
}
.dtime15min {
color: Silver;
}
.dtimefuture {
color: Gray;
}
.dtimeunknown {
color: White;
}
</style>
</head>