|
|
@@ -0,0 +1,288 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+import os
|
|
|
+import tempfile
|
|
|
+import threading
|
|
|
+from flask import Flask, jsonify, request, abort, render_template_string
|
|
|
+
|
|
|
+app = Flask(__name__)
|
|
|
+
|
|
|
+# --- Configuration ---
|
|
|
+VALUE_FILE = os.environ.get("VALUE_FILE", "value.txt")
|
|
|
+POLL_INTERVAL_MS = int(os.environ.get("POLL_INTERVAL_MS", "20000")) # client refresh rate
|
|
|
+_write_lock = threading.Lock()
|
|
|
+
|
|
|
+
|
|
|
+# --- Helpers ---
|
|
|
+def _clamp_to_range(val: int) -> int:
|
|
|
+ return max(0, min(99, int(val)))
|
|
|
+
|
|
|
+
|
|
|
+def _ensure_file_exists():
|
|
|
+ """Create the value file with default '0' if missing or invalid."""
|
|
|
+ if not os.path.exists(VALUE_FILE):
|
|
|
+ _atomic_write("0")
|
|
|
+ return
|
|
|
+ try:
|
|
|
+ _ = _read_value()
|
|
|
+ except Exception:
|
|
|
+ _atomic_write("0")
|
|
|
+
|
|
|
+
|
|
|
+def _read_value() -> int:
|
|
|
+ """Read the current numeric value from disk."""
|
|
|
+ with open(VALUE_FILE, "r", encoding="utf-8") as f:
|
|
|
+ raw = f.read().strip()
|
|
|
+ # Allow blank/new files to recover to 0
|
|
|
+ if raw == "":
|
|
|
+ return 0
|
|
|
+ n = int(raw)
|
|
|
+ return _clamp_to_range(n)
|
|
|
+
|
|
|
+
|
|
|
+def _atomic_write(text: str):
|
|
|
+ """Atomically write text to VALUE_FILE."""
|
|
|
+ dir_name = os.path.dirname(os.path.abspath(VALUE_FILE)) or "."
|
|
|
+ os.makedirs(dir_name, exist_ok=True)
|
|
|
+ fd, tmp_path = tempfile.mkstemp(prefix=".value-", dir=dir_name, text=True)
|
|
|
+ try:
|
|
|
+ with os.fdopen(fd, "w", encoding="utf-8") as tmp:
|
|
|
+ tmp.write(text)
|
|
|
+ tmp.flush()
|
|
|
+ os.fsync(tmp.fileno())
|
|
|
+ os.replace(tmp_path, VALUE_FILE) # atomic on POSIX/Windows
|
|
|
+ finally:
|
|
|
+ # If replace succeeded, tmp_path no longer exists; ignore errors.
|
|
|
+ try:
|
|
|
+ if os.path.exists(tmp_path):
|
|
|
+ os.remove(tmp_path)
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+def _get_mtime() -> float:
|
|
|
+ try:
|
|
|
+ return os.path.getmtime(VALUE_FILE)
|
|
|
+ except FileNotFoundError:
|
|
|
+ return 0.0
|
|
|
+
|
|
|
+
|
|
|
+# --- Routes ---
|
|
|
+@app.route("/", methods=["GET"])
|
|
|
+def index():
|
|
|
+ _ensure_file_exists()
|
|
|
+ val = _read_value()
|
|
|
+ mtime = _get_mtime()
|
|
|
+
|
|
|
+ # Single-file template for convenience
|
|
|
+ html = """
|
|
|
+<!doctype html>
|
|
|
+<html lang="en">
|
|
|
+<head>
|
|
|
+ <meta charset="utf-8" />
|
|
|
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
|
+ <title>Tiny Value Server</title>
|
|
|
+ <style>
|
|
|
+ :root { --accent: #2b7cff; --bg:#0b0d12; --card:#121722; --text:#e8eefc; --muted:#9db0d7; }
|
|
|
+ body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color:var(--text); background:linear-gradient(180deg, #0b0d12 0%, #0e1320 100%); }
|
|
|
+ .wrap { max-width: 880px; margin: 40px auto; padding: 24px; background: var(--card); border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.35); }
|
|
|
+ h1 { margin: 0 0 8px; font-size: 1.5rem; }
|
|
|
+ .sub { color: var(--muted); margin-bottom: 24px; }
|
|
|
+ .value-display { font-size: clamp(48px, 9vw, 96px); font-weight: 800; text-align:center; letter-spacing: 2px; margin: 14px 0 6px; }
|
|
|
+ .mtime { text-align:center; color: var(--muted); font-size: 0.9rem; margin-bottom: 18px; }
|
|
|
+ .buttons { display:grid; grid-template-columns: repeat(4, minmax(100px,1fr)); gap:12px; margin: 12px 0 22px;}
|
|
|
+ button.big {
|
|
|
+ font-size: 24px; font-weight: 700; padding: 18px 8px; border-radius: 12px; border:1px solid #2a3245; background:#172033; color:var(--text);
|
|
|
+ cursor:pointer; transition: transform 0.06s ease, background 0.2s ease, box-shadow 0.2s ease;
|
|
|
+ box-shadow: inset 0 0 0 1px #2a3245, 0 8px 20px rgba(0,0,0,0.35);
|
|
|
+ }
|
|
|
+ button.big:hover { transform: translateY(-1px); background:#1a2742; }
|
|
|
+ button.primary { background: linear-gradient(180deg, #2b7cff, #2a5bff); border:none; color:white; }
|
|
|
+ .control-row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
|
|
+ input[type="number"] {
|
|
|
+ width: 100px; font-size: 18px; padding: 10px 12px; border-radius: 10px; border:1px solid #2a3245; background:#0d121c; color:var(--text);
|
|
|
+ }
|
|
|
+ input[type="range"] { width: min(100%, 520px); }
|
|
|
+ .save { padding: 12px 16px; border-radius: 10px; border:0; background: var(--accent); color:white; font-weight:700; cursor:pointer; }
|
|
|
+ .status { min-height: 1.4em; color: var(--muted); }
|
|
|
+ .footer { margin-top: 24px; color: var(--muted); font-size: 0.9rem; }
|
|
|
+ .grid { display:grid; grid-template-columns: 1fr; gap:18px; }
|
|
|
+ @media (min-width: 720px) {
|
|
|
+ .grid { grid-template-columns: 1.2fr 1fr; align-items: center; }
|
|
|
+ }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="wrap">
|
|
|
+ <h1>Tiny Value Server</h1>
|
|
|
+ <div class="sub">Stores a single integer 0–99 in <code>{{value_file}}</code>. Updates live if the file changes.</div>
|
|
|
+
|
|
|
+ <div class="grid">
|
|
|
+ <div>
|
|
|
+ <div class="value-display" id="valueDisplay">{{value}}</div>
|
|
|
+ <div class="mtime" id="mtime">last updated: {{mtime_human}}</div>
|
|
|
+
|
|
|
+ <div class="buttons">
|
|
|
+ <button class="big" data-value="0">0</button>
|
|
|
+ <button class="big" data-value="7">7</button>
|
|
|
+ <button class="big" data-value="33">33</button>
|
|
|
+ <button class="big primary" data-value="99">99</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label for="valueRange">Set a custom value (0–99)</label>
|
|
|
+ <div class="control-row">
|
|
|
+ <input id="valueNumber" type="number" inputmode="numeric" min="0" max="99" value="{{value}}">
|
|
|
+ <input id="valueRange" type="range" min="0" max="99" value="{{value}}">
|
|
|
+ <button id="saveBtn" class="save">Save</button>
|
|
|
+ </div>
|
|
|
+ <div id="status" class="status"></div>
|
|
|
+ <div class="footer">Auto-refresh every {{poll_ms}} ms.</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+<script>
|
|
|
+const POLL_MS = {{poll_ms}};
|
|
|
+const valueDisplay = document.getElementById("valueDisplay");
|
|
|
+const mtimeEl = document.getElementById("mtime");
|
|
|
+const statusEl = document.getElementById("status");
|
|
|
+const num = document.getElementById("valueNumber");
|
|
|
+const rng = document.getElementById("valueRange");
|
|
|
+const saveBtn = document.getElementById("saveBtn");
|
|
|
+
|
|
|
+function clamp(n) {
|
|
|
+ n = Number(n);
|
|
|
+ if (!Number.isFinite(n)) return 0;
|
|
|
+ return Math.max(0, Math.min(99, Math.trunc(n)));
|
|
|
+}
|
|
|
+
|
|
|
+function setStatus(msg, ok=true) {
|
|
|
+ statusEl.textContent = msg || "";
|
|
|
+ statusEl.style.color = ok ? "#9db0d7" : "#ff9aa2";
|
|
|
+}
|
|
|
+
|
|
|
+function syncInputs(v) {
|
|
|
+ num.value = v;
|
|
|
+ rng.value = v;
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchValue() {
|
|
|
+ try {
|
|
|
+ const r = await fetch("/value", { cache: "no-store" });
|
|
|
+ if (!r.ok) throw new Error("HTTP " + r.status);
|
|
|
+ const data = await r.json();
|
|
|
+ valueDisplay.textContent = data.value;
|
|
|
+ mtimeEl.textContent = "last updated: " + data.mtime_human;
|
|
|
+ // Only pull slider if server changed
|
|
|
+ if (Number(num.value) !== data.value) {
|
|
|
+ syncInputs(data.value);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ setStatus("Failed to refresh: " + e.message, false);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function postValue(v) {
|
|
|
+ try {
|
|
|
+ const r = await fetch("/set", {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify({ value: v })
|
|
|
+ });
|
|
|
+ if (!r.ok) {
|
|
|
+ const t = await r.text();
|
|
|
+ throw new Error("HTTP " + r.status + " " + t);
|
|
|
+ }
|
|
|
+ const data = await r.json();
|
|
|
+ setStatus("Saved.");
|
|
|
+ valueDisplay.textContent = data.value;
|
|
|
+ mtimeEl.textContent = "last updated: " + data.mtime_human;
|
|
|
+ syncInputs(data.value);
|
|
|
+ } catch (e) {
|
|
|
+ setStatus("Save failed: " + e.message, false);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+document.querySelectorAll("button.big").forEach(btn => {
|
|
|
+ btn.addEventListener("click", () => {
|
|
|
+ const v = clamp(btn.dataset.value);
|
|
|
+ syncInputs(v);
|
|
|
+ postValue(v);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+num.addEventListener("input", () => {
|
|
|
+ syncInputs(clamp(num.value));
|
|
|
+});
|
|
|
+rng.addEventListener("input", () => {
|
|
|
+ syncInputs(clamp(rng.value));
|
|
|
+});
|
|
|
+
|
|
|
+saveBtn.addEventListener("click", () => {
|
|
|
+ const v = clamp(num.value);
|
|
|
+ syncInputs(v);
|
|
|
+ postValue(v);
|
|
|
+});
|
|
|
+
|
|
|
+// initial and polling
|
|
|
+fetchValue();
|
|
|
+setInterval(fetchValue, POLL_MS);
|
|
|
+</script>
|
|
|
+</body>
|
|
|
+</html>
|
|
|
+ """
|
|
|
+ return render_template_string(
|
|
|
+ html,
|
|
|
+ value_file=os.path.abspath(VALUE_FILE),
|
|
|
+ value=val,
|
|
|
+ mtime_human=_fmt_time(mtime),
|
|
|
+ poll_ms=POLL_INTERVAL_MS,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+@app.route("/value", methods=["GET"])
|
|
|
+def get_value():
|
|
|
+ _ensure_file_exists()
|
|
|
+ try:
|
|
|
+ val = _read_value()
|
|
|
+ except Exception:
|
|
|
+ # If the file is corrupt or unreadable, reset to 0
|
|
|
+ with _write_lock:
|
|
|
+ _atomic_write("0")
|
|
|
+ val = 0
|
|
|
+ mtime = _get_mtime()
|
|
|
+ return jsonify({"value": val, "mtime": mtime, "mtime_human": _fmt_time(mtime)})
|
|
|
+
|
|
|
+
|
|
|
+@app.route("/set", methods=["POST"])
|
|
|
+def set_value():
|
|
|
+ if not request.is_json:
|
|
|
+ abort(400, "Expected application/json body")
|
|
|
+ body = request.get_json(silent=True) or {}
|
|
|
+ if "value" not in body:
|
|
|
+ abort(400, "Missing 'value'")
|
|
|
+ try:
|
|
|
+ new_val = _clamp_to_range(int(body["value"]))
|
|
|
+ except Exception:
|
|
|
+ abort(400, "Value must be an integer 0–99")
|
|
|
+
|
|
|
+ with _write_lock:
|
|
|
+ _atomic_write(str(new_val))
|
|
|
+
|
|
|
+ mtime = _get_mtime()
|
|
|
+ return jsonify({"ok": True, "value": new_val, "mtime": mtime, "mtime_human": _fmt_time(mtime)})
|
|
|
+
|
|
|
+
|
|
|
+# --- Utilities ---
|
|
|
+import time
|
|
|
+def _fmt_time(mtime: float) -> str:
|
|
|
+ if not mtime:
|
|
|
+ return "never"
|
|
|
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mtime))
|
|
|
+
|
|
|
+
|
|
|
+# --- Entry point ---
|
|
|
+if __name__ == "__main__":
|
|
|
+ _ensure_file_exists()
|
|
|
+ app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8087")), debug=True)
|