From 35a8c00fbede3f681c7254fb0277972ebe42bd5c Mon Sep 17 00:00:00 2001 From: Cameron Otsuka Date: Sun, 16 Jun 2024 13:52:35 -0700 Subject: [PATCH] basics --- .env.sample | 2 + .gitignore | 164 ++++++++++++++++++++++++++++ requirements.txt | 11 ++ seattle_transit_tracker/__init__.py | 3 + seattle_transit_tracker/__main__.py | 102 +++++++++++++++++ seattle_transit_tracker/data.py | 56 ++++++++++ seattle_transit_tracker/model.py | 54 +++++++++ 7 files changed, 392 insertions(+) create mode 100644 .env.sample create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 seattle_transit_tracker/__init__.py create mode 100644 seattle_transit_tracker/__main__.py create mode 100644 seattle_transit_tracker/data.py create mode 100644 seattle_transit_tracker/model.py diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..fe84bdb --- /dev/null +++ b/.env.sample @@ -0,0 +1,2 @@ +API="https://api.pugetsound.onebusaway.org/api/where/" +API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2690e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,164 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +.vscode/ \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..73c98ae --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile - -o requirements.txt +certifi==2024.6.2 + # via requests +charset-normalizer==3.3.2 + # via requests +idna==3.7 + # via requests +requests==2.32.3 +urllib3==2.2.1 + # via requests diff --git a/seattle_transit_tracker/__init__.py b/seattle_transit_tracker/__init__.py new file mode 100644 index 0000000..950b6de --- /dev/null +++ b/seattle_transit_tracker/__init__.py @@ -0,0 +1,3 @@ +from . import data, model + +__all__ = ['data', 'model'] \ No newline at end of file diff --git a/seattle_transit_tracker/__main__.py b/seattle_transit_tracker/__main__.py new file mode 100644 index 0000000..a13b7e4 --- /dev/null +++ b/seattle_transit_tracker/__main__.py @@ -0,0 +1,102 @@ +""" Seattle Train Tracker main loop """ +import time +from .data import Seattle +from .model import ( + Agency, + LED, + Route, + Stop +) + +START_TIME = time.monotonic() +SEATTLE = Seattle() + +# routes and stops won't change often, so simply hardcode +link1_stops = { + '40_1108': Stop('40_1108', 'Westlake', 'SW', LED(0)), + '40_1121': Stop('40_1121', 'Westlake', 'NE', LED(0)), + '40_455': Stop('40_455', 'University St', 'SE', LED(0)), + '40_501': Stop('40_501', 'Pioneer Square', 'SE', LED(0)), + '40_532': Stop('40_532', 'Pioneer Square', 'NW', LED(0)), + '40_55578': Stop('40_55578', 'Rainier Beach', 'N', LED(0)), + '40_55656': Stop('40_55656', 'Othello', 'NW', LED(0)), + '40_55778': Stop('40_55778', 'Columbia City', 'NW', LED(0)), + '40_55860': Stop('40_55860', 'Mount Baker', 'NW', LED(0)), + '40_55949': Stop('40_55949', 'Mount Baker', 'SE', LED(0)), + '40_56039': Stop('40_56039', 'Columbia City', '', LED(0)), + '40_56159': Stop('40_56159', 'Othello', 'SE', LED(0)), + '40_56173': Stop('40_56173', 'Rainier Beach', 'S', LED(0)), + '40_565': Stop('40_565', 'University St', 'NW', LED(0)), + '40_621': Stop('40_621', "Int'l Dist/Chinatown", 'N', LED(0)), + '40_623': Stop('40_623', "Int'l Dist/Chinatown", 'S', LED(0)), + '40_990001': Stop('40_990001', 'U District', 'S', LED(0)), + '40_990002': Stop('40_990002', 'U District', 'N', LED(0)), + '40_990003': Stop('40_990003', 'Roosevelt', 'S', LED(0)), + '40_990004': Stop('40_990004', 'Roosevelt', 'N', LED(0)), + '40_990005': Stop('40_990005', 'Northgate', '', LED(0)), + '40_990006': Stop('40_990006', 'Northgate', '', LED(0)), + '40_99101': Stop('40_99101', 'Stadium', 'S', LED(0)), + '40_99111': Stop('40_99111', 'SODO', '', LED(0)), + '40_99121': Stop('40_99121', 'Beacon Hill', 'E', LED(0)), + '40_99240': Stop('40_99240', 'Beacon Hill', 'W', LED(0)), + '40_99256': Stop('40_99256', 'SODO', 'N', LED(0)), + '40_99260': Stop('40_99260', 'Stadium', 'N', LED(0)), + '40_99603': Stop('40_99603', 'Capitol Hill', '', LED(0)), + '40_99604': Stop('40_99604', 'Univ of Washington', 'S', LED(0)), + '40_99605': Stop('40_99605', 'Univ of Washington', 'N', LED(0)), + '40_99610': Stop('40_99610', 'Capitol Hill', 'S', LED(0)), + '40_99900': Stop('40_99900', "Tukwila Int'l Blvd", 'W', LED(0)), + '40_99903': Stop('40_99903', "SeaTac/Airport", '', LED(0)), + '40_99904': Stop('40_99904', "SeaTac/Airport", '', LED(0)), + '40_99905': Stop('40_99905', "Tukwila Int'l Blvd", 'E', LED(0)), + '40_99913': Stop('40_99913', 'Angle Lake', '', LED(0)), + '40_99914': Stop('40_99914', 'Angle Lake', '', LED(0)) +} +Link1Line = Route('40_100479', 'Link 1 Line') +Link1Line.stops = link1_stops + +link2_stops = { + '40_E09-T2': Stop('40_E09-T2', 'South Bellevue', '', LED(0)), + '40_E11-T1': Stop('40_E11-T1', 'East Main', 'S', LED(0)), + '40_E11-T2': Stop('40_E11-T2', 'East Main', 'N', LED(0)), + '40_E15-T1': Stop('40_E15-T1', 'Bellevue Downtown', 'W', LED(0)), + '40_E15-T2': Stop('40_E15-T2', 'Bellevue Downtown', 'E', LED(0)), + '40_E19-T1': Stop('40_E19-T1', 'Wilburton', 'S', LED(0)), + '40_E19-T2': Stop('40_E19-T2', 'Wilburton', 'N', LED(0)), + '40_E21-T1': Stop('40_E21-T1', 'Spring District', 'W', LED(0)), + '40_E21-T2': Stop('40_E21-T2', 'Spring District', 'E', LED(0)), + '40_E23-T1': Stop('40_E23-T1', 'BelRed', 'W', LED(0)), + '40_E23-T2': Stop('40_E23-T2', 'BelRed', 'E', LED(0)), + '40_E25-T1': Stop('40_E25-T1', 'Overlake Village', 'SW', LED(0)), + '40_E25-T2': Stop('40_E25-T2', 'Overlake Village', 'NE', LED(0)), + '40_E27-T1': Stop('40_E27-T1', 'Redmond Technology', '', LED(0)), + '40_E27-T2': Stop('40_E27-T2', 'Redmond Technology', '', LED(0)) +} +Link2Line = Route('40_2LINE', 'Link 2 Line') +Link2Line.stops = link2_stops + +SoundTransit = Agency(40, 'Sound Transit') +SoundTransit.routes = { + '40_100479': Link1Line, + '40_2LINE': Link2Line +} + +while True: + for route in SoundTransit.routes.keys(): + trips = SEATTLE.get_trips(route) + stop_ids: list[str] = [] + for trip in trips: + closest_stop_id = trip['status']['closestStop'] + stop_ids.append(closest_stop_id) + for stop_id, stop in SoundTransit.routes[route].stops.items(): + if stop_id in stop_ids: + stop.led.color = (255, 255, 255) + else: + stop.led.color = (0, 0, 0) + + # TODO: address LEDs instead of printing + for route in SoundTransit.routes.values(): + for key, value in route.stops.items(): + print(key, ': ', value.led.color) + print('\n') + time.sleep(60.0 - ((time.monotonic() - START_TIME) % 60.0)) \ No newline at end of file diff --git a/seattle_transit_tracker/data.py b/seattle_transit_tracker/data.py new file mode 100644 index 0000000..5bd5f96 --- /dev/null +++ b/seattle_transit_tracker/data.py @@ -0,0 +1,56 @@ +""" Gather static agency/route/stop data """ +import os +from requests import Session +from requests.adapters import HTTPAdapter +from urllib3.util import Retry + +class Seattle: + def __init__( + self, + api_key: str = os.environ['API_KEY'], + api: str = os.environ['API'] + ) -> None: + self.api_key = api_key + self.api = api + self.session = Session() + retries = Retry( + total=3, + backoff_factor=10, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods={'GET'} + ) + self.session.mount('https://', HTTPAdapter(max_retries=retries)) + + def request(self, route: str, **kwargs): + payload = { + 'key': self.api_key + } + for key, value in kwargs.items(): + payload[key] = value + endpoint = self.api + route + try: + response = self.session.get(endpoint, params=payload) + response.raise_for_status() + response_json = response.json() + self.last_updated = response_json['currentTime'] + return response_json + except Exception as _: + # TODO: better error handling + print('Issue') + + @property + def last_updated(self) -> int: + return self._last_updated + + @last_updated.setter + def last_updated(self, update_time: int) -> None: + self._last_updated = update_time + + @last_updated.deleter + def last_updated(self) -> None: + del self._last_updated + + def get_trips(self, route_id: str) -> list[str]: + response = self.request(f'trips-for-route/{route_id}.json', includeSchedule=False) + trip_list = response['data']['list'] + return trip_list diff --git a/seattle_transit_tracker/model.py b/seattle_transit_tracker/model.py new file mode 100644 index 0000000..23718f1 --- /dev/null +++ b/seattle_transit_tracker/model.py @@ -0,0 +1,54 @@ +""" Data model for transportation system """ +from dataclasses import dataclass, field + +type Routes = dict[str, Route] +type Stops = dict[str, Stop] +type RGB = tuple[int, int, int] + +@dataclass +class LED: + id: str + _color: RGB = (0, 0, 0) + + @property + def color(self) -> RGB: + return self._color + + @color.setter + def color(self, rgb: RGB) -> None: + self._color = rgb + +@dataclass +class Stop: + id: int + name: str + direction: str + led: LED + +@dataclass +class Route: + id: int + name: str + _stops: Stops = field(default_factory=dict) + + @property + def stops(self) -> Stops: + return self._stops + + @stops.setter + def stops(self, new_stops: Stops) -> None: + self._stops = new_stops + +@dataclass +class Agency: + id: int + name: str + _routes: Routes = field(default_factory=dict) + + @property + def routes(self) -> Routes: + return self._routes + + @routes.setter + def routes(self, new_routes: Routes) -> None: + self._routes = new_routes \ No newline at end of file -- 2.51.0