diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | README | 36 | ||||
| -rw-r--r-- | app.py | 40 | ||||
| -rw-r--r-- | block_weather.py | 67 | ||||
| -rw-r--r-- | config.def.py | 1 | ||||
| -rw-r--r-- | static/script.js | 56 | ||||
| -rw-r--r-- | static/style.css | 18 | ||||
| -rw-r--r-- | templates/index.html | 19 |
8 files changed, 240 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0359188 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +config.py @@ -0,0 +1,36 @@ +MASU - minimalist dashboard + +what: grid of blocks (clocks, weather, etc) w/ real-time updates + +how: flask + vanilla js, no bloat + +why: simple, tweakable, fast + +setup: + + git clone <url> + python -m venv .venv + source .venv/bin/activate + pip install flask requests + flask run + +use: + + http://localhost:5000 + reload/tweak in header + +files: + + app.py: server + static/: css, js + templates/: html + +hack: + + add blocks: app.py + script.js + set intervals: script.js + +deploy: + + local: python app.py + server: pip install gunicorn; gunicorn -w 4 app:app @@ -0,0 +1,40 @@ +import flask +import requests +import datetime +import zoneinfo +import locale + +import block_weather + +locale.setlocale(locale.LC_ALL, '') +app = flask.Flask(__name__) + [email protected]("/") +def index(): + return flask.render_template("index.html") + [email protected]("/time") +def get_time(): + # TODO: date should be passed as JSON + date = datetime.datetime.now(zoneinfo.ZoneInfo("Asia/Tokyo")) + quarter = (date.month - 1) // 3 + 1 + week = date.isocalendar().week + lines = [ + "Tokyo: " + date.strftime("%x (%a)") + f" (Q{quarter}W{week})", + "Tokyo: " + date.astimezone(zoneinfo.ZoneInfo("Asia/Tokyo")).strftime("%H:%M (%Z) (UTC%:z)"), + "Berlin: " + date.astimezone(zoneinfo.ZoneInfo("Europe/Berlin")).strftime("%H:%M (%Z) (UTC%:z)"), + "Lima: " + date.astimezone(zoneinfo.ZoneInfo("America/Lima")).strftime("%H:%M (%Z) (UTC%:z)"), + "New York: " + date.astimezone(zoneinfo.ZoneInfo("America/New_York")).strftime("%H:%M (%Z) (UTC%:z)"), + "Chicago: " + date.astimezone(zoneinfo.ZoneInfo("America/Chicago")).strftime("%H:%M (%Z) (UTC%:z)"), + "Denver: " + date.astimezone(zoneinfo.ZoneInfo("America/Denver")).strftime("%H:%M (%Z) (UTC%:z)"), + "Los Angeles: " + date.astimezone(zoneinfo.ZoneInfo("America/Los_Angeles")).strftime("%H:%M (%Z) (UTC%:z)"), + "Anchorage: " + date.astimezone(zoneinfo.ZoneInfo("America/Anchorage")).strftime("%H:%M (%Z) (UTC%:z)"), + "Honolulu: " + date.astimezone(zoneinfo.ZoneInfo("Pacific/Honolulu")).strftime("%H:%M (%Z) (UTC%:z)"), + ] + return flask.jsonify({"time": "\n".join(lines)}) + [email protected]("/weather") +def get_weather(): + city = flask.request.args.get("city", "Kofu, JP") + data = block_weather.get_current_weather(city) + return flask.jsonify(data) diff --git a/block_weather.py b/block_weather.py new file mode 100644 index 0000000..dc041fd --- /dev/null +++ b/block_weather.py @@ -0,0 +1,67 @@ +""" +Handle external requests and pre-process weather data + +Providers: +* OpenWeather + Free Access on OpenWeather allows for some API calls, see + https://openweathermap.org/price +""" + +import requests +import pandas as pd + +import config + +def get_current_weather(city: str) -> dict: + """https://openweathermap.org/current""" + pos = _get_position(city) + if not pos: + return None + url = f"http://api.openweathermap.org/data/2.5/weather?lat={pos['lat']}&lon={pos['lon']}&appid={config.OPENWEATHER_API_KEY}&units=metric" + data = requests.get(url).json() + + data['summary'] = _format_current_weather(data) + # On icon URL codes: https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2 + data['icon_url'] = f"https://openweathermap.org/img/wn/{data['weather'][0]['icon']}@2x.png" + return data + + +def _format_current_weather(data: dict) -> str: + """final formatting should occur in HTML/JS, not here""" + ts = [data['dt'], data['sys']['sunrise'], data['sys']['sunset']] + ts = [pd.to_datetime(x + data['timezone'], unit='s') for x in ts] + s = f"""{data['name']}, {data['sys']['country']} ({ts[0]}) +{data['weather'][0]['description']} +{data['main']['temp']}°C (feels like {data['main']['feels_like']}°C), humidity: {data['main']['humidity']}%, pressure: {data['main']['grnd_level']} hPa +visibility: {data['visibility']/1000:.1f}km +wind: {data['wind']['speed']}m/s from {data['wind']['deg']}°N (with gusts of {data['wind'].get('gust', 0)} m/s) +clouds: {data['clouds']['all']}% +sunrise & sunset: {ts[1].strftime('%H:%M')}, {ts[2].strftime('%H:%M')}""" + return s + + +def get_forecast(city: str) -> dict: + """https://openweathermap.org/forecast5""" + # 5 days, 3 hours forecast data + lat, lon = _get_position(city) + url = f"http://api.openweathermap.org/data/2.5/forecast?lat={lat}&lon={lon}&appid={config.OPENWEATHER_API_KEY}&units=metric" + data = requests.get(url).json() + return data + + +def _get_position(name) -> dict: + """direct geocoding + name: City name, state code (only for the US) and country code divided by comma. Please use ISO 3166 country codes. + + https://openweathermap.org/api/geocoding-api#direct + """ + limit = 5 + url = f"http://api.openweathermap.org/geo/1.0/direct?q={name}&limit={limit}&appid={config.OPENWEATHER_API_KEY}" + data = requests.get(url).json() + if not data: + return None + return data[0] + + +def get_weathermap() -> dict: + """https://openweathermap.org/api/weathermaps""" diff --git a/config.def.py b/config.def.py new file mode 100644 index 0000000..c80065d --- /dev/null +++ b/config.def.py @@ -0,0 +1 @@ +OPENWEATHER_API_KEY = "" diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..1cc3e44 --- /dev/null +++ b/static/script.js @@ -0,0 +1,56 @@ +let polling = true +const blocks = { + time: { interval: 30 * 1000, lastUpdate: 0, update: updateTime }, // 30s + weather: { interval: 30 * 60000, lastUpdate: 0, update: updateWeather } // 30min + // Add more: { interval: X, lastUpdate: 0, update: updateFunction } +}; +let lastPoll = 0; + +function updateTime() { + fetch("/time") + .then(res => res.json()) + .then(data => document.getElementById("time").innerText = data.time); +} + +function updateWeather() { + const city = document.getElementById("city").value; + fetch(`/weather?city=${encodeURIComponent(city)}`) + .then(res => res.json()) + .then(data => { + const weatherBlock = document.getElementById("weather"); + if (!data) weatherBlock.innerText = "Error: Bad city"; + else weatherBlock.innerText = `${data.summary}`; + }); +} + +function pollUpdates() { + if (!polling) return; + const now = Date.now(); + Object.keys(blocks).forEach(key => { + const block = blocks[key]; + if (now - block.lastUpdate >= block.interval) { + block.update(); + block.lastUpdate = now; + } + }); + lastPoll = now; +} + +function reloadAll() { + Object.keys(blocks).forEach(key => { + blocks[key].update(); + blocks[key].lastUpdate = Date.now(); + }); +} + +// Header controls +document.getElementById("reload").addEventListener("click", reloadAll); +document.getElementById("pause").addEventListener("change", (e) => { + polling = e.target.checked; +}); + +// Initial load +reloadAll(); +// Poll every 500ms to check intervals (fast enough for 1s updates, light on CPU) +// maybe the 1s updates should be special case, but for now let's keep it simple +setInterval(pollUpdates, 500); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..ff01a13 --- /dev/null +++ b/static/style.css @@ -0,0 +1,18 @@ +body { margin: 0 } + +header { padding: 10px; text-align: center; } +header button, header label { margin: 0 10px; } + +.grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 5px; +} + +.block { + aspect-ratio: 1; + border: 1px solid #000; + background: #fff; + padding: 10px; + text-align: left; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6337647 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html> +<head> + <link rel="stylesheet" href="/static/style.css"> +</head> +<body> + <header> + <button id="reload">Reload</button> + <label>Weather City: <input id="city" value="Kofu, JP"></label> + <label><input type="checkbox" id="pause" checked> Auto-Update</label> + </header> + <div class="grid"> + <div class="block" id="time"></div> + <div class="block" id="weather"></div> + <!-- Add more blocks --> + </div> + <script src="/static/script.js"></script> +</body> +</html> |
