zebra-report: delivery ACKs + outbox (hold sent msgs until confirmed)
This commit is contained in:
parent
db382f6d44
commit
90d5966c08
1 changed files with 112 additions and 11 deletions
|
|
@ -382,6 +382,10 @@
|
|||
<input type="text" id="msg-in" placeholder="type a message and press enter">
|
||||
<button id="btn-send" disabled>send</button>
|
||||
</div>
|
||||
<div id="outbox-wrap" style="display:none; margin-top:0.4rem">
|
||||
<p class="note" style="margin-bottom:0.2rem">outbox — awaiting delivery confirmation:</p>
|
||||
<div class="peer-list" id="outbox"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
|
|
@ -481,6 +485,7 @@ const T_OFFER = 0x01;
|
|||
const T_READY = 0x02;
|
||||
const T_DATA = 0x03;
|
||||
const T_HELLO = 0x04;
|
||||
const T_ACK = 0x05;
|
||||
const HS_FRAME_LEN = 6;
|
||||
|
||||
const PBKDF2_SALT = new TextEncoder().encode('zebra-report-v1');
|
||||
|
|
@ -729,6 +734,22 @@ async function buildHelloFrame(handle, maxBaud) {
|
|||
out[body.length+2] = (crc >> 16) & 0xFF; out[body.length+3] = (crc >> 24) & 0xFF;
|
||||
return out;
|
||||
}
|
||||
/* ACK frame: magic(2) type(1) sid(4) ackCrc(4) crc(4) = 15 bytes.
|
||||
* ackCrc is the CRC32 of the DATA frame being acknowledged (its message id). */
|
||||
async function buildAckFrame(ackCrc) {
|
||||
const sid = await senderIdBytes();
|
||||
const body = new Uint8Array(11);
|
||||
body[0] = HS_MAGIC[0]; body[1] = HS_MAGIC[1]; body[2] = T_ACK;
|
||||
body.set(sid, 3);
|
||||
body[7] = ackCrc & 0xFF; body[8] = (ackCrc >> 8) & 0xFF;
|
||||
body[9] = (ackCrc >> 16) & 0xFF; body[10] = (ackCrc >>> 24) & 0xFF;
|
||||
const crc = crc32(body);
|
||||
const out = new Uint8Array(15);
|
||||
out.set(body);
|
||||
out[11] = crc & 0xFF; out[12] = (crc >> 8) & 0xFF;
|
||||
out[13] = (crc >> 16) & 0xFF; out[14] = (crc >>> 24) & 0xFF;
|
||||
return out;
|
||||
}
|
||||
function parseFrame(bytes) {
|
||||
if (bytes.length < 3) return null;
|
||||
if (bytes[0] !== HS_MAGIC[0] || bytes[1] !== HS_MAGIC[1]) return null;
|
||||
|
|
@ -748,7 +769,15 @@ function parseFrame(bytes) {
|
|||
const frameNoCrc = bytes.slice(0, 9 + len);
|
||||
const got = bytes[9+len] | (bytes[9+len+1] << 8) | (bytes[9+len+2] << 16) | (bytes[9+len+3] << 24);
|
||||
if ((got >>> 0) !== crc32(frameNoCrc)) return { type, error: 'crc' };
|
||||
return { type, sid, payload };
|
||||
return { type, sid, payload, crc: (got >>> 0) };
|
||||
}
|
||||
if (type === T_ACK) {
|
||||
if (bytes.length < 15) return null;
|
||||
const sid = bytes.slice(3, 7);
|
||||
const ackCrc = (bytes[7] | (bytes[8] << 8) | (bytes[9] << 16) | (bytes[10] << 24)) >>> 0;
|
||||
const got = (bytes[11] | (bytes[12] << 8) | (bytes[13] << 16) | (bytes[14] << 24)) >>> 0;
|
||||
if (got !== crc32(bytes.slice(0, 11))) return { type, error: 'crc' };
|
||||
return { type, sid, ackCrc };
|
||||
}
|
||||
if (type === T_HELLO) {
|
||||
if (bytes.length < 10) return null;
|
||||
|
|
@ -968,6 +997,12 @@ class FrameAssembler {
|
|||
this.buf.splice(0, need);
|
||||
return frame;
|
||||
}
|
||||
if (t === T_ACK) {
|
||||
if (this.buf.length < 15) return null;
|
||||
const frame = new Uint8Array(this.buf.slice(0, 15));
|
||||
this.buf.splice(0, 15);
|
||||
return frame;
|
||||
}
|
||||
if (t === T_HELLO) {
|
||||
if (this.buf.length < 10) return null;
|
||||
const hlen = this.buf[9];
|
||||
|
|
@ -1116,6 +1151,52 @@ if (LOOPBACK) {
|
|||
};
|
||||
}
|
||||
|
||||
/* ============================================================== *
|
||||
* reliable delivery: outbox (pending until ACK) + retransmit *
|
||||
* ============================================================== */
|
||||
const msgPending = new Map(); /* frameCrc -> { handle, text, frame, tries, failed, timer } */
|
||||
const rxSeenCrc = new Set(); /* recently received DATA crcs, for de-dup */
|
||||
const MAX_TX_TRIES = 5;
|
||||
|
||||
function frameCrcOf(f) {
|
||||
const n = f.length;
|
||||
return (f[n-4] | (f[n-3] << 8) | (f[n-2] << 16) | (f[n-1] << 24)) >>> 0;
|
||||
}
|
||||
/* rough on-air time for a frame of byteLen bytes at the current link settings */
|
||||
function estimateTxMs(byteLen) {
|
||||
const ticks = 10 + byteLen * (2 + ZEBRA_SYMS_PER_BYTE) + 5; /* preamble + frame + trailer */
|
||||
return ticks / ZEBRA_BAUD_HANDSHAKE * 1000 + 80;
|
||||
}
|
||||
function renderOutbox() {
|
||||
const wrap = $('outbox-wrap'), el = $('outbox');
|
||||
if (!wrap || !el) return;
|
||||
if (msgPending.size === 0) { wrap.style.display = 'none'; el.innerHTML = ''; return; }
|
||||
wrap.style.display = '';
|
||||
el.innerHTML = '';
|
||||
for (const [, m] of msgPending) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'peer';
|
||||
const status = m.failed ? '✗ no ack (giving up)'
|
||||
: (m.tries > 1 ? `sending… (try ${m.tries})` : 'sending…');
|
||||
row.innerHTML = `<span style="color:${m.failed ? '#b00' : '#888'}">`
|
||||
+ `⧗ ${escapeHtml(m.text)} — ${status}</span>`;
|
||||
el.appendChild(row);
|
||||
}
|
||||
}
|
||||
function scheduleRetransmit(crc) {
|
||||
const entry = msgPending.get(crc);
|
||||
if (!entry) return;
|
||||
const wait = estimateTxMs(entry.frame.length) + estimateTxMs(15) + 4000; /* data + ack + margin */
|
||||
entry.timer = setTimeout(async () => {
|
||||
const e = msgPending.get(crc);
|
||||
if (!e) return; /* already ACKed */
|
||||
if (e.tries >= MAX_TX_TRIES) { e.failed = true; renderOutbox(); return; }
|
||||
e.tries++; renderOutbox();
|
||||
try { await txFrame(e.frame, ZEBRA_BAUD_HANDSHAKE); } catch (_) {}
|
||||
scheduleRetransmit(crc);
|
||||
}, wait);
|
||||
}
|
||||
|
||||
/* ============================================================== *
|
||||
* frame handler *
|
||||
* ============================================================== */
|
||||
|
|
@ -1152,6 +1233,17 @@ async function onRxFrame(bytes) {
|
|||
if (!seen) await announceHello();
|
||||
return;
|
||||
}
|
||||
if (f.type === T_ACK) {
|
||||
const entry = msgPending.get(f.ackCrc);
|
||||
if (entry) {
|
||||
if (entry.timer) clearTimeout(entry.timer);
|
||||
msgPending.delete(f.ackCrc);
|
||||
logLine('me', `<span class="from">${escapeHtml(entry.handle)}:</span> `
|
||||
+ `${escapeHtml(entry.text)} <span style="color:#060">✓</span>`);
|
||||
renderOutbox();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (f.type === T_DATA) {
|
||||
const sid = hex(f.sid);
|
||||
let pt = null;
|
||||
|
|
@ -1165,6 +1257,12 @@ async function onRxFrame(bytes) {
|
|||
}
|
||||
if (pt === null) { logLine('err', `rx: data from ${sid.slice(0,8)} (no key)`); return; }
|
||||
}
|
||||
/* received + decrypted OK → ACK it (even duplicates, so a retransmit stops
|
||||
* when an earlier ACK was lost) */
|
||||
try { await txFrame(await buildAckFrame(f.crc), ZEBRA_BAUD_HANDSHAKE); } catch (_) {}
|
||||
if (rxSeenCrc.has(f.crc)) return; /* duplicate: re-ACKed, don't re-display */
|
||||
rxSeenCrc.add(f.crc);
|
||||
if (rxSeenCrc.size > 300) rxSeenCrc.clear();
|
||||
const handle = (peers.get(sid) || {}).handle || sid.slice(0,8);
|
||||
logLine('peer', `<span class="from">${escapeHtml(handle)}:</span> ${escapeHtml(pt)}`);
|
||||
}
|
||||
|
|
@ -1684,25 +1782,28 @@ async function sendCurrentMsg() {
|
|||
if (mode === 'pubkey' && !Array.from(peers.values()).some(p => p.sharedKey)) {
|
||||
logLine('err', 'no pubkey peers ready'); return;
|
||||
}
|
||||
/* must match the inbound decoder, which stays at ZEBRA_BAUD_HANDSHAKE for the
|
||||
* life of the link — data sent at any other baud won't decode on the far side. */
|
||||
/* data travels at ZEBRA_BAUD_HANDSHAKE to match the inbound decoder */
|
||||
const baud = ZEBRA_BAUD_HANDSHAKE;
|
||||
const handle = handleIn.value.trim() || 'me';
|
||||
/* echo immediately so the sender sees the message without waiting for the
|
||||
* (slow, multi-second at low baud) transmit to finish */
|
||||
logLine('me', `<span class="from">${escapeHtml(handle)}:</span> ${escapeHtml(msg)}`);
|
||||
$('msg-in').value = '';
|
||||
/* don't echo to the log yet — hold in the outbox until the far side ACKs,
|
||||
* retransmitting until then. the message moves into the log on its ACK. */
|
||||
try {
|
||||
const frames = [];
|
||||
if (mode === 'passphrase') {
|
||||
const ct = await aesEncrypt(groupKey, msg);
|
||||
await txFrame(await buildDataFrame(ct), baud);
|
||||
frames.push(await buildDataFrame(await aesEncrypt(groupKey, msg)));
|
||||
} else {
|
||||
for (const p of peers.values()) {
|
||||
if (!p.sharedKey) continue;
|
||||
const ct = await aesEncrypt(p.sharedKey, msg);
|
||||
await txFrame(await buildDataFrame(ct), baud);
|
||||
if (p.sharedKey) frames.push(await buildDataFrame(await aesEncrypt(p.sharedKey, msg)));
|
||||
}
|
||||
}
|
||||
for (const frame of frames) {
|
||||
const crc = frameCrcOf(frame);
|
||||
msgPending.set(crc, { handle, text: msg, frame, tries: 1, failed: false, timer: null });
|
||||
renderOutbox();
|
||||
await txFrame(frame, baud);
|
||||
scheduleRetransmit(crc);
|
||||
}
|
||||
} catch (e) {
|
||||
logLine('err', 'send failed: ' + e.message);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue