mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 04:06:23 +01:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f20a106de1 | ||
|
|
c0bef8b3c8 | ||
|
|
84f8c3ea28 |
No files matched your search
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Add an opt-in desktop low-battery alert without starting a family session.
|
||||
Existing status reads confirm low charge and drain; recovery hysteresis and a
|
||||
ten-minute cooldown limit repeat alerts. Off on page open/headset switch;
|
||||
pauses with hidden-page status refreshes. No extra polling or headset changes.
|
||||
|
||||
- Clarify first-time desktop downloads, installation and opening on the website
|
||||
and README, including CPU choices, unsigned-app warnings, pairing fallback
|
||||
and the source-only iOS build. Keep section navigation available on mobile.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"command": "scripts/frame-power-sample.sh --count 2 --interval 1",
|
||||
"exitCode": 0,
|
||||
"stopped": null,
|
||||
"lockPolicy": "Existing sampler checked local and remote /tmp/frame-test.lock before reading; no lock acquired, bypassed or removed. Read-only run only.",
|
||||
"samples": [
|
||||
{
|
||||
"time": 1792828506.4317346,
|
||||
"osBuild": "20261015.6141207",
|
||||
"elapsedSeconds": 0.057539,
|
||||
"battery": {
|
||||
"battery.percent": {
|
||||
"value": 99,
|
||||
"unit": "%",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.status": {
|
||||
"value": "Not charging",
|
||||
"unit": "state",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.watts": {
|
||||
"value": -0.06,
|
||||
"unit": "W",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.tempC": {
|
||||
"value": 24.0,
|
||||
"unit": "C",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": 1792828507.4357972,
|
||||
"osBuild": "20261015.6141207",
|
||||
"elapsedSeconds": 1.061612,
|
||||
"battery": {
|
||||
"battery.percent": {
|
||||
"value": 99,
|
||||
"unit": "%",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.status": {
|
||||
"value": "Not charging",
|
||||
"unit": "state",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.watts": {
|
||||
"value": -0.05,
|
||||
"unit": "W",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.tempC": {
|
||||
"value": 24.0,
|
||||
"unit": "C",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"limitations": [
|
||||
"No real low-charge or thermal condition induced.",
|
||||
"No native notification delivery verified.",
|
||||
"No worn-headset or native package tests.",
|
||||
"Read-only parser/transport check; no background monitor or power-saving claim."
|
||||
],
|
||||
"browserCheck": {
|
||||
"page": "Production ui/index.html served statically; API calls intentionally unavailable",
|
||||
"viewport": {
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
"bridge": "Synthetic desktop notification bridge; no native OS notification sent",
|
||||
"result": "Initially unchecked and visible only after desktop host/bridge setup. Checkbox enabled through browser. Synthetic 12%, Discharging, -4 W samples at 0/30/60 seconds produced one bridge request, an in-page status and a toast. No horizontal overflow.",
|
||||
"apiLimit": "Static server returned expected 404/501 API errors; not an end-to-end live desktop connection test."
|
||||
},
|
||||
"offlineValidation": [
|
||||
"test_desktop_battery_comfort.py: 2 tests, production JS with monotonic fake clock and notification bridge",
|
||||
"test_comfort*.py: 25 tests",
|
||||
"test_battery.py: 7 tests",
|
||||
"test_power*.py: 36 tests (loopback HTTP test run outside socket-restricted sandbox)",
|
||||
"test_ui_workspace.py: 1 Python wrapper / Node workspace suite",
|
||||
"test_ui_review.py: 1 Python wrapper / 9 Node regression tests, including switch reset and retained cooldown"
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,45 @@ This is Frame Control code using Python, Steam and SteamVR already on the Frame.
|
||||
|
||||

|
||||
|
||||
## Desktop low-battery alert without a session
|
||||
|
||||
In the desktop app, open **Tools → Family & comfort** and check **Alert on this
|
||||
computer at 15% or less**. This separate option does not start a session, install
|
||||
a worker or change the headset. It starts off whenever the page opens and turns
|
||||
off when you switch headsets. The choice is only for the current open page.
|
||||
|
||||
It consumes the battery parser already used by normal status refreshes (also
|
||||
shared by the read-only power sampler). It adds no SSH calls, timers, sensor
|
||||
reads, third-party push or heat thresholds. Normal automatic status refreshes
|
||||
pause while the page is hidden, so this is **not a background or safety monitor**.
|
||||
Keep the app visible and connected. Use **Enable / test notifications** to check
|
||||
system delivery; operating-system settings may block notifications.
|
||||
|
||||
Two fresh readings, at least 20 seconds and at most 90 seconds apart, must show
|
||||
15% or less, driver state `Discharging`, and signed battery power below −0.5 W.
|
||||
This avoids alerting on plugged-in idle readings, which can report discharging.
|
||||
Missing, invalid, stale or interrupted readings break confirmation. Disconnect,
|
||||
a failed status request, hiding the page or toggling the option also breaks
|
||||
confirmation. Unknown readings never mean zero charge.
|
||||
|
||||
An alert appears in the page and requests a native desktop notification once
|
||||
per low-charge episode. Two readings at 20% or more rearm it, and at least ten
|
||||
minutes must pass between alerts (including across headset switches). A single
|
||||
recovery spike cannot rearm it. Toggling off/on does not clear the episode latch.
|
||||
Notification failure stays visible in the page and does not retry on every
|
||||
refresh. While an active family session has battery alerts enabled, that session
|
||||
handles battery alerts instead. Multiple app windows have independent state;
|
||||
this does not coordinate notifications between computers or windows.
|
||||
|
||||
A read-only check on 2026-10-24, build **20261015.6141207**, received two samples
|
||||
at 99%, `Not charging`, approximately −0.06 W and 24 °C. No low-battery condition
|
||||
was induced, no notification was sent to the real headset, and no headset state
|
||||
was changed. The sampler's local and remote smoke-lock checks allowed the run;
|
||||
no lock was acquired or removed. Deterministic tests exercise low charge,
|
||||
recovery, missing readings, idle drain, timing and notification denial. See
|
||||
[recorded evidence](evidence/desktop-battery-comfort-2026-10-24.json). Native OS
|
||||
notification display, worn use and macOS/Windows packaging were not verified.
|
||||
|
||||
## Sessions
|
||||
|
||||
Set a limit of 1–240 minutes, optional break and check-in intervals, then
|
||||
|
||||
@@ -23,7 +23,8 @@ function fixture() {
|
||||
const page = element('controlCenter');
|
||||
page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
|
||||
page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
|
||||
return {document:{getElementById:element},element,cards,buttons};
|
||||
const listeners = {};
|
||||
return {document:{hidden:false,getElementById:element,addEventListener(name,callback){listeners[name]=callback;}},listeners,element,cards,buttons};
|
||||
}
|
||||
function mounted(options = {}) {
|
||||
const f = fixture(), calls = [];
|
||||
@@ -39,8 +40,10 @@ const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
|
||||
// bootstrap, including the real Home-to-Control status handoff. Only unrelated
|
||||
// rendering helpers, timers and transport are stubbed.
|
||||
function sharedPage({productionApi=false} = {}) {
|
||||
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
|
||||
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
|
||||
const f=fixture(), requests=[], writes=[], notifications=[], host=deferred(), job=deferred();
|
||||
let now=0;
|
||||
const context={document:f.document,FrameControlCenter:cc,performance:{now:()=>now},
|
||||
window:{frameApp:{setUpConnection(){},notify:async(message,request)=>{notifications.push({message,request});}}},
|
||||
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
|
||||
battery(){},setVolume(){},renderHome(){},gb:String,dur:String,esc:String,meter(){},toast(){},
|
||||
runJob(title,id){context.installing.add(id);return job.promise;},
|
||||
@@ -52,7 +55,11 @@ function sharedPage({productionApi=false} = {}) {
|
||||
watchConnection(){context.watched=true;}};
|
||||
vm.createContext(context);
|
||||
const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
|
||||
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
|
||||
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1, comfortActive = false; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
|
||||
// Load the real alert state machine and DOM wiring used by render, refresh,
|
||||
// setOnline and onConnection; do not stub away their lifecycle behavior.
|
||||
const batteryStart=pageSource.indexOf('class DesktopBatteryAlert'),batteryEnd=pageSource.indexOf('// ---- battery ----',batteryStart);
|
||||
vm.runInContext(pageSource.slice(batteryStart,batteryEnd),context);
|
||||
const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
|
||||
const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
|
||||
vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
|
||||
@@ -80,12 +87,48 @@ function sharedPage({productionApi=false} = {}) {
|
||||
const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('</script>',hostStart);
|
||||
vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
|
||||
const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('</script>',bootstrapStart);
|
||||
return {...f,context,requests,writes,host,refresh:()=>vm.runInContext('refresh()',context),
|
||||
return {...f,context,requests,writes,notifications,host,setNow(value){now=value;},refresh:()=>vm.runInContext('refresh()',context),
|
||||
switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
|
||||
installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
|
||||
mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
|
||||
}
|
||||
const response = data => ({ok:true,status:200,json:async()=>data});
|
||||
test('production refresh confirms desktop alerts and failures interrupt confirmation', async () => {
|
||||
const f=sharedPage({productionApi:true});
|
||||
const low=sample();low.battery={percent:15,status:'Discharging',watts:-4};
|
||||
const read=async(now,data=low)=>{
|
||||
f.setNow(now);const pending=f.refresh();
|
||||
f.requests.at(-1).resolve(response(data));await pending;
|
||||
};
|
||||
await read(0);await read(30000);
|
||||
assert.equal(f.notifications.length,0,'desktop alert starts off');
|
||||
f.element('desktopBatteryAlert').checked=true;
|
||||
f.element('desktopBatteryAlert').onchange();
|
||||
await read(60000);
|
||||
const failed=f.refresh();f.requests.at(-1).reject(Error('Status transport failed'));await failed;
|
||||
await read(90000);
|
||||
assert.equal(f.notifications.length,0,'failed status interrupts low-battery confirmation');
|
||||
await read(120000);
|
||||
assert.equal(f.notifications.length,1);
|
||||
assert.match(f.notifications[0].message,/15%.*draining/);
|
||||
assert.equal(f.notifications[0].request,false,'refresh never requests notification permission');
|
||||
await read(150000);
|
||||
assert.equal(f.notifications.length,1,'same episode stays latched');
|
||||
assert.equal(f.requests.length,7,'only explicit existing status reads are used');
|
||||
});
|
||||
test('production headset switch clears desktop opt-in and pending confirmation', async () => {
|
||||
const f=sharedPage({productionApi:true});
|
||||
f.element('desktopBatteryAlert').checked=true;
|
||||
const low=sample();low.battery={percent:10,status:'Discharging',watts:-3};
|
||||
const pending=f.refresh();f.requests[0].resolve(response(low));await pending;
|
||||
assert.equal(vm.runInContext('desktopBattery.pending.condition',f.context),'low');
|
||||
f.setNow(30000);f.switchHeadset();
|
||||
assert.equal(f.element('desktopBatteryAlert').checked,false);
|
||||
assert.equal(vm.runInContext('desktopBattery.pending',f.context),null);
|
||||
assert.match(f.element('desktopBatteryStatus').textContent,/Headset changed/);
|
||||
f.requests[1].resolve(response(low));await flush();
|
||||
assert.equal(f.notifications.length,0,'new headset cannot inherit the prior opt-in');
|
||||
});
|
||||
for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
|
||||
test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
|
||||
const f=sharedPage({productionApi:true});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Exercise the shipped desktop alert and its UI with a monotonic fake clock."""
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page JavaScript')
|
||||
class DesktopBatteryComfort(unittest.TestCase):
|
||||
def test_alert_confirmation_and_lifecycle(self):
|
||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||
code = page.split('// ---- Desktop-only low-battery comfort:')[1].split('// ---- battery ----')[0]
|
||||
code = code[code.index('class DesktopBatteryAlert'):]
|
||||
js = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const elements = new Map();
|
||||
const $ = id => {
|
||||
if (!elements.has(id)) elements.set(id, {checked:false, textContent:''});
|
||||
return elements.get(id);
|
||||
};
|
||||
let now = 0, calls = [], toasts = [], devGen = 1, comfortActive = false;
|
||||
const performance = {now:()=>now};
|
||||
const listeners = {};
|
||||
const document = {hidden:false, addEventListener:(name,fn)=>listeners[name]=fn};
|
||||
const HOST = {mobile:false};
|
||||
const window = {frameApp:{notify:(message,request)=>{calls.push({message,request});return Promise.resolve();}}};
|
||||
const toast = message => toasts.push(message);
|
||||
''' + code + r'''
|
||||
(async () => {
|
||||
const low = {percent:15,status:'Discharging',watts:-4};
|
||||
const high = {...low,percent:20};
|
||||
const a = new DesktopBatteryAlert();
|
||||
assert.equal(a.sample(low,0),false);
|
||||
assert.equal(a.sample(low,1000),false); // rapid manual refreshes cannot confirm
|
||||
assert.equal(a.sample(low,30000),true);
|
||||
for (let t=60000;t<=900000;t+=30000) assert.equal(a.sample(low,t),false);
|
||||
assert.equal(a.sample(high,930000),false);
|
||||
assert.equal(a.sample(low,960000),false); // a recovery spike did not rearm
|
||||
assert.equal(a.sample(low,990000),false);
|
||||
a.sample(high,1020000); a.sample(high,1050000);
|
||||
assert.equal(a.sample(low,1080000),false);
|
||||
assert.equal(a.sample(low,1110000),true);
|
||||
const c = new DesktopBatteryAlert();
|
||||
c.sample(low,0); assert.equal(c.sample(low,30000),true);
|
||||
c.sample(high,60000); c.sample(high,90000);
|
||||
c.sample(low,120000); assert.equal(c.sample(low,150000),false); // ten minute cap
|
||||
assert.equal(c.sample(low,631000),false); // long gap requires fresh confirmation
|
||||
assert.equal(c.sample(low,661000),true);
|
||||
for (const bad of [null,{}, {...low,percent:null}, {...low,percent:NaN}, {...low,percent:-1},
|
||||
{...low,percent:101},{...low,percent:'10'}, {...low,status:'Unknown'},
|
||||
{...low,status:'Charging'}, {...low,status:'Full'}, {...low,status:'Not charging'},
|
||||
{...low,watts:0},{...low,watts:-0.5},{...low,watts:null},{...low,watts:NaN}]) {
|
||||
const b = new DesktopBatteryAlert();
|
||||
b.sample(low,0); assert.equal(b.sample(bad,30000),false);
|
||||
assert.equal(b.sample(low,60000),false); // unknown interrupts confirmation
|
||||
}
|
||||
const b = new DesktopBatteryAlert();
|
||||
b.sample(low,0); b.invalidate(); assert.equal(b.sample(low,30000),false);
|
||||
assert.equal(b.sample(low,121000),false); // stale gap
|
||||
assert.equal(b.sample(low,120000),false); // clock rollback
|
||||
desktopBatterySample(low); now=30000; desktopBatterySample(low);
|
||||
assert.equal(calls.length,0); // opt-in starts unchecked
|
||||
$('desktopBatteryAlert').checked=true; $('desktopBatteryAlert').onchange();
|
||||
now=60000;desktopBatterySample(low);now=90000;desktopBatterySample(low);
|
||||
assert.equal(calls.length,1); assert.equal(calls[0].request,false);
|
||||
assert.match(calls[0].message,/15%.*draining/);
|
||||
$('desktopBatteryAlert').checked=false;$('desktopBatteryAlert').onchange();
|
||||
$('desktopBatteryAlert').checked=true;$('desktopBatteryAlert').onchange();
|
||||
now=120000;desktopBatterySample(low);now=150000;desktopBatterySample(low);
|
||||
assert.equal(calls.length,1); // toggling does not clear episode latch
|
||||
desktopBattery.latched=false;desktopBattery.lastAlert=-Infinity;desktopBattery.invalidate();
|
||||
now=180000;desktopBatterySample(low);document.hidden=true;listeners.visibilitychange();
|
||||
document.hidden=false;now=210000;desktopBatterySample(low);assert.equal(calls.length,1);
|
||||
now=240000;desktopBatterySample(low);assert.equal(calls.length,2);
|
||||
desktopBattery.latched=false;desktopBattery.lastAlert=-Infinity;desktopBattery.invalidate();
|
||||
comfortActive=true;$('batteryAlert').checked=true;
|
||||
now=270000;desktopBatterySample(low);now=300000;desktopBatterySample(low);
|
||||
assert.equal(calls.length,2); // existing session owns battery alerts
|
||||
comfortActive=false;HOST.mobile=true;
|
||||
now=330000;desktopBatterySample(low);now=360000;desktopBatterySample(low);
|
||||
assert.equal(calls.length,2);
|
||||
HOST.mobile=false;
|
||||
window.frameApp.notify=()=>Promise.reject(Error('denied'));
|
||||
now=390000;desktopBatterySample(low);now=420000;desktopBatterySample(low);
|
||||
await new Promise(setImmediate);
|
||||
assert.match($('desktopBatteryStatus').textContent,/not confirmed/);
|
||||
now=450000;desktopBatterySample(low);
|
||||
assert.equal(toasts.length,3); // delivery failure does not retry on refresh
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
'''
|
||||
result = subprocess.run(['node', '-e', js], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_existing_status_path_and_switch_reset(self):
|
||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||
self.assertIn('desktopBatterySample(s.battery);', page)
|
||||
switch = page.split('if (link.device !== null && devId !== link.device)')[1].split('devGen++;')[0]
|
||||
self.assertIn('$("desktopBatteryAlert").checked = false;', switch)
|
||||
refresh = page.split('function refresh()')[1].split('async function refreshAfterAudio')[0]
|
||||
self.assertIn('desktopBattery.invalidate();', refresh)
|
||||
block = page.split('// ---- Desktop-only low-battery comfort:')[1].split('// ---- battery ----')[0]
|
||||
self.assertNotIn('setTimeout', block)
|
||||
self.assertNotIn('api(', block)
|
||||
@@ -10,6 +10,8 @@ function connectionFixture() {
|
||||
sourceState:{jobs:new Map(),detail:null,detailRequest:0},touches:new Map(),gesture:null,padType:{blur(){},value:''},
|
||||
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},resetClips(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
|
||||
c.window.connDevice='a';
|
||||
c.document.addEventListener=()=>{};
|
||||
f.run(pick(html,'class DesktopBatteryAlert {','// ---- battery ----'));
|
||||
f.run('const UI_KEY="test";'+pick(html,'const HEADSET_FREE =','// What a panel shows'));
|
||||
f.run(pick(html,'function onConnection(s) {','// Read the event stream'));
|
||||
return f;
|
||||
@@ -41,8 +43,15 @@ test('a device switch discards completed install results and the open detail',()
|
||||
Object.assign(c,{pad:{state:'off',queue:[]},padReset(){},sourceState:{jobs:new Map([['test:one',{done:true,result:{package:'app.on.a',game_data:true}}]]),detail:{app:{}},detailRequest:0}});
|
||||
f.el('appDetail').open=true;
|
||||
f.el('storageResult').textContent='Old headset storage';
|
||||
f.el('desktopBatteryAlert').checked=true;
|
||||
f.run('desktopBattery.pending={condition:"low",at:0};desktopBattery.latched=true;desktopBattery.lastAlert=42;');
|
||||
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
|
||||
assert.match(f.el('storageResult').textContent,/Headset changed/);
|
||||
assert.equal(f.el('desktopBatteryAlert').checked,false);
|
||||
assert.equal(f.run('desktopBattery.pending'),null);
|
||||
assert.equal(f.run('desktopBattery.latched'),false);
|
||||
assert.equal(f.run('desktopBattery.lastAlert'),42);
|
||||
assert.match(f.el('desktopBatteryStatus').textContent,/Off.*Headset changed/);
|
||||
assert.equal(c.sourceState.jobs.size,0);assert.equal(c.sourceState.detail,null);assert.equal(f.el('appDetail').open,false);
|
||||
});
|
||||
test('only a preview matching current diagnostics consent can be sent or copied',async()=>{
|
||||
|
||||
@@ -1465,6 +1465,12 @@
|
||||
</details>
|
||||
</section><section class="panel" id="comfort" style="margin:20px 0;padding:16px">
|
||||
<div class="shelf-head"><h2>Family and comfort</h2><span class="count">Session timer and reminders</span></div>
|
||||
<div id="desktopBatteryComfort" hidden>
|
||||
<h3>Desktop low-battery alert</h3>
|
||||
<label><input id="desktopBatteryAlert" type="checkbox"> Alert on this computer at 15% or less</label>
|
||||
<p class="hint">No session timer needed. Off when this page opens or you switch headsets. Uses normal status refreshes while the page is visible; no extra polling or headset changes. Two readings must confirm low charge and battery drain. One alert until charge recovers to 20%, with at least ten minutes between alerts.</p>
|
||||
<p id="desktopBatteryStatus" class="hint" role="status">Off.</p>
|
||||
</div>
|
||||
<form id="comfortForm">
|
||||
<div class="row" style="flex-wrap:wrap;gap:12px">
|
||||
<label>Session limit (minutes) <input id="sessionMinutes" type="number" min="1" max="240" value="30" required style="width:75px"></label>
|
||||
@@ -2130,6 +2136,7 @@ const HOST = { computer: "computer" };
|
||||
function applyHostWording(h) {
|
||||
Object.assign(HOST, h);
|
||||
window.frameControlCenter?.host(h);
|
||||
$("desktopBatteryComfort").hidden = !!h.mobile || !window.frameApp?.notify;
|
||||
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
|
||||
document.body.classList.toggle("mobile", !!h.mobile);
|
||||
$("shotsFolder").hidden = !!h.mobile;
|
||||
@@ -2240,6 +2247,7 @@ function setOnline(ok, why) {
|
||||
if (HOST.mobile) $('mobileConnectionStatus').textContent = ok ? 'Connected to the Frame serving this session.' : 'The Frame is disconnected. Retry or check its address in connection setup.';
|
||||
$('offline').hidden = ok;
|
||||
if (!ok) {
|
||||
desktopBattery.invalidate();
|
||||
$("offMsg").textContent = why || "Can't reach the Frame";
|
||||
setConn(false, "Offline");
|
||||
$("battChip").hidden = true; closeDevPop();
|
||||
@@ -2276,6 +2284,7 @@ function refresh() {
|
||||
setConn(true, state.hostname);
|
||||
setOnline(true);
|
||||
} catch (e) {
|
||||
desktopBattery.invalidate();
|
||||
renderPerformance(null);
|
||||
if (!e.stale) stateRead = {valid: false, error: e};
|
||||
renderHome();
|
||||
@@ -2456,6 +2465,60 @@ $("castBtn").onclick = () => {
|
||||
$("viewer").requestFullscreen?.().catch(() => {});
|
||||
};
|
||||
|
||||
// ---- Desktop-only low-battery comfort: consumes existing status, never polls ----
|
||||
class DesktopBatteryAlert {
|
||||
constructor() {
|
||||
this.pending = null;
|
||||
this.latched = false;
|
||||
this.lastAlert = -Infinity;
|
||||
}
|
||||
invalidate() { this.pending = null; }
|
||||
sample(b, now) {
|
||||
// Require actual drain as well as driver state: plugged-in idle can report Discharging.
|
||||
const valid = Number.isFinite(b?.percent) && b.percent >= 0 && b.percent <= 100 &&
|
||||
["Charging", "Discharging", "Full", "Not charging"].includes(b.status);
|
||||
const low = valid && b.percent <= 15 && b.status === "Discharging" &&
|
||||
Number.isFinite(b.watts) && b.watts < -0.5;
|
||||
const recovered = valid && b.percent >= 20;
|
||||
const condition = low ? "low" : recovered ? "recovered" : null;
|
||||
if (!condition) { this.invalidate(); return false; }
|
||||
if (!this.pending || this.pending.condition !== condition || now - this.pending.at > 90000 || now < this.pending.at) {
|
||||
this.pending = {condition, at: now};
|
||||
return false;
|
||||
}
|
||||
if (now - this.pending.at < 20000) return false;
|
||||
if (recovered) this.latched = false;
|
||||
if (!low || this.latched || now - this.lastAlert < 600000) return false;
|
||||
this.latched = true;
|
||||
this.lastAlert = now; // latch even when system delivery fails; never retry on each refresh
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const desktopBattery = new DesktopBatteryAlert();
|
||||
$("desktopBatteryAlert").onchange = () => {
|
||||
desktopBattery.invalidate();
|
||||
$("desktopBatteryStatus").textContent = $("desktopBatteryAlert").checked
|
||||
? "On. Waiting for two fresh status readings confirming low charge and battery drain."
|
||||
: "Off.";
|
||||
};
|
||||
function desktopBatterySample(b) {
|
||||
if (!$("desktopBatteryAlert").checked || HOST.mobile || !window.frameApp?.notify || document.hidden ||
|
||||
(comfortActive && $("batteryAlert").checked)) {
|
||||
desktopBattery.invalidate();
|
||||
return;
|
||||
}
|
||||
if (!desktopBattery.sample(b, performance.now())) return;
|
||||
const message = `Frame battery is low (${b.percent}%) and draining. Consider charging or taking a break.`;
|
||||
$("desktopBatteryStatus").textContent = message;
|
||||
toast(message);
|
||||
// Desktop bridge only: never prompt or send a phone/browser push from a status refresh.
|
||||
const generation = devGen;
|
||||
window.frameApp.notify(message, false).catch(() => {
|
||||
if (generation !== devGen || !$("desktopBatteryAlert").checked) return;
|
||||
$("desktopBatteryStatus").textContent = message + " System notification delivery was not confirmed. Check system settings or use Enable / test notifications below.";
|
||||
});
|
||||
}
|
||||
document.addEventListener("visibilitychange", () => desktopBattery.invalidate());
|
||||
// ---- battery ----
|
||||
function battery(b, power) {
|
||||
const pct = b?.percent;
|
||||
@@ -2511,6 +2574,7 @@ function render(s) {
|
||||
renderHome(s);
|
||||
window.frameControlCenter?.status(s, stateReceivedAt, stateRead);
|
||||
battery(s.battery, s.power);
|
||||
desktopBatterySample(s.battery);
|
||||
$("devPopTitle").textContent = $("devPopTitle").title = window.connName || "Headset";
|
||||
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
|
||||
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
|
||||
@@ -6898,6 +6962,11 @@ function onConnection(s) {
|
||||
stateReceivedAt = null;
|
||||
stateRead = {valid: false, error: null};
|
||||
online = null;
|
||||
$("desktopBatteryAlert").checked = false;
|
||||
desktopBattery.invalidate();
|
||||
// Keep the cooldown across switches, but a new headset can start its own charge episode.
|
||||
desktopBattery.latched = false;
|
||||
$("desktopBatteryStatus").textContent = "Off. Headset changed; enable for this headset if wanted.";
|
||||
devGen++; // answers still on their way are about the other headset
|
||||
padReset(); ctrlReset();
|
||||
sourceState.jobs.clear(); sourceState.detail = null; sourceState.detailRequest++;
|
||||
|
||||
Reference in new issue
Block a user