mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 22:02:17 +01:00
feat: web UI protocol 2 client layer, JS and CSS bundles (step 2a)
- data-src/js: ProtoSchema (schema from the sync, short keys and names, defaults, cached by hash), ProtoModel (rooms, shades, groups, repeaters, header and status from the sync and the events) and ProtoClient (/ws, hello, sync with next, op() as a Promise of its reply, events, feeds, reconnect). Nothing uses them yet: 2b moves the UI onto them. - minify.py joins js/*.js and index.js into index.js, and the three CSS files into app.css, so a page load opens fewer connections. - test/js: node --test on the three modules, replaying the recorded fixtures; a CI job runs it and the firmware build waits for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2c815d30f1
commit
99c6019431
13 changed files with 994 additions and 20 deletions
|
|
@ -8,9 +8,7 @@
|
|||
<meta name="apple-mobile-web-app-title" content="ESPSomfy RTS App">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
<link rel="stylesheet" href="main.css?v=3.0.14d" type="text/css" />
|
||||
<link rel="stylesheet" href="widgets.css?v=3.0.14d" type="text/css" />
|
||||
<link rel="stylesheet" href="icons.css?v=3.0.14d" type="text/css" />
|
||||
<link rel="stylesheet" href="app.css?v=3.0.14d" type="text/css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
|
||||
<!-- iPad retina icon -->
|
||||
|
|
|
|||
242
data-src/js/client.js
Normal file
242
data-src/js/client.js
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// A socket protocol 2 client (docs/PROTOCOL.md): ws://host/ws, hello, the sync
|
||||
// with next after each frame, ops answered by their reply, events and feeds.
|
||||
// It keeps the shade model (ProtoModel) up to date and connects again when the
|
||||
// socket closes. The WebSocket, the timers and the schema's storage can be
|
||||
// handed in, so the tests (test/js) run it without a device. The minify step
|
||||
// joins this file into index.js after schema.js and model.js.
|
||||
class ProtoError extends Error {
|
||||
// code is an error name from the schema (badRequest, busy ...), or
|
||||
// notConnected, closed or timeout from the client itself.
|
||||
constructor(code, desc, data) {
|
||||
super(desc || code);
|
||||
this.code = code;
|
||||
this.desc = desc || code;
|
||||
this.data = data || {};
|
||||
}
|
||||
}
|
||||
class ProtoClient {
|
||||
// opts: host, token, WebSocket, storage (getItem/setItem, for the schema),
|
||||
// setTimeout/clearTimeout, opTimeoutMs.
|
||||
constructor(opts) {
|
||||
const deps = ProtoClient.deps();
|
||||
this.Schema = deps.ProtoSchema;
|
||||
this.host = opts.host;
|
||||
this.token = opts.token || '';
|
||||
this.WebSocket = opts.WebSocket || (typeof WebSocket !== 'undefined' ? WebSocket : null);
|
||||
this.storage = opts.storage || null;
|
||||
this.setTimeout = opts.setTimeout || ((fn, ms) => setTimeout(fn, ms));
|
||||
this.clearTimeout = opts.clearTimeout || ((t) => clearTimeout(t));
|
||||
this.opTimeoutMs = opts.opTimeoutMs || 10000;
|
||||
this.schema = this.loadSchema() || new this.Schema();
|
||||
this.model = new deps.ProtoModel(this.schema);
|
||||
this.model.onChange((kind, id, item) => this.emit('change', { kind: kind, id: id, item: item }));
|
||||
this.listeners = {};
|
||||
this.pending = new Map();
|
||||
this.nextId = 1;
|
||||
this.ws = null;
|
||||
this.ready = false;
|
||||
this.opened = false;
|
||||
this.stopped = true;
|
||||
this.retries = 0;
|
||||
this.reconnectTimer = null;
|
||||
this.held = [];
|
||||
this.fw = null;
|
||||
}
|
||||
// In the browser the classes are the ones index.js was joined with; in
|
||||
// Node they are loaded.
|
||||
static deps() {
|
||||
if (typeof require === 'function' && typeof module !== 'undefined' && module.exports)
|
||||
return { ProtoSchema: require('./schema.js').ProtoSchema, ProtoModel: require('./model.js').ProtoModel };
|
||||
return { ProtoSchema: ProtoSchema, ProtoModel: ProtoModel };
|
||||
}
|
||||
|
||||
// cb(d) for a device event by its name (shadeState, frame ...), and for
|
||||
// the client's own: open, hello, synced, change, refused (closed before it
|
||||
// opened: the token may have been refused), closed, versionMismatch and
|
||||
// error (a refusal the device couldn't tie to an op).
|
||||
on(name, cb) { (this.listeners[name] = this.listeners[name] || []).push(cb); }
|
||||
emit(name, d) { for (const cb of this.listeners[name] || []) cb(d); }
|
||||
|
||||
connect() {
|
||||
this.stopped = false;
|
||||
this.open();
|
||||
}
|
||||
close() {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimer) this.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
if (this.ws) this.ws.close();
|
||||
}
|
||||
// A new token, from a login: the device checks it as the socket opens.
|
||||
setToken(token) {
|
||||
this.token = token || '';
|
||||
if (this.ws) this.ws.close();
|
||||
else if (!this.stopped) this.open();
|
||||
}
|
||||
url() {
|
||||
const params = [];
|
||||
if (this.token) params.push(`apikey=${encodeURIComponent(this.token)}`);
|
||||
if (this.schema.hash) params.push(`schema=${encodeURIComponent(this.schema.hash)}`);
|
||||
return `ws://${this.host}/ws${params.length ? '?' + params.join('&') : ''}`;
|
||||
}
|
||||
open() {
|
||||
this.reconnectTimer = null;
|
||||
this.ready = false;
|
||||
this.opened = false;
|
||||
this.held = [];
|
||||
const ws = new this.WebSocket(this.url());
|
||||
this.ws = ws;
|
||||
ws.onopen = () => {
|
||||
this.opened = true;
|
||||
this.retries = 0;
|
||||
this.emit('open');
|
||||
};
|
||||
ws.onmessage = (evt) => this.receive(evt.data);
|
||||
ws.onclose = () => {
|
||||
if (this.ws !== ws) return;
|
||||
this.ws = null;
|
||||
this.ready = false;
|
||||
for (const [, p] of this.pending) {
|
||||
this.clearTimeout(p.timer);
|
||||
p.reject(new ProtoError('closed', 'The connection closed'));
|
||||
}
|
||||
this.pending.clear();
|
||||
this.emit(this.opened ? 'closed' : 'refused');
|
||||
if (this.stopped) return;
|
||||
// Soon after a close, slower after each failure, at most 10 s.
|
||||
const ms = Math.min(500 * Math.pow(2, this.retries++), 10000);
|
||||
this.reconnectTimer = this.setTimeout(() => this.open(), ms);
|
||||
};
|
||||
}
|
||||
send(type, d, id) {
|
||||
const frame = { v: ProtoClient.VERSION, t: this.schema.typeNumbers[type] };
|
||||
if (typeof frame.t === 'undefined') throw new ProtoError('unknownOp', `The device has no message ${type}`);
|
||||
if (id) frame.id = id;
|
||||
if (d) frame.d = d;
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
// Sends an op, or a control message that wants a reply, by its name with
|
||||
// its fields by name. Resolves with the reply's fields, rejects with a
|
||||
// ProtoError.
|
||||
op(name, fields) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ready || !this.ws) {
|
||||
reject(new ProtoError('notConnected', 'Not connected to the device'));
|
||||
return;
|
||||
}
|
||||
const id = this.nextId++;
|
||||
try {
|
||||
this.send(name, this.schema.pack(fields || {}), id);
|
||||
} catch (err) {
|
||||
reject(err instanceof ProtoError ? err : new ProtoError('badRequest', err.message));
|
||||
return;
|
||||
}
|
||||
const timer = this.setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new ProtoError('timeout', `No reply to ${name}`));
|
||||
}, this.opTimeoutMs);
|
||||
this.pending.set(id, { resolve: resolve, reject: reject, timer: timer });
|
||||
});
|
||||
}
|
||||
subscribe(feed) { return this.op('subscribe', { feed: this.schema.feedNumbers[feed] }); }
|
||||
unsubscribe(feed) { return this.op('unsubscribe', { feed: this.schema.feedNumbers[feed] }); }
|
||||
// The shade model and status again, without the schema.
|
||||
resync() {
|
||||
this.model.beginSync();
|
||||
return this.op('sync');
|
||||
}
|
||||
|
||||
receive(text) {
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.parse(text);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
if (frame.v !== ProtoClient.VERSION) {
|
||||
this.emit('versionMismatch', { device: frame.v, client: ProtoClient.VERSION });
|
||||
return;
|
||||
}
|
||||
const d = frame.d || {};
|
||||
// hello, the sync and syncEnd have fixed numbers: they come before the
|
||||
// schema.
|
||||
switch (frame.t) {
|
||||
case 1: this.hello(d); return;
|
||||
case 4: this.syncFrame(d); return;
|
||||
case 5: this.syncEnd(); return;
|
||||
default: break;
|
||||
}
|
||||
if (!this.ready) this.held.push(frame);
|
||||
else this.dispatch(frame);
|
||||
}
|
||||
hello(d) {
|
||||
this.fw = d.fw;
|
||||
// A schema cached for another firmware is no use: the device sends the
|
||||
// one it has, as it didn't get its hash.
|
||||
if (this.schema.hash !== d.sh) {
|
||||
this.schema = new this.Schema();
|
||||
this.model.schema = this.schema;
|
||||
}
|
||||
this.schema.hash = d.sh;
|
||||
this.model.beginSync();
|
||||
this.emit('hello', d);
|
||||
}
|
||||
syncFrame(d) {
|
||||
this.schema.add(d);
|
||||
const device = {};
|
||||
for (const key of Object.keys(d)) if (!['T', 'F', 'E', 'Q'].includes(key)) device[key] = d[key];
|
||||
// The schema's rows come first, so a frame with the device's sections
|
||||
// has the whole of it.
|
||||
if (Object.keys(device).length) this.model.applySync(this.schema.expand(device));
|
||||
this.ws.send(JSON.stringify({ v: ProtoClient.VERSION, t: 3 }));
|
||||
}
|
||||
syncEnd() {
|
||||
this.model.endSync();
|
||||
if (!this.ready) {
|
||||
this.ready = true;
|
||||
this.saveSchema();
|
||||
const held = this.held;
|
||||
this.held = [];
|
||||
for (const frame of held) this.dispatch(frame);
|
||||
}
|
||||
this.emit('synced', this.model);
|
||||
}
|
||||
dispatch(frame) {
|
||||
const d = this.schema.expand(frame.d || {});
|
||||
if (frame.t === 2) {
|
||||
const p = typeof frame.id !== 'undefined' ? this.pending.get(frame.id) : null;
|
||||
if (!p) {
|
||||
if (!d.ok) this.emit('error', new ProtoError(this.schema.errors[d.code] || 'internal', d.desc, d));
|
||||
return;
|
||||
}
|
||||
this.pending.delete(frame.id);
|
||||
this.clearTimeout(p.timer);
|
||||
if (d.ok) p.resolve(d);
|
||||
else p.reject(new ProtoError(this.schema.errors[d.code] || 'internal', d.desc, d));
|
||||
return;
|
||||
}
|
||||
const name = this.schema.types[frame.t];
|
||||
if (!name) return;
|
||||
this.model.applyEvent(name, d);
|
||||
this.emit(name, d);
|
||||
}
|
||||
|
||||
loadSchema() {
|
||||
if (!this.storage) return null;
|
||||
try {
|
||||
return this.Schema.load(JSON.parse(this.storage.getItem(ProtoClient.SCHEMA_KEY)));
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
saveSchema() {
|
||||
if (!this.storage) return;
|
||||
try {
|
||||
this.storage.setItem(ProtoClient.SCHEMA_KEY, JSON.stringify(this.schema.save()));
|
||||
} catch (err) { /* Private windows may have no storage: the device sends the schema each time. */ }
|
||||
}
|
||||
}
|
||||
ProtoClient.VERSION = 2;
|
||||
ProtoClient.SCHEMA_KEY = 'espsomfyProtoSchema';
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = { ProtoClient, ProtoError };
|
||||
150
data-src/js/model.js
Normal file
150
data-src/js/model.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// The shade model as socket protocol 2 keeps a client up to date with it
|
||||
// (docs/PROTOCOL.md): rooms, shades, groups and repeaters from the sync, then
|
||||
// changed by the device's events. Items are inserted or updated by id, so an
|
||||
// event during the sync does no harm. It takes frames already expanded to
|
||||
// names (ProtoSchema.expand) and fills in what a frame leaves out at its
|
||||
// default. The minify step joins this file into index.js; Node loads it on
|
||||
// its own for the tests (test/js).
|
||||
class ProtoModel {
|
||||
constructor(schema) {
|
||||
this.schema = schema;
|
||||
this.rooms = new Map();
|
||||
this.shades = new Map();
|
||||
this.groups = new Map();
|
||||
this.repeaters = [];
|
||||
this.header = {};
|
||||
this.status = {};
|
||||
this.listeners = [];
|
||||
this.seen = null;
|
||||
}
|
||||
// cb(kind, id, item): kind is room, shade, group, roomRemoved,
|
||||
// shadeRemoved, groupRemoved, order, repeaters, header or status.
|
||||
onChange(cb) { this.listeners.push(cb); }
|
||||
changed(kind, id, item) { for (const cb of this.listeners) cb(kind, id, item); }
|
||||
|
||||
// A sync: the items it doesn't list are gone once it ends.
|
||||
beginSync() { this.seen = { rooms: new Set(), shades: new Set(), groups: new Set() }; }
|
||||
applySync(d) {
|
||||
if (d.header) {
|
||||
this.header = this.schema.fill(Object.assign({}, d.header), ProtoModel.FIELDS.header);
|
||||
this.changed('header', 0, this.header);
|
||||
}
|
||||
for (const r of d.rooms || []) this.putRoom(r);
|
||||
for (const s of d.shades || []) this.putShade(s);
|
||||
for (const g of d.groups || []) this.putGroup(g);
|
||||
if (d.repeaters) this.putRepeaters(d.repeaters);
|
||||
if (d.status) {
|
||||
this.putStatus(d.status, ProtoModel.FIELDS.memory);
|
||||
this.putStatus(d.status, this.header.connType === 'Ethernet' ? ProtoModel.FIELDS.ethernet : ProtoModel.FIELDS.wifi);
|
||||
}
|
||||
}
|
||||
endSync() {
|
||||
if (!this.seen) return;
|
||||
const lists = [['rooms', 'roomRemoved'], ['shades', 'shadeRemoved'], ['groups', 'groupRemoved']];
|
||||
for (const [list, removed] of lists) {
|
||||
for (const id of [...this[list].keys()]) {
|
||||
if (this.seen[list].has(id)) continue;
|
||||
this[list].delete(id);
|
||||
this.changed(removed, id);
|
||||
}
|
||||
}
|
||||
this.seen = null;
|
||||
}
|
||||
// An event by its type's name; false for one that isn't about the model.
|
||||
applyEvent(type, d) {
|
||||
switch (type) {
|
||||
case 'room': this.putRoom(d); return true;
|
||||
case 'shade': this.putShade(d); return true;
|
||||
case 'group': this.putGroup(d); return true;
|
||||
case 'shadeState': return this.mergeState(this.shades, 'shade', d.shadeId, d, ProtoModel.FIELDS.shadeState);
|
||||
case 'groupState': return this.mergeState(this.groups, 'group', d.groupId, d, ProtoModel.FIELDS.groupState);
|
||||
case 'roomRemoved': return this.remove(this.rooms, type, d.roomId || 0);
|
||||
case 'shadeRemoved': return this.remove(this.shades, type, d.shadeId || 0);
|
||||
case 'groupRemoved': return this.remove(this.groups, type, d.groupId || 0);
|
||||
case 'roomOrder': return this.order(this.rooms, d.ids || []);
|
||||
case 'shadeOrder': return this.order(this.shades, d.ids || []);
|
||||
case 'groupOrder': return this.order(this.groups, d.ids || []);
|
||||
case 'repeaters': this.putRepeaters(d.repeaters || []); return true;
|
||||
case 'memory': this.putStatus(d, ProtoModel.FIELDS.memory); return true;
|
||||
case 'wifi': this.putStatus(d, ProtoModel.FIELDS.wifi); return true;
|
||||
case 'ethernet': this.putStatus(d, ProtoModel.FIELDS.ethernet); return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The lists in their sort order.
|
||||
roomList() { return ProtoModel.sorted(this.rooms); }
|
||||
shadeList() { return ProtoModel.sorted(this.shades); }
|
||||
groupList() { return ProtoModel.sorted(this.groups); }
|
||||
static sorted(map) { return [...map.values()].sort((a, b) => a.sortOrder - b.sortOrder); }
|
||||
|
||||
putRoom(r) { this.put(this.rooms, 'room', r.roomId, r, ProtoModel.FIELDS.room, 'rooms'); }
|
||||
putShade(s) {
|
||||
// Its linked remotes leave out their defaults too.
|
||||
const linked = (s.linkedRemotes || []).map((r) => this.schema.fill(Object.assign({}, r), ProtoModel.FIELDS.linkedRemote));
|
||||
this.put(this.shades, 'shade', s.shadeId, Object.assign({}, s, { linkedRemotes: linked }), ProtoModel.FIELDS.shade, 'shades');
|
||||
}
|
||||
putGroup(g) { this.put(this.groups, 'group', g.groupId, g, ProtoModel.FIELDS.group, 'groups'); }
|
||||
// A whole item: what it had before is replaced.
|
||||
put(map, kind, id, item, fields, list) {
|
||||
id = id || 0;
|
||||
const whole = this.schema.fill(Object.assign({}, item), fields);
|
||||
map.set(id, whole);
|
||||
if (this.seen) this.seen[list].add(id);
|
||||
this.changed(kind, id, whole);
|
||||
}
|
||||
// Part of an item: only its state. A state of an item not synced yet
|
||||
// waits for the item.
|
||||
mergeState(map, kind, id, d, fields) {
|
||||
const item = map.get(id || 0);
|
||||
if (!item) return false;
|
||||
Object.assign(item, this.schema.fill(Object.assign({}, d), fields));
|
||||
this.changed(kind, item[`${kind}Id`], item);
|
||||
return true;
|
||||
}
|
||||
remove(map, kind, id) {
|
||||
if (!map.delete(id)) return false;
|
||||
this.changed(kind, id);
|
||||
return true;
|
||||
}
|
||||
order(map, ids) {
|
||||
ids.forEach((id, i) => {
|
||||
const item = map.get(id);
|
||||
if (item) item.sortOrder = i;
|
||||
});
|
||||
this.changed('order', 0, ids);
|
||||
return true;
|
||||
}
|
||||
putRepeaters(list) {
|
||||
this.repeaters = list.slice();
|
||||
this.changed('repeaters', 0, this.repeaters);
|
||||
}
|
||||
putStatus(d, fields) {
|
||||
const part = {};
|
||||
for (const name of fields) part[name] = typeof d[name] === 'undefined' ? this.schema.defaultOf(name) : d[name];
|
||||
Object.assign(this.status, part);
|
||||
this.changed('status', 0, this.status);
|
||||
}
|
||||
}
|
||||
// The fields of each kind of item, as the device writes them
|
||||
// (src/ProtoSync.cpp, src/outputs/ProtoSocket.cpp): what a frame that leaves
|
||||
// one out means by it.
|
||||
ProtoModel.FIELDS = {
|
||||
header: ['maxRooms', 'maxShades', 'maxGroups', 'maxGroupedShades', 'maxLinkedRemotes', 'startingAddress', 'bitLength',
|
||||
'proto', 'serverId', 'hostname', 'model', 'chipModel', 'fw', 'connType'],
|
||||
room: ['roomId', 'name', 'sortOrder'],
|
||||
shadeState: ['shadeId', 'position', 'target', 'direction', 'myPos', 'tiltPosition', 'tiltTarget', 'tiltDirection',
|
||||
'myTiltPos', 'flags', 'lastRollingCode'],
|
||||
groupState: ['groupId', 'flags', 'direction'],
|
||||
linkedRemote: ['remoteAddress', 'lastRollingCode'],
|
||||
group: ['groupId', 'roomId', 'name', 'remoteAddress', 'lastRollingCode', 'bitLength', 'proto', 'sunSensor',
|
||||
'flipCommands', 'flags', 'repeats', 'sortOrder', 'linkedShades'],
|
||||
memory: ['freeHeap', 'maxHeap', 'minHeap', 'totalHeap', 'uptime', 'loopRate', 'loopMaxUs'],
|
||||
wifi: ['ssid', 'strength', 'channel'],
|
||||
ethernet: ['connected', 'speed', 'fullDuplex']
|
||||
};
|
||||
ProtoModel.FIELDS.shade = ProtoModel.FIELDS.shadeState.concat(['roomId', 'name', 'remoteAddress', 'upTime', 'downTime',
|
||||
'tiltTime', 'stepSize', 'paired', 'tiltType', 'shadeType', 'bitLength', 'proto', 'flipCommands', 'flipPosition',
|
||||
'inGroup', 'sunSensor', 'light', 'simMy', 'repeats', 'sortOrder', 'gpioUp', 'gpioDown', 'gpioMy', 'gpioLLTrigger',
|
||||
'linkedRemotes']);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = { ProtoModel };
|
||||
96
data-src/js/schema.js
Normal file
96
data-src/js/schema.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Socket protocol 2's schema (docs/PROTOCOL.md): the device's table of message
|
||||
// types, fields, error codes and feeds, as the sync sends it. It turns the
|
||||
// short keys of a frame into names and back. The minify step joins this file
|
||||
// into index.js; Node loads it on its own for the tests (test/js).
|
||||
class ProtoSchema {
|
||||
constructor() {
|
||||
this.hash = null;
|
||||
this.types = {}; // number -> name
|
||||
this.typeNumbers = {}; // name -> number
|
||||
this.fields = {}; // key -> { name, kind, def }
|
||||
this.keys = {}; // name -> key
|
||||
this.errors = {};
|
||||
this.errorNumbers = {};
|
||||
this.feeds = {};
|
||||
this.feedNumbers = {};
|
||||
this.rows = { T: [], F: [], E: [], Q: [] };
|
||||
}
|
||||
// The schema's rows in a sync frame's payload: T types, F fields, E error
|
||||
// codes, Q feeds. They may span several frames.
|
||||
add(d) {
|
||||
for (const [number, name] of d.T || []) {
|
||||
this.types[number] = name;
|
||||
this.typeNumbers[name] = number;
|
||||
}
|
||||
for (const [key, name, kind, def] of d.F || []) {
|
||||
this.fields[key] = { name: name, kind: kind, def: def };
|
||||
this.keys[name] = key;
|
||||
}
|
||||
for (const [number, name] of d.E || []) {
|
||||
this.errors[number] = name;
|
||||
this.errorNumbers[name] = number;
|
||||
}
|
||||
for (const [number, name] of d.Q || []) {
|
||||
this.feeds[number] = name;
|
||||
this.feedNumbers[name] = number;
|
||||
}
|
||||
for (const k of ['T', 'F', 'E', 'Q']) if (d[k]) this.rows[k].push(...d[k]);
|
||||
}
|
||||
get empty() { return Object.keys(this.fields).length === 0; }
|
||||
// What a field left out of a frame is: its default.
|
||||
defaultOf(name) {
|
||||
const f = this.fields[this.keys[name]];
|
||||
if (!f) return undefined;
|
||||
switch (f.kind) {
|
||||
case 'b': return f.def !== 0;
|
||||
case 's': return '';
|
||||
case 'l': return [];
|
||||
case 'o': return {};
|
||||
default: return f.def;
|
||||
}
|
||||
}
|
||||
// Short keys to names, and 1/0 to true/false, all the way down. A field
|
||||
// left out stays out; fill() puts the defaults in.
|
||||
expand(value) {
|
||||
if (Array.isArray(value)) return value.map((v) => this.expand(v));
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
const out = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
const f = this.fields[key];
|
||||
let v = value[key];
|
||||
if (f && f.kind === 'b' && typeof v === 'number') v = v !== 0;
|
||||
out[f ? f.name : key] = this.expand(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Names to short keys, and booleans to 1/0, for an op's payload. A name
|
||||
// the schema doesn't have is an error: the device would refuse it anyway.
|
||||
pack(value) {
|
||||
if (Array.isArray(value)) return value.map((v) => this.pack(v));
|
||||
if (value === null || typeof value !== 'object') return typeof value === 'boolean' ? (value ? 1 : 0) : value;
|
||||
const out = {};
|
||||
for (const name of Object.keys(value)) {
|
||||
if (typeof value[name] === 'undefined') continue;
|
||||
const key = this.keys[name];
|
||||
if (typeof key === 'undefined') throw new Error(`The device has no field ${name}`);
|
||||
out[key] = this.pack(value[name]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Sets each of names that obj lacks to its default, as a frame leaves out
|
||||
// a field at its default. Returns obj.
|
||||
fill(obj, names) {
|
||||
for (const name of names) if (typeof obj[name] === 'undefined') obj[name] = this.defaultOf(name);
|
||||
return obj;
|
||||
}
|
||||
// What a cache keeps, and the schema back from it.
|
||||
save() { return { hash: this.hash, rows: this.rows }; }
|
||||
static load(saved) {
|
||||
if (!saved || typeof saved.hash !== 'string' || !saved.rows) return null;
|
||||
const s = new ProtoSchema();
|
||||
s.add(saved.rows);
|
||||
s.hash = saved.hash;
|
||||
return s.empty ? null : s;
|
||||
}
|
||||
}
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = { ProtoSchema };
|
||||
|
|
@ -3,9 +3,7 @@
|
|||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="main.css?v=2.4.0" type="text/css" />
|
||||
<link rel="stylesheet" href="widgets.css?v=2.4.0" type="text/css" />
|
||||
<link rel="stylesheet" href="icons.css?v=2.4.0" type="text/css" />
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue