temp-set.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. #!/usr/bin/env python3
  2. import os
  3. import tempfile
  4. import threading
  5. from flask import Flask, jsonify, request, abort, render_template_string
  6. app = Flask(__name__)
  7. # --- Configuration ---
  8. VALUE_FILE = os.environ.get("VALUE_FILE", "value.txt")
  9. POLL_INTERVAL_MS = int(os.environ.get("POLL_INTERVAL_MS", "20000")) # client refresh rate
  10. _write_lock = threading.Lock()
  11. # --- Helpers ---
  12. def _clamp_to_range(val: int) -> int:
  13. return max(0, min(99, int(val)))
  14. def _ensure_file_exists():
  15. """Create the value file with default '0' if missing or invalid."""
  16. if not os.path.exists(VALUE_FILE):
  17. _atomic_write("0")
  18. return
  19. try:
  20. _ = _read_value()
  21. except Exception:
  22. _atomic_write("0")
  23. def _read_value() -> int:
  24. """Read the current numeric value from disk."""
  25. with open(VALUE_FILE, "r", encoding="utf-8") as f:
  26. raw = f.read().strip()
  27. # Allow blank/new files to recover to 0
  28. if raw == "":
  29. return 0
  30. n = int(raw)
  31. return _clamp_to_range(n)
  32. def _atomic_write(text: str):
  33. """Atomically write text to VALUE_FILE."""
  34. dir_name = os.path.dirname(os.path.abspath(VALUE_FILE)) or "."
  35. os.makedirs(dir_name, exist_ok=True)
  36. fd, tmp_path = tempfile.mkstemp(prefix=".value-", dir=dir_name, text=True)
  37. try:
  38. with os.fdopen(fd, "w", encoding="utf-8") as tmp:
  39. tmp.write(text)
  40. tmp.flush()
  41. os.fsync(tmp.fileno())
  42. os.replace(tmp_path, VALUE_FILE) # atomic on POSIX/Windows
  43. finally:
  44. # If replace succeeded, tmp_path no longer exists; ignore errors.
  45. try:
  46. if os.path.exists(tmp_path):
  47. os.remove(tmp_path)
  48. except Exception:
  49. pass
  50. def _get_mtime() -> float:
  51. try:
  52. return os.path.getmtime(VALUE_FILE)
  53. except FileNotFoundError:
  54. return 0.0
  55. # --- Routes ---
  56. @app.route("/", methods=["GET"])
  57. def index():
  58. _ensure_file_exists()
  59. val = _read_value()
  60. mtime = _get_mtime()
  61. # Single-file template for convenience
  62. html = """
  63. <!doctype html>
  64. <html lang="en">
  65. <head>
  66. <meta charset="utf-8" />
  67. <meta name="viewport" content="width=device-width,initial-scale=1" />
  68. <title>Tiny Value Server</title>
  69. <style>
  70. :root { --accent: #2b7cff; --bg:#0b0d12; --card:#121722; --text:#e8eefc; --muted:#9db0d7; }
  71. 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%); }
  72. .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); }
  73. h1 { margin: 0 0 8px; font-size: 1.5rem; }
  74. .sub { color: var(--muted); margin-bottom: 24px; }
  75. .value-display { font-size: clamp(48px, 9vw, 96px); font-weight: 800; text-align:center; letter-spacing: 2px; margin: 14px 0 6px; }
  76. .mtime { text-align:center; color: var(--muted); font-size: 0.9rem; margin-bottom: 18px; }
  77. .buttons { display:grid; grid-template-columns: repeat(4, minmax(100px,1fr)); gap:12px; margin: 12px 0 22px;}
  78. button.big {
  79. font-size: 24px; font-weight: 700; padding: 18px 8px; border-radius: 12px; border:1px solid #2a3245; background:#172033; color:var(--text);
  80. cursor:pointer; transition: transform 0.06s ease, background 0.2s ease, box-shadow 0.2s ease;
  81. box-shadow: inset 0 0 0 1px #2a3245, 0 8px 20px rgba(0,0,0,0.35);
  82. }
  83. button.big:hover { transform: translateY(-1px); background:#1a2742; }
  84. button.primary { background: linear-gradient(180deg, #2b7cff, #2a5bff); border:none; color:white; }
  85. .control-row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
  86. input[type="number"] {
  87. width: 100px; font-size: 18px; padding: 10px 12px; border-radius: 10px; border:1px solid #2a3245; background:#0d121c; color:var(--text);
  88. }
  89. input[type="range"] { width: min(100%, 520px); }
  90. .save { padding: 12px 16px; border-radius: 10px; border:0; background: var(--accent); color:white; font-weight:700; cursor:pointer; }
  91. .status { min-height: 1.4em; color: var(--muted); }
  92. .footer { margin-top: 24px; color: var(--muted); font-size: 0.9rem; }
  93. .grid { display:grid; grid-template-columns: 1fr; gap:18px; }
  94. @media (min-width: 720px) {
  95. .grid { grid-template-columns: 1.2fr 1fr; align-items: center; }
  96. }
  97. </style>
  98. </head>
  99. <body>
  100. <div class="wrap">
  101. <h1>Tiny Value Server</h1>
  102. <div class="sub">Stores a single integer 0–99 in <code>{{value_file}}</code>. Updates live if the file changes.</div>
  103. <div class="grid">
  104. <div>
  105. <div class="value-display" id="valueDisplay">{{value}}</div>
  106. <div class="mtime" id="mtime">last updated: {{mtime_human}}</div>
  107. <div class="buttons">
  108. <button class="big" data-value="0">0</button>
  109. <button class="big" data-value="7">7</button>
  110. <button class="big" data-value="33">33</button>
  111. <button class="big primary" data-value="99">99</button>
  112. </div>
  113. </div>
  114. <div>
  115. <label for="valueRange">Set a custom value (0–99)</label>
  116. <div class="control-row">
  117. <input id="valueNumber" type="number" inputmode="numeric" min="0" max="99" value="{{value}}">
  118. <input id="valueRange" type="range" min="0" max="99" value="{{value}}">
  119. <button id="saveBtn" class="save">Save</button>
  120. </div>
  121. <div id="status" class="status"></div>
  122. <div class="footer">Auto-refresh every {{poll_ms}} ms.</div>
  123. </div>
  124. </div>
  125. </div>
  126. <script>
  127. const POLL_MS = {{poll_ms}};
  128. const valueDisplay = document.getElementById("valueDisplay");
  129. const mtimeEl = document.getElementById("mtime");
  130. const statusEl = document.getElementById("status");
  131. const num = document.getElementById("valueNumber");
  132. const rng = document.getElementById("valueRange");
  133. const saveBtn = document.getElementById("saveBtn");
  134. function clamp(n) {
  135. n = Number(n);
  136. if (!Number.isFinite(n)) return 0;
  137. return Math.max(0, Math.min(99, Math.trunc(n)));
  138. }
  139. function setStatus(msg, ok=true) {
  140. statusEl.textContent = msg || "";
  141. statusEl.style.color = ok ? "#9db0d7" : "#ff9aa2";
  142. }
  143. function syncInputs(v) {
  144. num.value = v;
  145. rng.value = v;
  146. }
  147. async function fetchValue() {
  148. try {
  149. const r = await fetch("/value", { cache: "no-store" });
  150. if (!r.ok) throw new Error("HTTP " + r.status);
  151. const data = await r.json();
  152. valueDisplay.textContent = data.value;
  153. mtimeEl.textContent = "last updated: " + data.mtime_human;
  154. // Only pull slider if server changed
  155. if (Number(num.value) !== data.value) {
  156. syncInputs(data.value);
  157. }
  158. } catch (e) {
  159. setStatus("Failed to refresh: " + e.message, false);
  160. }
  161. }
  162. async function postValue(v) {
  163. try {
  164. const r = await fetch("/set", {
  165. method: "POST",
  166. headers: { "Content-Type": "application/json" },
  167. body: JSON.stringify({ value: v })
  168. });
  169. if (!r.ok) {
  170. const t = await r.text();
  171. throw new Error("HTTP " + r.status + " " + t);
  172. }
  173. const data = await r.json();
  174. setStatus("Saved.");
  175. valueDisplay.textContent = data.value;
  176. mtimeEl.textContent = "last updated: " + data.mtime_human;
  177. syncInputs(data.value);
  178. } catch (e) {
  179. setStatus("Save failed: " + e.message, false);
  180. }
  181. }
  182. document.querySelectorAll("button.big").forEach(btn => {
  183. btn.addEventListener("click", () => {
  184. const v = clamp(btn.dataset.value);
  185. syncInputs(v);
  186. postValue(v);
  187. });
  188. });
  189. num.addEventListener("input", () => {
  190. syncInputs(clamp(num.value));
  191. });
  192. rng.addEventListener("input", () => {
  193. syncInputs(clamp(rng.value));
  194. });
  195. saveBtn.addEventListener("click", () => {
  196. const v = clamp(num.value);
  197. syncInputs(v);
  198. postValue(v);
  199. });
  200. // initial and polling
  201. fetchValue();
  202. setInterval(fetchValue, POLL_MS);
  203. </script>
  204. </body>
  205. </html>
  206. """
  207. return render_template_string(
  208. html,
  209. value_file=os.path.abspath(VALUE_FILE),
  210. value=val,
  211. mtime_human=_fmt_time(mtime),
  212. poll_ms=POLL_INTERVAL_MS,
  213. )
  214. @app.route("/value", methods=["GET"])
  215. def get_value():
  216. _ensure_file_exists()
  217. try:
  218. val = _read_value()
  219. except Exception:
  220. # If the file is corrupt or unreadable, reset to 0
  221. with _write_lock:
  222. _atomic_write("0")
  223. val = 0
  224. mtime = _get_mtime()
  225. return jsonify({"value": val, "mtime": mtime, "mtime_human": _fmt_time(mtime)})
  226. @app.route("/set", methods=["POST"])
  227. def set_value():
  228. if not request.is_json:
  229. abort(400, "Expected application/json body")
  230. body = request.get_json(silent=True) or {}
  231. if "value" not in body:
  232. abort(400, "Missing 'value'")
  233. try:
  234. new_val = _clamp_to_range(int(body["value"]))
  235. except Exception:
  236. abort(400, "Value must be an integer 0–99")
  237. with _write_lock:
  238. _atomic_write(str(new_val))
  239. mtime = _get_mtime()
  240. return jsonify({"ok": True, "value": new_val, "mtime": mtime, "mtime_human": _fmt_time(mtime)})
  241. # --- Utilities ---
  242. import time
  243. def _fmt_time(mtime: float) -> str:
  244. if not mtime:
  245. return "never"
  246. return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mtime))
  247. # --- Entry point ---
  248. if __name__ == "__main__":
  249. _ensure_file_exists()
  250. app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8087")), debug=True)