diff options
| author | Mitsuo Tokumori <[email protected]> | 2025-03-06 11:24:57 +0900 |
|---|---|---|
| committer | Mitsuo Tokumori <[email protected]> | 2025-03-06 11:24:57 +0900 |
| commit | 304d0a2c3e0e1eea58d6db1762c1b96e450b5843 (patch) | |
| tree | 70976dfdec1b65f92849125a47b7ff7ff18740f7 | |
| download | masu-304d0a2c3e0e1eea58d6db1762c1b96e450b5843.tar.gz masu-304d0a2c3e0e1eea58d6db1762c1b96e450b5843.tar.bz2 masu-304d0a2c3e0e1eea58d6db1762c1b96e450b5843.zip | |
Initial commit
Only 2 blocks: time and weather. Currently data is passed in formatted
strings made server-side (python) just for testing, later the formatting
should be client-side (html).
Also the "time" API might be redundant, ideally it should pass the city
names, and offsets (time zone), and then just use the system time
(otherwise DST jumps would need to be taken into account).
| -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> |
