Ver Fonte

temp-set.py - a stupuid util to make adjusting the manual duty cycle on the Astoria Argenta easier from my phone

Breandan Dezendorf há 1 mês atrás
pai
commit
b963b1bb06
1 ficheiros alterados com 288 adições e 0 exclusões
  1. 288 0
      dezendorf/applications/temp-set/temp-set.py

+ 288 - 0
dezendorf/applications/temp-set/temp-set.py

@@ -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)