mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
feat: web UI on socket protocol 2 (step 2b)
The UI talks to the device over the one socket at /ws: the sync fills the shade model, its events redraw the lists, and every read, command, edit, setting, scan and reboot is an op. Only /loginContext, /login and the file transfers stay on HTTP, so a page load opens no other connection. - The 8080 socket, editJSON/requestResult and the /controller, /rooms, /shades, /groups, /room, /shade, /group, settings and command routes are no longer used by the UI. - An edit's callback gets the item once its event is in (ProtoClient waitFor); busy edits and saves are sent again. - The token is kept in sessionStorage. When /ws is refused and /loginContext says a login is needed, the client stops retrying and the login panel opens. Backup and uploads send the token. - A banner shows when the device speaks another protocol version. - Removed dead code: the /pairShade call and its unused /unpairShade twin, loadLogin, login.html (broken, linked from nowhere), Secure Config Only's checkbox (protocol 2 has two modes) and the HTTP helpers nothing uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4415beb4f7
commit
0df740df6a
6 changed files with 570 additions and 944 deletions
|
|
@ -179,10 +179,6 @@
|
|||
</select>
|
||||
<label for="fldPinEntry">Security Type</label>
|
||||
</div>
|
||||
<div id="divPermissions" class="field-group" style="display:none;vertical-align:middle;float:right;width:auto;">
|
||||
<input id="cbSecureConfigOnly" data-bind="security.permissions.configOnly" type="checkbox" style="display:inline-block;" />
|
||||
<label for="cbSecureConfigOnly" style="display:inline-block;cursor:pointer;">Secure Config Only</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="divPinSecurity" style="display:none;">
|
||||
<div class="field-group" style="text-align:center;">
|
||||
|
|
|
|||
1307
data-src/index.js
1307
data-src/index.js
File diff suppressed because it is too large
Load diff
|
|
@ -68,7 +68,27 @@ class ProtoClient {
|
|||
// unless a listener calls close(), as the UI does once /loginContext says
|
||||
// a login is needed (docs/SECURITY_PLAN.md).
|
||||
on(name, cb) { (this.listeners[name] = this.listeners[name] || []).push(cb); }
|
||||
emit(name, d) { for (const cb of this.listeners[name] || []) cb(d); }
|
||||
off(name, cb) { this.listeners[name] = (this.listeners[name] || []).filter((x) => x !== cb); }
|
||||
emit(name, d) { for (const cb of (this.listeners[name] || []).slice()) cb(d); }
|
||||
// The next event of that name that match(d) accepts, or null after ms.
|
||||
// An edit's events follow its reply, so this is how a caller gets the
|
||||
// item the edit left: the model has it by then.
|
||||
waitFor(name, match, ms) {
|
||||
return new Promise((resolve) => {
|
||||
let timer = null;
|
||||
const cb = (d) => {
|
||||
if (match && !match(d)) return;
|
||||
this.off(name, cb);
|
||||
this.clearTimeout(timer);
|
||||
resolve(d);
|
||||
};
|
||||
this.on(name, cb);
|
||||
timer = this.setTimeout(() => {
|
||||
this.off(name, cb);
|
||||
resolve(null);
|
||||
}, ms);
|
||||
});
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (!this.stopped && (this.ws || this.reconnectTimer)) return;
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="app.css?v=2.4.0" type="text/css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<script type="text/javascript" src="index.js?v=2.4.0"></script>
|
||||
</head>
|
||||
<body onload="general.loadLogin();">
|
||||
<div id="divContainer" class="container" data-securitytype="0">
|
||||
<h1 style="text-align: center;"><img src="icon.png" style="width:127px;margin-left:1px;margin-top:-10px;" /></h1>
|
||||
<div id="divLoginPnl" class="login-content" style="position:relative;">
|
||||
<div style="max-width:270px;margin:0px auto;">
|
||||
<form id="frmLogin" action="/login" method="post" class="login-form" onsubmit="general.login(event); return false;">
|
||||
<input id="fldPin" type="hidden" name="pin">
|
||||
<div id="divPinSecurity" style="display:none;">
|
||||
<div class="field-group" style="text-align:center;">
|
||||
<div id="fldPinEntry" style="display:inline-block;" onkeydown="general.pinKeyPressed(event);">
|
||||
<input class="pin-digit" maxlength="1" data-bind="security.pin.d0" onfocus="general.pinDigitFocus(event);">
|
||||
<input class="pin-digit" maxlength="1" data-bind="security.pin.d1" onfocus="general.pinDigitFocus(event);">
|
||||
<input class="pin-digit" maxlength="1" data-bind="security.pin.d2" onfocus="general.pinDigitFocus(event);">
|
||||
<input class="pin-digit" maxlength="1" data-bind="security.pin.d3" onfocus="general.pinDigitFocus(event);">
|
||||
</div>
|
||||
<label for="fldPinEntry" style="margin-top:7px;">Enter Pin</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="divPasswordSecurity" style="display:none;">
|
||||
<div class="field-group">
|
||||
<input id="fldUsername" name="username" type="text" autocomplete="username" data-bind="security.username" length=32 placeholder="Username">
|
||||
<label for="fldUsername">Username</label>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<input id="fldPassword" name="password" type="password" autocomplete="current-password" data-bind="security.password" length=32 placeholder="Password">
|
||||
<label for="fldPassword">Password</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center;"><span id="spanLoginMessage" style="color:red"></span></div>
|
||||
<div class="button-container">
|
||||
<button id="btnLogin" type="submit" value="Submit">
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -868,3 +868,14 @@ div.frame-pulses {
|
|||
padding-right:14px;
|
||||
}
|
||||
}
|
||||
/* Shown when the device speaks another protocol version than this page. */
|
||||
.version-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 8px 12px;
|
||||
background: orangered;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -296,6 +296,25 @@ test('a new token connects again with it', () => {
|
|||
assert.match(FakeSocket.last.url, /apikey=new/);
|
||||
});
|
||||
|
||||
test('waitFor resolves with the first event that matches, after the model has it', async () => {
|
||||
const { c, ws } = connected();
|
||||
const shadeId = recordedItems('S')[0].s;
|
||||
const p = c.waitFor('shade', (d) => d.shadeId === shadeId, 3000);
|
||||
ws.deliver({ v: 2, t: T.shade, d: { s: 250, n: 'Other' } });
|
||||
ws.deliver({ v: 2, t: T.shade, d: { s: shadeId, n: 'Renamed' } });
|
||||
const d = await p;
|
||||
assert.equal(d.name, 'Renamed');
|
||||
assert.equal(c.model.shades.get(shadeId).name, 'Renamed');
|
||||
});
|
||||
|
||||
test('waitFor gives null when no event comes, and stops listening', async () => {
|
||||
const { c } = connected();
|
||||
const p = c.waitFor('room', null, 3000);
|
||||
c.timers.runAll();
|
||||
assert.equal(await p, null);
|
||||
assert.equal((c.listeners.room || []).length, 0);
|
||||
});
|
||||
|
||||
test('subscribe sends the feed number', () => {
|
||||
const { c, ws } = connected();
|
||||
c.subscribe('frames');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue