ui: auto-refresh the proactive log and STK menu state

The proactive command list only re-rendered on tab/pill switches, so new
fetched commands (background STATUS polling, menu traffic) stayed
invisible while the Phone view was open.

- /api/status now exposes proactive_seq (_PROACTIVE_ENTRY_ID, monotonic
  and not reset with the log), so the existing 2s status poll detects
  changes with no extra request; pysimCardStateUpdate() re-renders the
  log when the sequence changed and the Phone/Phone view is visible
- the 5s backend timer no longer fetches the log (it was discarded) and
  now uses the already-fetched stk-status: when active/pending changes
  while the Phone tab is visible, stkCheckMenu() refreshes the menu
  button without a tab switch
- pysimProactiveLogRender() keeps its scroll position and only shows
  Loading... on a first/empty paint
- tests: proactive seq + STK signature helpers and poll-driven render
  behaviour in card_state.test.js; SW cache v123 -> v124.
This commit is contained in:
2026-09-12 22:22:55 +03:00
parent a5cccee17c
commit 8459040856
4 changed files with 72 additions and 6 deletions
+27 -3
View File
@@ -6713,6 +6713,10 @@ function pysimCardStateUpdate(status) {
_pysimCardEquipped = !!status.connected; _pysimCardEquipped = !!status.connected;
_pysimEquipping = !!status.equipping; _pysimEquipping = !!status.equipping;
pysimApplyAvailability(); pysimApplyAvailability();
if (pysimProactiveSeqChanged(status.proactive_seq)
&& isViewVisible('tab-phone') && isViewVisible('phone-sub-phone')) {
pysimProactiveLogRender();
}
const key = [status.connected, !!status.card_present, !!status.equipping, !!status.auto_equip, status.card_session].join('|'); const key = [status.connected, !!status.card_present, !!status.equipping, !!status.auto_equip, status.card_session].join('|');
if (key === _pysimCardStateKey) return; if (key === _pysimCardStateKey) return;
_pysimCardStateKey = key; _pysimCardStateKey = key;
@@ -6752,17 +6756,35 @@ function pysimStartBackendPoll() {
}, 2000); }, 2000);
_pysimPollTimer = setInterval(async () => { _pysimPollTimer = setInterval(async () => {
try { try {
const [log, stk, ps] = await Promise.all([ const [stk, ps] = await Promise.all([
pysimFetch('/api/proactive-log'),
pysimFetch('/api/stk-status'), pysimFetch('/api/stk-status'),
pysimFetch('/api/poll-status'), pysimFetch('/api/poll-status'),
]); ]);
pysimUpdatePollUI(ps.enabled, ps.interval); pysimUpdatePollUI(ps.enabled, ps.interval);
if (pysimStkStatusChanged(stk) && isViewVisible('tab-phone')) stkCheckMenu();
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
}, 5000); }, 5000);
} }
let _pysimStkSig = null;
function pysimStkStatusChanged(stk) {
if (!stk) return false;
const sig = [!!stk.active, !!stk.pending, stk.pending_type || ''].join('|');
if (sig === _pysimStkSig) return false;
_pysimStkSig = sig;
return true;
}
// ===== proactive command log ===== // ===== proactive command log =====
let _pysimProactiveSeq = null;
function pysimProactiveSeqChanged(seq) {
if (seq === undefined || seq === null) return false;
if (_pysimProactiveSeq === seq) return false;
_pysimProactiveSeq = seq;
return true;
}
const CMD_NAMES = { const CMD_NAMES = {
'03': 'POLL INTERVAL', '05': 'SET UP EVENT LIST', '03': 'POLL INTERVAL', '05': 'SET UP EVENT LIST',
'13': 'SEND SHORT MESSAGE', '20': 'PLAY TONE', '13': 'SEND SHORT MESSAGE', '20': 'PLAY TONE',
@@ -6818,7 +6840,8 @@ function pysimLogFields(decoded) {
async function pysimProactiveLogRender() { async function pysimProactiveLogRender() {
const el = document.getElementById('pysim-proactive-log'); const el = document.getElementById('pysim-proactive-log');
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>'; const scrollTop = el.scrollTop;
if (!el.firstChild) el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
try { try {
const log = await pysimFetch('/api/proactive-log'); const log = await pysimFetch('/api/proactive-log');
if (!log || !log.length) { if (!log || !log.length) {
@@ -6865,6 +6888,7 @@ async function pysimProactiveLogRender() {
html += '</div></div>'; html += '</div></div>';
}); });
el.innerHTML = html; el.innerHTML = html;
el.scrollTop = scrollTop;
} catch (err) { } catch (err) {
el.innerHTML = '<span class="text-red-500">Error: ' + esc(err.message) + '</span>'; el.innerHTML = '<span class="text-red-500">Error: ' + esc(err.message) + '</span>';
} }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v123'; const CACHE = 'otaman-v124';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+43 -2
View File
@@ -22,23 +22,29 @@ function extractFunc(src, name) {
} }
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n' let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'; + 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\n';
code += extractFunc(html, 'pysimCardStateUpdate') + '\n'; code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n'; code += extractFunc(html, 'pysimAvailabilityState') + '\n';
code += extractFunc(html, 'pysimControlDisabled') + '\n'; code += extractFunc(html, 'pysimControlDisabled') + '\n';
code += extractFunc(html, 'pysimProactiveSeqChanged') + '\n';
code += extractFunc(html, 'pysimStkStatusChanged') + '\n';
code += '\nglobalThis.esc = s => s;\n'; code += '\nglobalThis.esc = s => s;\n';
code += 'globalThis.t = s => s;\n'; code += 'globalThis.t = s => s;\n';
eval(code); eval(code);
function setup() { function setup() {
const el = { textContent: 'status line', innerHTML: '' }; const el = { textContent: 'status line', innerHTML: '' };
const calls = { connected: [], resets: [], refreshStatus: [] }; const calls = { connected: [], resets: [], refreshStatus: [], proactive: 0 };
_pysimCardStateKey = null; _pysimCardStateKey = null;
_pysimCardSession = null; _pysimCardSession = null;
_pysimProactiveSeq = null;
globalThis.document = { getElementById: () => el, querySelectorAll: () => [] }; globalThis.document = { getElementById: () => el, querySelectorAll: () => [] };
globalThis.pysimSetConnected = v => calls.connected.push(v); globalThis.pysimSetConnected = v => calls.connected.push(v);
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh); globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
globalThis.pysimApplyAvailability = () => {}; globalThis.pysimApplyAvailability = () => {};
globalThis.isViewVisible = () => true;
globalThis.pysimProactiveLogRender = () => { calls.proactive++; };
return { el, calls }; return { el, calls };
} }
@@ -127,3 +133,38 @@ test('no card with auto-equip enabled still shows the no-card message', () => {
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML); assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
assert.ok(!el.innerHTML.includes('initializing'), el.innerHTML); assert.ok(!el.innerHTML.includes('initializing'), el.innerHTML);
}); });
test('proactive log refreshes when the status sequence changes', () => {
const { calls } = setup();
pysimCardStateUpdate(status({ proactive_seq: 7 }));
pysimCardStateUpdate(status({ proactive_seq: 7 }));
assert.strictEqual(calls.proactive, 1);
pysimCardStateUpdate(status({ proactive_seq: 8 }));
assert.strictEqual(calls.proactive, 2);
});
test('proactive log is not refreshed while the phone view is hidden', () => {
const { calls } = setup();
globalThis.isViewVisible = () => false;
pysimCardStateUpdate(status({ proactive_seq: 3 }));
assert.strictEqual(calls.proactive, 0);
});
test('pysimProactiveSeqChanged tracks the last sequence', () => {
_pysimProactiveSeq = null;
assert.ok(pysimProactiveSeqChanged(4));
assert.ok(!pysimProactiveSeqChanged(4));
assert.ok(pysimProactiveSeqChanged(5));
assert.ok(!pysimProactiveSeqChanged(undefined));
assert.ok(!pysimProactiveSeqChanged(null));
});
test('pysimStkStatusChanged detects menu state transitions', () => {
_pysimStkSig = null;
assert.ok(pysimStkStatusChanged({ active: false, pending: false }));
assert.ok(!pysimStkStatusChanged({ active: false, pending: false }));
assert.ok(pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
assert.ok(!pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
assert.ok(pysimStkStatusChanged({ active: true, pending: false }));
assert.ok(!pysimStkStatusChanged(null));
});
+1
View File
@@ -1821,6 +1821,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'connected': connected, 'connected': connected,
'card_present': bool(getattr(self.server, 'card_present', False)), 'card_present': bool(getattr(self.server, 'card_present', False)),
'card_session': int(getattr(self.server, 'card_session', 0)), 'card_session': int(getattr(self.server, 'card_session', 0)),
'proactive_seq': _PROACTIVE_ENTRY_ID,
'equipping': bool(getattr(self.server, 'equipping', False)), 'equipping': bool(getattr(self.server, 'equipping', False)),
'auto_equip': bool(_AUTO_EQUIP), 'auto_equip': bool(_AUTO_EQUIP),
'card': card.name if card else None, 'card': card.name if card else None,