update
This commit is contained in:
parent
20993b949e
commit
e0e3c371d2
4 changed files with 552 additions and 1 deletions
69
README.md
69
README.md
|
|
@ -1,2 +1,69 @@
|
|||
# bus-arduino
|
||||
# Bus LCD for Arduino
|
||||
|
||||
Этот набор состоит из двух частей:
|
||||
|
||||
- `bus_monitor.py` получает координаты автобусов `94` и `96` с `https://rnd.don.su/get_bus`, считает примерное время до заданных точек и формирует две строки по 16 символов.
|
||||
- `arduino_bus_display/arduino_bus_display.ino` принимает эти строки по `Serial` и выводит их на LCD 16x2 через `LiquidCrystal`.
|
||||
|
||||
Используются точки:
|
||||
|
||||
- `94`: `47.291609, 39.694856`
|
||||
- `96`: `47.220350, 39.632114`
|
||||
|
||||
На экране выводится короткая ASCII-строка, например:
|
||||
|
||||
```text
|
||||
94 #8 09m 2.4km
|
||||
96 #6 03m 180m
|
||||
```
|
||||
|
||||
`#8` это сколько автобусов скрипт видит на маршруте в текущем ответе API. `09m` это примерный ETA, `2.4km` или `180m` это расстояние по прямой до нужной точки. Это не маршрутное время, а оценка по текущему положению и скорости автобуса.
|
||||
|
||||
## Подключение LCD
|
||||
|
||||
Прошивка использует ваш вариант подключения:
|
||||
|
||||
- `RS -> D6`
|
||||
- `EN -> D7`
|
||||
- `DB4 -> D8`
|
||||
- `DB5 -> D9`
|
||||
- `DB6 -> D10`
|
||||
- `DB7 -> D11`
|
||||
|
||||
## Что установить на компьютере
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Как загрузить прошивку
|
||||
|
||||
Откройте файл `arduino_bus_display/arduino_bus_display.ino` в Arduino IDE и загрузите его в плату.
|
||||
|
||||
Скорость `Serial`: `115200`.
|
||||
|
||||
## Как запустить скрипт
|
||||
|
||||
Если хотите сначала просто посмотреть вывод в консоль:
|
||||
|
||||
```bash
|
||||
python3 bus_monitor.py --once
|
||||
```
|
||||
|
||||
Если Arduino подключена по USB:
|
||||
|
||||
```bash
|
||||
python3 bus_monitor.py --serial-port /dev/ttyACM0
|
||||
```
|
||||
|
||||
Полезные параметры:
|
||||
|
||||
- `--interval 5` или другое значение, если нужно изменить частоту обновления
|
||||
- по умолчанию обновление идет каждые `5` секунд
|
||||
- `--baud-rate 115200` скорость `Serial`
|
||||
- `--once` один запрос без бесконечного цикла
|
||||
|
||||
## Примечания
|
||||
|
||||
- Скрипт сам получает свежие `cookie` и `csrf-token` с главной страницы `rnd.don.su`, поэтому не использует одноразовые токены из браузера.
|
||||
- Если стандартный LCD 1602 плохо показывает кириллицу, это нормально. Поэтому строки на экране оставлены в ASCII.
|
||||
|
|
|
|||
89
arduino_bus_display/arduino_bus_display.ino
Normal file
89
arduino_bus_display/arduino_bus_display.ino
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#include <LiquidCrystal.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
constexpr uint8_t PIN_RS = 6;
|
||||
constexpr uint8_t PIN_EN = 7;
|
||||
constexpr uint8_t PIN_DB4 = 8;
|
||||
constexpr uint8_t PIN_DB5 = 9;
|
||||
constexpr uint8_t PIN_DB6 = 10;
|
||||
constexpr uint8_t PIN_DB7 = 11;
|
||||
|
||||
constexpr unsigned long SERIAL_BAUD = 115200;
|
||||
constexpr size_t LCD_COLUMNS = 16;
|
||||
constexpr size_t LCD_ROWS = 2;
|
||||
constexpr size_t INPUT_BUFFER_SIZE = 40;
|
||||
|
||||
LiquidCrystal lcd(PIN_RS, PIN_EN, PIN_DB4, PIN_DB5, PIN_DB6, PIN_DB7);
|
||||
|
||||
char line1[LCD_COLUMNS + 1] = "Booting... ";
|
||||
char line2[LCD_COLUMNS + 1] = "Waiting data... ";
|
||||
char inputBuffer[INPUT_BUFFER_SIZE];
|
||||
size_t inputPos = 0;
|
||||
|
||||
void copyLine(char* destination, const char* source) {
|
||||
for (size_t i = 0; i < LCD_COLUMNS; ++i) {
|
||||
if (source[i] == '\0') {
|
||||
for (size_t j = i; j < LCD_COLUMNS; ++j) {
|
||||
destination[j] = ' ';
|
||||
}
|
||||
destination[LCD_COLUMNS] = '\0';
|
||||
return;
|
||||
}
|
||||
destination[i] = source[i];
|
||||
}
|
||||
destination[LCD_COLUMNS] = '\0';
|
||||
}
|
||||
|
||||
void renderDisplay() {
|
||||
lcd.setCursor(0, 0);
|
||||
lcd.print(line1);
|
||||
lcd.setCursor(0, 1);
|
||||
lcd.print(line2);
|
||||
}
|
||||
|
||||
void handleMessage(const char* message) {
|
||||
if (strncmp(message, "L1:", 3) == 0) {
|
||||
copyLine(line1, message + 3);
|
||||
renderDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (strncmp(message, "L2:", 3) == 0) {
|
||||
copyLine(line2, message + 3);
|
||||
renderDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
void readSerial() {
|
||||
while (Serial.available() > 0) {
|
||||
const char incoming = static_cast<char>(Serial.read());
|
||||
|
||||
if (incoming == '\r') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (incoming == '\n') {
|
||||
inputBuffer[inputPos] = '\0';
|
||||
handleMessage(inputBuffer);
|
||||
inputPos = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inputPos + 1 < INPUT_BUFFER_SIZE) {
|
||||
inputBuffer[inputPos++] = incoming;
|
||||
} else {
|
||||
inputPos = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
lcd.begin(LCD_COLUMNS, LCD_ROWS);
|
||||
renderDisplay();
|
||||
Serial.begin(SERIAL_BAUD);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
readSerial();
|
||||
}
|
||||
394
bus_monitor.py
Normal file
394
bus_monitor.py
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.cookiejar
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
API_ROOT = "https://rnd.don.su/"
|
||||
GET_BUS_URL = f"{API_ROOT}get_bus"
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:140.0) "
|
||||
"Gecko/20100101 Firefox/140.0"
|
||||
)
|
||||
CSRF_RE = re.compile(r'<meta name="csrf-token" content="([^"]+)"')
|
||||
ROUTE_REQUEST = ["2_96", "2_94"]
|
||||
MAX_BUS_SPEED_KMH = 60.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TargetPoint:
|
||||
route: str
|
||||
lat: float
|
||||
lon: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusEstimate:
|
||||
route: str
|
||||
vehicle_id: str
|
||||
distance_m: float
|
||||
eta_min: float | None
|
||||
speed_kmh: float | None
|
||||
approaching: bool
|
||||
live: bool
|
||||
|
||||
|
||||
TARGETS: dict[str, TargetPoint] = {
|
||||
"94": TargetPoint(route="94", lat=47.291609, lon=39.694856),
|
||||
"96": TargetPoint(route="96", lat=47.220350, lon=39.632114),
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Gets bus positions from rnd.don.su and sends two LCD lines to Arduino."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--serial-port",
|
||||
help="Serial port for Arduino, for example /dev/ttyACM0 or COM3.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baud-rate",
|
||||
type=int,
|
||||
default=115200,
|
||||
help="Arduino Serial speed. Default: 115200.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help="Polling interval in seconds. Default: 5.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--once",
|
||||
action="store_true",
|
||||
help="Perform one request and print/send one update.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
radius = 6_371_000.0
|
||||
d_lat = math.radians(lat2 - lat1)
|
||||
d_lon = math.radians(lon2 - lon1)
|
||||
a = (
|
||||
math.sin(d_lat / 2.0) ** 2
|
||||
+ math.cos(math.radians(lat1))
|
||||
* math.cos(math.radians(lat2))
|
||||
* math.sin(d_lon / 2.0) ** 2
|
||||
)
|
||||
return 2.0 * radius * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def scaled_coord(value: Any) -> float:
|
||||
return float(value) / 1_000_000.0
|
||||
|
||||
|
||||
def parse_float(value: Any) -> float | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_timestamp(value: Any) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def current_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def format_distance(distance_m: float) -> str:
|
||||
if distance_m < 1000:
|
||||
return f"{int(round(distance_m))}m"
|
||||
if distance_m < 10_000:
|
||||
return f"{distance_m / 1000:.1f}km"
|
||||
return f"{distance_m / 1000:.0f}km"
|
||||
|
||||
|
||||
def format_eta(eta_min: float | None) -> str:
|
||||
if eta_min is None:
|
||||
return "--m"
|
||||
minutes = max(1, int(math.ceil(eta_min)))
|
||||
return f"{minutes:02d}m"
|
||||
|
||||
|
||||
def fit_lcd(text: str) -> str:
|
||||
return text[:16].ljust(16)
|
||||
|
||||
|
||||
def compute_speed_kmh(item: dict[str, Any]) -> float | None:
|
||||
direct_speed = parse_float(item.get("speed"))
|
||||
if direct_speed is not None and direct_speed > 1.0:
|
||||
return min(direct_speed, MAX_BUS_SPEED_KMH)
|
||||
|
||||
prev = item.get("prev") or {}
|
||||
if not prev:
|
||||
return None
|
||||
|
||||
prev_time = parse_timestamp(prev.get("time"))
|
||||
curr_time = parse_timestamp(item.get("time"))
|
||||
if prev_time is None or curr_time is None:
|
||||
return None
|
||||
|
||||
delta_s = (curr_time - prev_time).total_seconds()
|
||||
if delta_s <= 0 or delta_s > 180:
|
||||
return None
|
||||
|
||||
prev_lat = prev.get("lat")
|
||||
prev_lon = prev.get("lon")
|
||||
if prev_lat is None or prev_lon is None:
|
||||
return None
|
||||
|
||||
delta_m = haversine_m(
|
||||
scaled_coord(prev_lat),
|
||||
scaled_coord(prev_lon),
|
||||
scaled_coord(item["lat"]),
|
||||
scaled_coord(item["lon"]),
|
||||
)
|
||||
if delta_m < 5.0:
|
||||
return None
|
||||
|
||||
return min((delta_m / delta_s) * 3.6, MAX_BUS_SPEED_KMH)
|
||||
|
||||
|
||||
def build_estimate(item: dict[str, Any], target: TargetPoint) -> BusEstimate:
|
||||
lat = scaled_coord(item["lat"])
|
||||
lon = scaled_coord(item["lon"])
|
||||
distance_m = haversine_m(lat, lon, target.lat, target.lon)
|
||||
|
||||
prev = item.get("prev") or {}
|
||||
prev_distance = None
|
||||
if prev.get("lat") is not None and prev.get("lon") is not None:
|
||||
prev_distance = haversine_m(
|
||||
scaled_coord(prev["lat"]),
|
||||
scaled_coord(prev["lon"]),
|
||||
target.lat,
|
||||
target.lon,
|
||||
)
|
||||
|
||||
approaching = prev_distance is None or distance_m <= prev_distance + 35.0
|
||||
speed_kmh = compute_speed_kmh(item)
|
||||
effective_speed_kmh = speed_kmh
|
||||
|
||||
if effective_speed_kmh is None and approaching:
|
||||
effective_speed_kmh = 12.0 if distance_m < 500 else 18.0
|
||||
elif effective_speed_kmh is not None and effective_speed_kmh < 4.0 and distance_m > 120:
|
||||
effective_speed_kmh = 4.0
|
||||
|
||||
eta_min = None
|
||||
if effective_speed_kmh is not None:
|
||||
meters_per_minute = effective_speed_kmh * 1000.0 / 60.0
|
||||
eta_min = distance_m / meters_per_minute
|
||||
|
||||
sample_time = parse_timestamp(item.get("time"))
|
||||
age_s = None
|
||||
if sample_time is not None:
|
||||
age_s = (current_utc() - sample_time).total_seconds()
|
||||
live = not bool(item.get("lost")) and (age_s is None or age_s <= 180)
|
||||
|
||||
return BusEstimate(
|
||||
route=target.route,
|
||||
vehicle_id=str(item.get("num", "")),
|
||||
distance_m=distance_m,
|
||||
eta_min=eta_min,
|
||||
speed_kmh=speed_kmh,
|
||||
approaching=approaching,
|
||||
live=live,
|
||||
)
|
||||
|
||||
|
||||
def choose_best_estimate(
|
||||
vehicles: list[dict[str, Any]],
|
||||
target: TargetPoint,
|
||||
) -> tuple[int, BusEstimate | None]:
|
||||
estimates = [
|
||||
build_estimate(item, target)
|
||||
for item in vehicles
|
||||
if str(item.get("name")) == target.route and str(item.get("type")) == "2"
|
||||
]
|
||||
if not estimates:
|
||||
return (0, None)
|
||||
|
||||
live_estimates = [estimate for estimate in estimates if estimate.live]
|
||||
approaching_live = [estimate for estimate in live_estimates if estimate.approaching]
|
||||
bus_count = len(live_estimates) if live_estimates else len(estimates)
|
||||
|
||||
def eta_sort_key(estimate: BusEstimate) -> tuple[float, float]:
|
||||
eta = estimate.eta_min if estimate.eta_min is not None else float("inf")
|
||||
return (eta, estimate.distance_m)
|
||||
|
||||
if approaching_live:
|
||||
return (bus_count, min(approaching_live, key=eta_sort_key))
|
||||
if live_estimates:
|
||||
return (bus_count, min(live_estimates, key=lambda estimate: estimate.distance_m))
|
||||
return (bus_count, min(estimates, key=lambda estimate: estimate.distance_m))
|
||||
|
||||
|
||||
def build_line(route: str, bus_count: int, estimate: BusEstimate | None) -> str:
|
||||
if estimate is None:
|
||||
return fit_lcd(f"{route} #{bus_count} no signal")
|
||||
text = (
|
||||
f"{route} #{bus_count} "
|
||||
f"{format_eta(estimate.eta_min)} "
|
||||
f"{format_distance(estimate.distance_m)}"
|
||||
)
|
||||
return fit_lcd(text)
|
||||
|
||||
|
||||
class BusApiClient:
|
||||
def __init__(self) -> None:
|
||||
self.cookie_jar = http.cookiejar.CookieJar()
|
||||
self.opener = request.build_opener(
|
||||
request.HTTPCookieProcessor(self.cookie_jar)
|
||||
)
|
||||
self.csrf_token: str | None = None
|
||||
|
||||
def refresh_session(self) -> None:
|
||||
req = request.Request(
|
||||
API_ROOT,
|
||||
headers={
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
},
|
||||
)
|
||||
with self.opener.open(req, timeout=20) as response:
|
||||
html = response.read().decode("utf-8", errors="replace")
|
||||
|
||||
match = CSRF_RE.search(html)
|
||||
if match is None:
|
||||
raise RuntimeError("CSRF token not found on rnd.don.su main page")
|
||||
self.csrf_token = match.group(1)
|
||||
|
||||
def fetch_vehicles(self) -> list[dict[str, Any]]:
|
||||
if self.csrf_token is None:
|
||||
self.refresh_session()
|
||||
|
||||
payload = json.dumps({"bus": ROUTE_REQUEST}, ensure_ascii=False).encode("utf-8")
|
||||
req = request.Request(
|
||||
GET_BUS_URL,
|
||||
data=payload,
|
||||
method="POST",
|
||||
headers={
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json;charset=utf-8",
|
||||
"Origin": API_ROOT.rstrip("/"),
|
||||
"Referer": API_ROOT,
|
||||
"X-CSRF-TOKEN": self.csrf_token or "",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with self.opener.open(req, timeout=20) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except error.HTTPError as exc:
|
||||
if exc.code not in (403, 422):
|
||||
raise
|
||||
self.refresh_session()
|
||||
req.remove_header("X-CSRF-TOKEN")
|
||||
req.add_header("X-CSRF-TOKEN", self.csrf_token or "")
|
||||
with self.opener.open(req, timeout=20) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
class SerialDisplay:
|
||||
def __init__(self, port: str | None, baud_rate: int) -> None:
|
||||
self.port = port
|
||||
self.baud_rate = baud_rate
|
||||
self._serial = None
|
||||
self._serial_module = None
|
||||
|
||||
def _connect(self) -> None:
|
||||
if self.port is None or self._serial is not None:
|
||||
return
|
||||
|
||||
if self._serial_module is None:
|
||||
try:
|
||||
import serial # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"pyserial is required for --serial-port. Install with: pip install pyserial"
|
||||
) from exc
|
||||
self._serial_module = serial
|
||||
|
||||
self._serial = self._serial_module.Serial(
|
||||
self.port,
|
||||
self.baud_rate,
|
||||
timeout=1,
|
||||
write_timeout=1,
|
||||
)
|
||||
time.sleep(2.0)
|
||||
|
||||
def send(self, line1: str, line2: str) -> None:
|
||||
if self.port is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self._connect()
|
||||
assert self._serial is not None
|
||||
payload = f"L1:{line1}\nL2:{line2}\n".encode("ascii", errors="replace")
|
||||
self._serial.write(payload)
|
||||
self._serial.flush()
|
||||
except Exception as exc:
|
||||
print(f"Serial send failed: {exc}", file=sys.stderr)
|
||||
if self._serial is not None:
|
||||
try:
|
||||
self._serial.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._serial = None
|
||||
|
||||
|
||||
def poll_once(client: BusApiClient) -> tuple[str, str]:
|
||||
vehicles = client.fetch_vehicles()
|
||||
lines: list[str] = []
|
||||
for route in ("94", "96"):
|
||||
bus_count, estimate = choose_best_estimate(vehicles, TARGETS[route])
|
||||
lines.append(build_line(route, bus_count, estimate))
|
||||
return lines[0], lines[1]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
client = BusApiClient()
|
||||
display = SerialDisplay(args.serial_port, args.baud_rate)
|
||||
|
||||
while True:
|
||||
try:
|
||||
line1, line2 = poll_once(client)
|
||||
print(line1.rstrip())
|
||||
print(line2.rstrip())
|
||||
print("-" * 16)
|
||||
display.send(line1, line2)
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"Update failed: {exc}", file=sys.stderr)
|
||||
|
||||
if args.once:
|
||||
return 0
|
||||
time.sleep(max(args.interval, 5.0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
pyserial>=3.5
|
||||
Loading…
Reference in a new issue