mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
fix(comfort): harden timer recovery and notification UX after review
This commit is contained in:
1 parent
d3fa282377
commit
222d5eccc9
7 files changed
+220
-28
No files matched your search
+37
-8
@@ -71,6 +71,15 @@ def notify(message):
|
||||
|
||||
|
||||
def home():
|
||||
# A total process deadline also bounds a CDP peer that keeps sending events
|
||||
# without completing the request. Keep cancellation ordered after this action.
|
||||
r = subprocess.run([sys.executable, str(Path(__file__).resolve()), '--home'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
if r.returncode:
|
||||
raise RuntimeError('Steam Home failed: ' + (r.stdout or r.stderr)[-300:])
|
||||
|
||||
|
||||
def open_home():
|
||||
page = Page()
|
||||
try:
|
||||
result = page.eval(HOME_JS)
|
||||
@@ -80,7 +89,7 @@ def home():
|
||||
page.sock.close()
|
||||
|
||||
|
||||
def tick(s, now, sample, warn=notify, go_home=home):
|
||||
def tick(s, now, sample, warn=notify, go_home=home, read_clock=clock):
|
||||
"""One deterministic step; injected actions/samples also exercise a fake Frame."""
|
||||
if not s.get('active'):
|
||||
return
|
||||
@@ -107,7 +116,7 @@ def tick(s, now, sample, warn=notify, go_home=home):
|
||||
# Missing samples never count as time worn. No catch-up burst after a disconnect.
|
||||
if now >= s['deadline'] - 60 and s['warned'] is None:
|
||||
warn('One minute left. Save your progress; Steam Home will open.')
|
||||
s['warned'] = now
|
||||
s['warned'] = max(now, read_clock())
|
||||
event(s, 'warning', 'One minute left. Save your progress; Steam Home will open.')
|
||||
if s['warned'] is not None and now >= max(s['deadline'], s['warned'] + 60):
|
||||
go_home()
|
||||
@@ -126,7 +135,7 @@ def tick(s, now, sample, warn=notify, go_home=home):
|
||||
for kind, enabled, value, message in (
|
||||
('battery', o['batteryAlert'], low if b else None, 'Frame battery is low (15% or less).'),
|
||||
('heat', o['heatAlert'], bool(hot) if hot is not None else None,
|
||||
'Frame reports a hot/critical thermal trip or battery overheat. Take a break.')):
|
||||
'Frame reports a hot/critical thermal trip or battery overheat. Ask the wearer to take a break.')):
|
||||
if enabled and value and kind not in s['latched']:
|
||||
event(s, kind, message)
|
||||
s['latched'].append(kind)
|
||||
@@ -147,9 +156,17 @@ def locked(name='state.lock', nonblocking=False):
|
||||
|
||||
def read_state():
|
||||
try:
|
||||
return json.loads((ROOT / 'session.json').read_text())
|
||||
state = json.loads((ROOT / 'session.json').read_text())
|
||||
if not isinstance(state, dict):
|
||||
raise ValueError('Saved session must be an object')
|
||||
return state
|
||||
except FileNotFoundError:
|
||||
return {'active': False, 'events': []}
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
# Preserve the unreadable state for diagnosis, then allow a new session.
|
||||
(ROOT / 'session.json').replace(ROOT / ('session-unreadable-' + uuid.uuid4().hex + '.json'))
|
||||
return {'active': False, 'events': [],
|
||||
'error': 'Saved session was unreadable. Start a new session.'}
|
||||
|
||||
|
||||
def save(s):
|
||||
@@ -213,13 +230,21 @@ def command(body):
|
||||
event(s, 'started', 'Session started. Steam Home opens at the limit; games are not closed.')
|
||||
elif body['action'] == 'cancel':
|
||||
s['active'] = False
|
||||
s['error'] = None
|
||||
if s.get('id'):
|
||||
event(s, 'cancelled', 'Session timer and monitoring cancelled.')
|
||||
save(s)
|
||||
if body['action'] == 'start':
|
||||
subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '--watch'],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True, close_fds=True)
|
||||
try:
|
||||
subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '--watch'],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True, close_fds=True)
|
||||
except OSError as e:
|
||||
s['active'] = False
|
||||
s['error'] = 'Could not start session worker: ' + str(e)
|
||||
event(s, 'error', s['error'])
|
||||
save(s)
|
||||
raise
|
||||
return current(s, clock())
|
||||
|
||||
|
||||
@@ -228,7 +253,11 @@ if __name__ == '__main__':
|
||||
watch()
|
||||
else:
|
||||
try:
|
||||
print(json.dumps(command(json.loads(sys.argv[1]))))
|
||||
if sys.argv[1:] == ['--home']:
|
||||
open_home()
|
||||
print(json.dumps({'home': True}))
|
||||
else:
|
||||
print(json.dumps(command(json.loads(sys.argv[1]))))
|
||||
except Exception as e:
|
||||
print(json.dumps({'error': str(e)}))
|
||||
sys.exit(1)
|
||||
+4
-1
@@ -175,7 +175,10 @@ def thermal_alerts():
|
||||
def activity_level():
|
||||
try:
|
||||
rows = json.loads(run("/opt/steamvr/bin/linuxarm64/vrcmd", "--stats"))
|
||||
return next((r.get("activity_level") for r in rows if r.get("operation") == "status"), None)
|
||||
if not isinstance(rows, list):
|
||||
return None
|
||||
return next((r.get("activity_level") for r in rows
|
||||
if isinstance(r, dict) and r.get("operation") == "status"), None)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
+42
-16
@@ -101,6 +101,7 @@
|
||||
.shelf-head .count { color: var(--muted); font-size: 13px; }
|
||||
.shelf-head .spacer { flex: 1; }
|
||||
.sub { color: var(--muted); font-size: 12.5px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.hint { color: var(--muted); font-size: 12.5px; margin-top: 11px; line-height: 1.5; }
|
||||
|
||||
/* ---- buttons ---- */
|
||||
@@ -483,20 +484,22 @@
|
||||
<label>Check in after <input id="stillMinutes" type="number" min="0" max="240" value="30" required style="width:75px"> active minutes</label>
|
||||
</div>
|
||||
<div class="row" style="flex-wrap:wrap;margin:14px 0;gap:14px">
|
||||
<label><input id="batteryAlert" type="checkbox" checked> Low battery</label>
|
||||
<label><input id="heatAlert" type="checkbox" checked> Overheating</label>
|
||||
<label><input id="batteryAlert" type="checkbox" checked> Alert on low battery</label>
|
||||
<label><input id="heatAlert" type="checkbox" checked> Alert on overheating</label>
|
||||
<button class="action small" id="sessionStart" type="submit">Start session</button>
|
||||
<button class="small" id="sessionCancel" type="button" disabled>Cancel session</button>
|
||||
<button class="small" id="testNotification" type="button">Enable / test notifications</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="comfortStatus" role="status">Checking session…</p>
|
||||
<p id="comfortNotificationStatus" class="hint" role="status" hidden></p>
|
||||
<p class="hint">A one-minute warning, then Steam Home. Games stay running: save and pause first. Keep this app open and connected for notifications.</p>
|
||||
<details class="hint"><summary>How sessions and alerts work</summary>
|
||||
<p>This is a reminder, not a parental lock. The timer continues on the Frame if you disconnect; a headset restart cancels it. Cancel before changing settings. Set break/check-in to 0 to turn them off.</p>
|
||||
<p>Alerts run during a session. iOS may suspend phone notifications in the background. Breaks and check-ins count SteamVR activity, not confirmed wear time.</p>
|
||||
<p>Battery, heat and check-in alerts appear on connected companions during a session. Headset warnings and break reminders continue without a companion. iOS may suspend phone notifications in the background. Breaks and check-ins count SteamVR activity, not confirmed wear time.</p>
|
||||
</details>
|
||||
<div id="comfortEvents" class="hint" aria-live="polite"></div>
|
||||
<div id="comfortEvents" class="hint"></div>
|
||||
<span id="comfortAnnouncement" class="sr-only" aria-live="polite"></span>
|
||||
</section>
|
||||
|
||||
<section id="shots">
|
||||
@@ -926,15 +929,27 @@ function refresh() {
|
||||
}
|
||||
|
||||
// The Frame owns the clock. Poll independently of status and the selected tab.
|
||||
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null;
|
||||
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
|
||||
async function localNotification(message, request = false) {
|
||||
if (window.frameApp?.notify) return window.frameApp.notify(message, request);
|
||||
if (!("Notification" in window)) throw new Error("This browser has no notifications; use the Frame Control app.");
|
||||
const permission = request ? await Notification.requestPermission() : Notification.permission;
|
||||
if (permission !== "granted") throw new Error("Notifications are off. Enable them in system settings.");
|
||||
new Notification("Frame Control", {body: message});
|
||||
try {
|
||||
if (window.frameApp?.notify) await window.frameApp.notify(message, request);
|
||||
else {
|
||||
if (!("Notification" in window)) throw new Error("This browser has no notifications; use the Frame Control app.");
|
||||
const permission = request ? await Notification.requestPermission() : Notification.permission;
|
||||
if (permission !== "granted") throw new Error("Notifications are off. Enable them in system settings.");
|
||||
new Notification("Frame Control", {body: message});
|
||||
}
|
||||
$("comfortNotificationStatus").hidden = true;
|
||||
} catch (e) {
|
||||
$("comfortNotificationStatus").hidden = false;
|
||||
$("comfortNotificationStatus").textContent = "Notifications aren't available on this device. Check system notification settings, then use Enable / test notifications. Headset reminders continue during an active session.";
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function renderComfort(s) {
|
||||
const firstSnapshot = !comfortHydrated;
|
||||
comfortHydrated = true;
|
||||
$("sessionStart").disabled = !!s.active;
|
||||
for (const id of ["sessionMinutes", "breakMinutes", "stillMinutes", "batteryAlert", "heatAlert"])
|
||||
$(id).disabled = !!s.active;
|
||||
@@ -944,30 +959,41 @@ function renderComfort(s) {
|
||||
comfortSeen.clear();
|
||||
if (s.options) for (const [key, value] of Object.entries(s.options)) {
|
||||
const el = $(key === "minutes" ? "sessionMinutes" : key);
|
||||
if (!el) continue;
|
||||
if (el.type === "checkbox") el.checked = value; else el.value = value;
|
||||
}
|
||||
}
|
||||
$("comfortStatus").textContent = s.error || (s.active
|
||||
let statusText = s.error || (s.active
|
||||
? `${Math.ceil(s.remaining / 60)} min until Steam Home · ${s.activity == null ? "activity unknown" : s.activity === 3 ? "headset in standby" : "monitoring"}`
|
||||
: "No session running.");
|
||||
if (s.active && s.unavailable?.length)
|
||||
$("comfortStatus").textContent += " · No readings: " + s.unavailable.join(", ");
|
||||
statusText += " · No readings: " + s.unavailable.join(", ");
|
||||
if ($("comfortStatus").textContent !== statusText) $("comfortStatus").textContent = statusText;
|
||||
let latest = null;
|
||||
for (const e of s.events || []) {
|
||||
if (comfortSeen.has(e.id)) continue;
|
||||
comfortSeen.add(e.id);
|
||||
// Historical events remain visible but never produce a burst on reconnect.
|
||||
if (s.time - e.time >= 0 && s.time - e.time < 30 && !["started", "cancelled"].includes(e.kind)) {
|
||||
if (!firstSnapshot && s.time - e.time >= 0 && s.time - e.time < 30 && !["started", "cancelled"].includes(e.kind)) {
|
||||
latest = e.message;
|
||||
log(e.message); toast(e.message, e.kind === "error");
|
||||
localNotification(e.message).catch(err => { $("comfortStatus").textContent += " · " + err.message; });
|
||||
localNotification(e.message).catch(() => {}); // the notification status keeps the failure visible
|
||||
}
|
||||
}
|
||||
$("comfortEvents").textContent = (s.events || []).slice(-3).map(e => e.message).join(" · ");
|
||||
// Keep only the server's bounded history; a new page seeds it without replay.
|
||||
comfortSeen = new Set((s.events || []).map(e => e.id));
|
||||
if (latest !== null) $("comfortAnnouncement").textContent = latest;
|
||||
const history = (s.events || []).slice(-3).map(e => e.message).join(" · ");
|
||||
if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history;
|
||||
}
|
||||
async function pollComfort() {
|
||||
if (!comfortBusy) {
|
||||
comfortBusy = true;
|
||||
try { renderComfort(await api("/api/comfort", {action: "status"})); }
|
||||
catch (e) { $("comfortStatus").textContent = "Session status unavailable: " + e.message; }
|
||||
catch (e) {
|
||||
const message = "Session status unavailable: " + e.message;
|
||||
if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message;
|
||||
}
|
||||
finally { comfortBusy = false; }
|
||||
}
|
||||
setTimeout(pollComfort, 5000);
|
||||
|
||||
Reference in new issue
Block a user