Compare commits

...

2 commits

10 changed files with 244 additions and 50 deletions

View file

@ -2,7 +2,7 @@ import threading
from wsgiref.simple_server import make_server, WSGIRequestHandler
from operator import itemgetter
from departures import Departure
from bottle import route, error, run, get, ServerAdapter, static_file
from bottle import route, error, run, get, ServerAdapter, static_file, request
class Server(ServerAdapter):
server = None
@ -34,13 +34,42 @@ class API:
return {'stops': main.config["stops"]}
@get("/static/<file>")
def index(file: str):
def static(file: str):
return static_file(file, root="static")
@get("/")
def static():
def index():
return static_file("index.html", root="static")
@get("/admin")
def admin():
return static_file("admin.html", root="static")
@route('/admin/devices', method='POST')
def admin_devices():
if main.config["admin"]["secret"] != request.forms.get('secret'):
return {'error': "unauthorized"}
return {'devices': main.controller.json()}
@route('/admin/devices/resend', method='POST')
def admin_devices_resend():
if main.config["admin"]["secret"] != request.forms.get('secret'):
return {'error': "unauthorized"}
for i, d in enumerate(main.controller.devices):
if d.stop_id and str(i) == request.forms.get('index'):
for dep in Departure.get(d.stop_id):
dep.resend(d.id)
return {'success': True}
@route('/admin/devices/clear', method='POST')
def admin_devices_clear():
if main.config["admin"]["secret"] != request.forms.get('secret'):
return {'error': "unauthorized"}
for i, d in enumerate(main.controller.devices):
if str(i) == request.forms.get('index'):
d.clear()
return {'success': True}
@error(404)
def error404(err):
return ''

View file

@ -50,20 +50,25 @@ class Departure:
self.last_stop = last_stop
self.departure = departure
self.delay = delay
self.updated = 1
self.updated = {}
Departure.storage[did] = self
def update(self, delay):
if delay != self.delay:
self.updated += 1
for id in self.updated:
self.updated[id] += 1
self.delay = delay
def resend(self, id):
if id in self.updated:
self.updated[id] += 1
def get_departure(self):
return -(floor(((datetime.now().timestamp() - (self.departure + self.delay*60))/60)*10)/10)
def data(self):
if self.updated != 0:
self.updated = 0
def data(self, id):
if id not in self.updated or self.updated[id] != 0:
self.updated[id] = 0
return f"{self}"
else:
return None

View file

@ -16,7 +16,6 @@ class LoraController:
def __init__(self, main):
self.main = main
self.storage = dict()
self.devices = []
self.token = ""
@ -38,6 +37,12 @@ class LoraController:
def new(self, id: int, stop_id: str):
self.devices.append(LoraDevice(self, id, stop_id))
def json(self):
resp = []
for d in self.devices:
resp.append({'id': f"{d.id:0>16x}", 'stop_id': d.stop_id})
return resp
class LoraDevice:
def __init__(self, controller: LoraController, deveui: int, stop_id: str):
@ -47,16 +52,19 @@ class LoraDevice:
self.message_pool = []
self.thread = None
self.port = 1
self.send(lambda: "CLEAR")
self.clear()
def get_updated_departures(self):
self.send_time()
for d in Departure.get(self.stop_id):
if d.updated > 0:
if self.id not in d.updated or d.updated[self.id] > 0:
self.send(d.data)
def clear(self):
self.send(lambda id: "CLEAR")
def send_time(self):
self.send(lambda: f"TIME|{(datetime.now() - datetime(1970, 1, 1)).total_seconds():.0f}")
self.send(lambda id: f"TIME|{(datetime.now() - datetime(1970, 1, 1)).total_seconds():.0f}")
def send(self, msg):
self.message_pool.append(msg)
@ -68,7 +76,7 @@ class LoraDevice:
while not self.controller.main.ended:
if len(self.message_pool) == 0:
break
message = self.message_pool.pop(0)()
message = self.message_pool.pop(0)(self.id)
if not message:
continue
url = f"https://lora.plzen.eu/api/v2/nodes/{self.id:0>16x}/queue"
@ -88,4 +96,3 @@ class LoraDevice:
if self.port > 3:
self.port = 1
sleep(5)

View file

@ -25,7 +25,8 @@ class Main:
self.api = API(self)
lora_controller.generate_token()
for d in self.config["devices"]:
lora_controller.new(d["id"], str(d["stop_id"]))
if "stop_id" in d:
lora_controller.new(d["id"], str(d["stop_id"]))
self.thread = threading.Thread(target=self.update_loop)
self.thread.start()

12
server/static/admin.css Normal file
View file

@ -0,0 +1,12 @@
.devices {display: flex; flex-direction: column; gap: 10px;}
.devices > * {background-color: var(--alt-bg); border-radius: 8px; box-shadow: 0 2px 10px 0 #0005; border: 1px solid var(--border-color); overflow: hidden;}
.devices .header {cursor: pointer; padding: 15px; display: grid; grid-template-columns: 1fr max-content; user-select: none;}
.devices .id {font-weight: 700;}
.devices .settings {padding: 15px; border-top: 1px solid var(--border-color2); background: var(--alt-bg2); display: none;}
.devices .settings.visible {display: block;}
.devices .actions {display: flex; gap: 10px; flex-wrap: wrap; align-items: center;}
.devices .actions::before {content: "Rychlé akce:"; font-weight: 500; font-size: 90%; opacity: .5; text-transform: uppercase;}
.login form input {margin-bottom: 15px; display: block;}

39
server/static/admin.html Normal file
View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<title>Administrace</title>
<link href="/static/style.css" rel="stylesheet">
<link href="/static/admin.css" rel="stylesheet">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div class="container" id="app">
<div v-if="logged">
<h2>Zařízení</h2>
<div class="devices">
<div v-for="d, i in devices">
<div class="header" @click="d.visible = !d.visible">
<div class="id">{{ d.id }}</div>
<div class="stop">{{ stops[d.stop_id] ? stops[d.stop_id].name : "Nenastaveno" }}</div>
</div>
<div class="settings" :class="{ visible: d.visible }">
<div class="actions">
<button @click="clear(i)">CLEAR</button>
<button @click="resend(i)">RESEND</button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="login">
<h2>Přihlašte se</h2>
<form @submit.prevent="update()">
<input v-model="secret" type="password" placeholder="Heslo" />
<input type="submit" value="Přihlásit se" />
</form>
</div>
</div>
<script src="/static/admin.js"></script>
</body>
</html>

57
server/static/admin.js Normal file
View file

@ -0,0 +1,57 @@
const { createApp } = Vue;
let app = createApp({
data() {
return {
// logged: false,
// secret: "",
logged: true,
secret: "TajneHeslo",
stops: {},
devices: []
}
},
methods: {
async update() {
let devices = await api("/admin/devices", {secret: this.secret});
let stops = await api("/stops");
if(devices.error) {
alert("Neplatné heslo!");
return;
}
this.devices = devices.devices;
this.stops = stops.stops;
this.logged = true;
},
async clear(index) {
await api("/admin/devices/clear", {index, secret: this.secret});
},
async resend(index) {
await api("/admin/devices/resend", {index, secret: this.secret});
}
}
}).mount('#app');
async function api(url, data) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if(this.readyState !== 4) return;
if(!this.responseText) reject(this);
try {
resolve(JSON.parse(this.responseText));
} catch(e) {
resolve(null);
}
};
xhr.open(data ? "POST" : "GET", url, true);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
var str = [];
for (var key in data) {
if (data.hasOwnProperty(key)) {
str.push(encodeURIComponent(key) + "=" + encodeURIComponent(data[key]))
}
}
xhr.send(str.join("&"));
});
}
app.update();

View file

@ -7,34 +7,50 @@ let app = createApp({
interval: null,
stop_id: null,
stops: [],
departures: []
departures: [],
filtered_departures: [],
filter: false
}
},
methods: {
async set() {
localStorage.setItem("favstop", app.$data.stop_id);
localStorage.setItem("favstop", this.stop_id);
this.update();
},
async update() {
if(app.$data.stop_id) app.$data.departures = (await api("/departures/"+app.$data.stop_id)).departures;
if(!this.stop_id) return;
let res = await api("/departures/"+this.stop_id);
let filtered_out = [];
this.departures = [];
this.filtered_departures = [];
for(let i=0; i<res.departures.length; i++) {
let d = res.departures[i];
if(d.departure < -.3) continue;
let hash = d.line + d.last_stop;
this.departures.push(d);
if(filtered_out.indexOf(hash) == -1) {
this.filtered_departures.push(d);
filtered_out.push(hash);
}
}
},
async setup() {
app.$data.stops = (await api("/stops")).stops;
let ids = Object.keys(app.$data.stops);
this.stops = (await api("/stops")).stops;
let ids = Object.keys(this.stops);
if(ids.length < 1) {
alert("Žádné zastávky nejsou k dispozici!");
if(app.$data.interval) clearTimeout(app.$data.interval);
if(this.interval) clearTimeout(this.interval);
return;
}
if(localStorage.getItem("favstop")) {
if(ids.indexOf(localStorage.getItem("favstop")) != -1) app.$data.stop_id = localStorage.getItem("favstop");
if(ids.indexOf(localStorage.getItem("favstop")) != -1) this.stop_id = localStorage.getItem("favstop");
}
if(!app.$data.stop_id) app.$data.stop_id = ids[0];
if(!this.stop_id) this.stop_id = ids[0];
window.addEventListener("scroll", () => {
app.$data.top = document.querySelector('html').scrollTop;
this.top = document.querySelector('html').scrollTop;
});
app.$data.interval = setInterval(app.update, 5000);
app.update();
this.interval = setInterval(this.update, 5000);
this.update();
}
}
}).mount('#app');

View file

@ -10,15 +10,25 @@
<div class="container" id="app">
<div class="nav" :class="{'top': top == 0}">
<div class="inner" v-if="stop_id && stops[stop_id]">
<h3>
<span>{{ stops[stop_id].name }}</span>
<svg class="icon" viewBox="0 0 24 24">
<path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" />
<div>
<h3>
<span>{{ stops[stop_id].name }}</span>
<svg class="icon icon-dropdown" viewBox="0 0 24 24">
<path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" />
</svg>
</h3>
<select v-model="stop_id" @change="set">
<option v-for="stop, id in stops" :value="id">{{ stop.name }}</option>
</select>
</div>
<div :title="filter ? 'Zobrazit všechny' : 'Zobrazit pouze nejbližší'" class="filter-toggle" @click="filter = !filter">
<svg class="icon" v-if="filter" viewBox="0 0 24 24">
<path d="M15,19.88C15.04,20.18 14.94,20.5 14.71,20.71C14.32,21.1 13.69,21.1 13.3,20.71L9.29,16.7C9.06,16.47 8.96,16.16 9,15.87V10.75L4.21,4.62C3.87,4.19 3.95,3.56 4.38,3.22C4.57,3.08 4.78,3 5,3V3H19V3C19.22,3 19.43,3.08 19.62,3.22C20.05,3.56 20.13,4.19 19.79,4.62L15,10.75V19.88M7.04,5L11,10.06V15.58L13,17.58V10.05L16.96,5H7.04Z" />
</svg>
</h3>
<select v-model="stop_id" @change="set">
<option v-for="stop, id in stops" :value="id">{{ stop.name }}</option>
</select>
<svg class="icon" v-else viewBox="0 0 24 24">
<path d="M2.39 1.73L1.11 3L9 10.89V15.87C8.96 16.16 9.06 16.47 9.29 16.7L13.3 20.71C13.69 21.1 14.32 21.1 14.71 20.71C14.94 20.5 15.04 20.18 15 19.88V16.89L20.84 22.73L22.11 21.46L15 14.35V14.34L13 12.35L11 10.34L4.15 3.5L2.39 1.73M6.21 3L8.2 5H16.96L13.11 9.91L15 11.8V10.75L19.79 4.62C20.13 4.19 20.05 3.56 19.62 3.22C19.43 3.08 19.22 3 19 3H6.21M11 12.89L13 14.89V17.58L11 15.58V12.89Z" />
</svg>
</div>
</div>
</div>
<div class="departure-grid">
@ -26,13 +36,17 @@
<div class="header">poslední zastávka</div>
<div class="header">zpoždění</div>
<div class="header">odjezd</div>
<template v-for="d in departures">
<template v-if="d.departure > -0.7">
<div class="line" :class="'type'+d.type">{{ d.line }}</div>
<div class="last-stop">{{ d.last_stop }}</div>
<div class="delay">{{ d.delay == 0 ? '' : '+'+d.delay }}</div>
<div class="departure">{{ d.departure < 1 ? '<1' : Math.floor(d.departure) }}</div>
</template>
<template v-if="!filter" v-for="d in departures">
<div class="line" :class="'type'+d.type">{{ d.line }}</div>
<div class="last-stop">{{ d.last_stop }}</div>
<div class="delay">{{ d.delay == 0 ? '' : '+'+d.delay }}</div>
<div class="departure">{{ d.departure < 1 ? '<1' : Math.floor(d.departure) }}</div>
</template>
<template v-else v-for="d in filtered_departures">
<div class="line" :class="'type'+d.type">{{ d.line }}</div>
<div class="last-stop">{{ d.last_stop }}</div>
<div class="delay">{{ d.delay == 0 ? '' : '+'+d.delay }}</div>
<div class="departure">{{ d.departure < 1 ? '<1' : Math.floor(d.departure) }}</div>
</template>
</div>
</div>

View file

@ -1,18 +1,23 @@
:root {
--site-bg: #1C1C1C;
--site-alt-bg: #272727;
--alt-bg: #303030;
--alt-bg2: #242424;
--button-bg: #454545;
--button-hover-bg: #3E3E3E;
--border-color: #3E3E3E;
--border-color2: #1f1f1f;
}
* {box-sizing: border-box;}
body {background-color: var(--site-bg); color: #fffd; margin: 0; margin-top: 35px; font-family: sans-serif;}
body {background-color: var(--site-bg); color: #fffd; margin: 0; margin-top: 35px; font-family: Ubuntu, sans-serif;}
.container {max-width: 700px; padding: 25px; margin: auto;}
.departure-grid {display: grid; grid-template-columns: 30px 1fr max-content max-content; gap: 10px 20px; line-height: 31px;}
.header {line-height: 100%; opacity: .5; margin-bottom: 10px; font-size: 90%;}
.departure-grid .header {line-height: 100%; opacity: .5; margin-bottom: 10px; font-size: 90%;}
.line {background-color: var(--site-alt-bg); border-radius: 3px; box-shadow: 0 0 5px 0 #0004; text-align: center; font-weight: 700; width: 30px; height: 30px; color: #fff; text-shadow: 0 0 15px #000;}
.line {background-color: var(--alt-bg); border-radius: 3px; box-shadow: 0 0 5px 0 #0004; text-align: center; font-weight: 700; width: 30px; height: 30px; color: #fff; text-shadow: 0 0 15px #000;}
.last-stop {font-weight: 700;}
.departure {text-align: right;}
.delay {text-align: right; color: #ff6e6e; font-size: 80%;}
@ -21,11 +26,20 @@ body {background-color: var(--site-bg); color: #fffd; margin: 0; margin-top: 35p
.line.type2 {background-color: #1E9641;}
.line.type3 {background-color: #CD2837;}
h3 {color: #fff; display: flex; align-items: center; gap: 5px;}
.icon {fill: #fff5; width: 24px; height: 24px;}
.icon {fill: #fff; width: 24px; height: 24px;}
.icon.icon-dropdown {fill: #fff5;}
.filter-toggle {cursor: pointer;}
.nav {position: fixed; top: 0; left: 0; width: 100%; z-index: 10;}
.nav .inner {background: linear-gradient(to bottom, var(--site-bg) 70%, transparent 100%); padding: 5px 25px; margin: auto; max-width: 700px; padding-bottom: 25px; transition: padding .1s; position: relative;}
.nav .inner {background: linear-gradient(to bottom, var(--site-bg) 70%, transparent 100%); padding: 20px 25px; margin: auto; max-width: 700px; padding-bottom: 30px; transition: padding .1s; display: grid; grid-template-columns: 1fr max-content; gap: 15px; align-items: center;}
.nav .inner > * {position: relative;}
.nav.top .inner {padding-bottom: 0px;}
.nav .inner::after {content: ""; position: fixed; bottom: 0; left: 0; height: 20px; width: 100%; background: linear-gradient(to top, var(--site-bg) 0%, transparent 100%)}
.nav .inner select {opacity: 0; float: left; position: relative; top: -60px; height: 50px; width: 100%; cursor: pointer;}
.nav .inner::after {content: ""; position: fixed; bottom: 0; left: 0; height: 20px; width: 100%; background: linear-gradient(to top, var(--site-bg) 0%, transparent 100%);}
.nav .inner h3 {margin: 0; color: #fff; display: flex; gap: 5px; font-size: 20px;}
.nav .inner select {opacity: 0; float: left; position: absolute; top: 0px; height: 110%; width: 100%; cursor: pointer;}
button, input {background: var(--button-bg); border: 0px; border-radius: 6px; color: #fff; padding: 10px 14px; font: inherit;}
button, input[type=submit] {cursor: pointer;}
:is(button, input[type=submit]):hover {background: var(--button-hover-bg);}