aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--README36
-rw-r--r--app.py40
-rw-r--r--block_weather.py67
-rw-r--r--config.def.py1
-rw-r--r--static/script.js56
-rw-r--r--static/style.css18
-rw-r--r--templates/index.html19
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
diff --git a/README b/README
new file mode 100644
index 0000000..71c0ae3
--- /dev/null
+++ b/README
@@ -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
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..e7a6075
--- /dev/null
+++ b/app.py
@@ -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__)
+
+def index():
+ return flask.render_template("index.html")
+
+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>