472 lines
18 KiB
TypeScript
472 lines
18 KiB
TypeScript
import { FRAME, DATA, ZEPHYR_ERRORS } from './constants';
|
|
import { protocolInfo, deviceInfo, fsInfo, transferStats, fwInfo, resetTransferStats, transferDetails } from '../store';
|
|
import { addToast } from '../toast';
|
|
import { SETTINGS } from '../settings';
|
|
import { crc32 } from './crc32';
|
|
import { get } from 'svelte/store';
|
|
import { saveLocalFile } from '../db';
|
|
import { refreshLocal } from '../sync';
|
|
import { file } from 'astro:schema';
|
|
|
|
let lastUiUpdate = 0;
|
|
let currentFileCrc32 = 0;
|
|
|
|
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
|
|
|
|
let lsBuffer: any[] = [];
|
|
let fileChunks: Uint8Array[] = [];
|
|
let lsTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let lsResolve: ((data: any[]) => void) | null = null;
|
|
let lsReject: ((error: Error) => void) | null = null;
|
|
let fileGetResolve: ((result: { success: boolean, blob?: Blob }) => void) | null = null;
|
|
let fileGetReject: ((error: Error) => void) | null = null;
|
|
|
|
export function showErrorToast(errorCode: number) {
|
|
const errorInfo = ZEPHYR_ERRORS[errorCode];
|
|
const hexCode = errorCode.toString(16).padStart(2, '0');
|
|
|
|
if (errorInfo) {
|
|
// Variante mit HTML-Tags (erfordert {@html ...} im Svelte-Template)
|
|
const htmlMessage = `Buzzer: <span class="font-mono font-bold text-xs">${errorInfo.zephyr} (0x${hexCode}):</span> ${errorInfo.text} `;
|
|
addToast(htmlMessage, 'error');
|
|
|
|
/* Alternativ als reiner Text, falls HTML im Toast nicht unterstützt wird:
|
|
const textMessage = `Buzzer: [${errorInfo.zephyr}] ${errorInfo.text} (0x${hexCode})`;
|
|
addToast(textMessage, 'error');
|
|
*/
|
|
} else {
|
|
addToast(`Der Buzzer meldet einen unbekannten Fehler: <span class="font-mono">0x${hexCode}</span>`, 'error');
|
|
}
|
|
}
|
|
|
|
export function parseIncomingFrame(view: DataView, sender: FrameSender) {
|
|
if (view.byteLength < 3) return;
|
|
|
|
const frameType = view.getUint8(0);
|
|
const payloadLength = view.getUint16(1, true);
|
|
|
|
switch (frameType) {
|
|
case FRAME.RESPONSE:
|
|
const dataType = view.getUint8(3);
|
|
switch (dataType) {
|
|
case DATA.PROTO_INFO:
|
|
const version = view.getUint16(4, true);
|
|
const maxChunkSize = view.getUint16(6, true);
|
|
protocolInfo.set({ version, maxChunkSize });
|
|
break;
|
|
case DATA.FS_INFO:
|
|
const totalSizeBytes = view.getUint32(4, true);
|
|
const freeSizeBytes = view.getUint32(8, true);
|
|
const maxPathLength = view.getUint8(12);
|
|
const sysPathLength = view.getUint8(13);
|
|
const audioPathLength = view.getUint8(14);
|
|
const sysPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15, sysPathLength));
|
|
const audioPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + sysPathLength, audioPathLength));
|
|
fsInfo.set({ totalSize: totalSizeBytes / 1024 / 1024, freeSize: freeSizeBytes / 1024 / 1024, maxPathLength, sysPath, audioPath });
|
|
break;
|
|
case DATA.DEVICE_INFO:
|
|
const deviceId = [0, 2, 4, 6]
|
|
.map(offset => view.getUint16(4 + offset, false).toString(16).padStart(4, '0').toUpperCase())
|
|
.join('-');
|
|
const boardNameLength = view.getUint8(12);
|
|
const boardRevisionLength = view.getUint8(13);
|
|
const socNameLength = view.getUint8(14);
|
|
const boardName = new TextDecoder().decode(new Uint8Array(view.buffer, 15, boardNameLength));
|
|
const boardRevision = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + boardNameLength, boardRevisionLength));
|
|
const socName = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + boardNameLength + boardRevisionLength, socNameLength));
|
|
deviceInfo.set({ deviceId, boardName, boardRevision, socName });
|
|
break;
|
|
case DATA.FW_INFO:
|
|
const fwStatus = view.getUint8(4);
|
|
const slot1Size = view.getUint32(5, true);
|
|
const fw_version_length = view.getUint8(9);
|
|
const kernel_version_length = view.getUint8(10);
|
|
const fwVersion = new TextDecoder().decode(new Uint8Array(view.buffer, 11, fw_version_length));
|
|
const kernelVersion = new TextDecoder().decode(new Uint8Array(view.buffer, 11 + fw_version_length, kernel_version_length));
|
|
fwInfo.set({ fwStatus, slot1Size, fwVersion, kernelVersion });
|
|
}
|
|
break;
|
|
|
|
case FRAME.LS_START:
|
|
lsBuffer = [];
|
|
resetLsWatchdog();
|
|
handleLsStart(sender);
|
|
break;
|
|
|
|
case FRAME.LS_ENTRY:
|
|
resetLsWatchdog();
|
|
if (payloadLength >= 6) {
|
|
const type = view.getUint8(3);
|
|
const size = view.getUint32(4, true);
|
|
const nameLen = view.getUint8(8);
|
|
const name = new TextDecoder().decode(new Uint8Array(view.buffer, 9, nameLen));
|
|
|
|
lsBuffer.push({ type, size, name });
|
|
|
|
// TODO: Mehr credits senden, wenn diese knapp werden
|
|
}
|
|
break;
|
|
|
|
case FRAME.LS_END:
|
|
if (lsTimeout) clearTimeout(lsTimeout);
|
|
const total = view.getUint32(3, true);
|
|
if (total !== lsBuffer.length) {
|
|
console.warn(`LS Stream: Erwartete Anzahl Einträge laut Header: ${total}, tatsächlich empfangen: ${lsBuffer.length}`);
|
|
addToast(`Warnung: LS Stream erwartete ${total} Einträge, aber nur ${lsBuffer.length} empfangen.`, 'warning');
|
|
} else if (lsResolve) {
|
|
const currentResolve = lsResolve;
|
|
lsResolve = null;
|
|
lsReject = null;
|
|
currentResolve([...lsBuffer]);
|
|
}
|
|
break;
|
|
|
|
case FRAME.FILE_START:
|
|
currentFileCrc32 = 0;
|
|
const totalBytes = view.getUint32(3, true);
|
|
const nowStart = performance.now();
|
|
fileChunks = [];
|
|
|
|
if (fileTransfer.mode === 'file') {
|
|
transferStats.update(s => ({
|
|
...s,
|
|
bytesTotal: totalBytes,
|
|
bytesDone: 0,
|
|
currentFileName: s.pendingFileName || s.currentFileName,
|
|
fileStartTime: nowStart,
|
|
bulkStartTime: s.bulkStartTime === 0 ? nowStart : s.bulkStartTime
|
|
}));
|
|
}
|
|
|
|
// Parser-interne Metriken (Watchdog etc.)
|
|
fileTransfer.totalBytes = totalBytes;
|
|
fileTransfer.receivedBytes = 0;
|
|
fileTransfer.active = true;
|
|
fileTransfer.startTime = nowStart;
|
|
lastUiUpdate = 0;
|
|
|
|
fileTransfer.metricsTimer = setInterval(() => {
|
|
if (!fileTransfer.active) return;
|
|
|
|
// Watchdog-Logik: Prüfen ob seit der letzten Sekunde Daten kamen
|
|
if (fileTransfer.receivedBytes === fileTransfer.lastReceivedBytes) {
|
|
fileTransfer.stalledSeconds++;
|
|
|
|
if (fileTransfer.stalledSeconds >= 5) { // 5 Sekunden Timeout
|
|
console.warn("[FILE_GET] Übertragung abgebrochen: Timeout (Keine Daten empfangen).");
|
|
|
|
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
|
|
fileTransfer.active = false;
|
|
|
|
addToast("Dateitransfer abgebrochen (Timeout)", "error");
|
|
|
|
const currentReject = fileGetReject;
|
|
fileGetResolve = null;
|
|
fileGetReject = null;
|
|
|
|
if (currentReject) {
|
|
currentReject(new Error("Timeout beim Dateitransfer"));
|
|
}
|
|
return;
|
|
}
|
|
} else {
|
|
fileTransfer.stalledSeconds = 0;
|
|
fileTransfer.lastReceivedBytes = fileTransfer.receivedBytes;
|
|
}
|
|
}, 1000);
|
|
|
|
// Initiale Credits (z.B. 64)
|
|
fileTransfer.credits = 128;
|
|
sendCredits(fileTransfer.credits, sender);
|
|
break;
|
|
|
|
case FRAME.FILE_CHUNK:
|
|
if (!fileTransfer.active) break;
|
|
|
|
const chunkData = new Uint8Array(view.buffer, 3, payloadLength);
|
|
currentFileCrc32 = crc32(chunkData, currentFileCrc32);
|
|
fileChunks.push(new Uint8Array(chunkData));
|
|
|
|
const previousReceived = fileTransfer.receivedBytes;
|
|
fileTransfer.receivedBytes += payloadLength;
|
|
fileTransfer.credits--;
|
|
|
|
if (fileTransfer.mode === 'file') {
|
|
const nowChunk = performance.now();
|
|
if (nowChunk - lastUiUpdate > SETTINGS.ui.transferUpdateIntervalMs) {
|
|
const delta = fileTransfer.receivedBytes - previousReceived;
|
|
|
|
transferStats.update(s => ({
|
|
...s,
|
|
bytesDone: fileTransfer.receivedBytes,
|
|
overallDone: s.overallDone + (fileTransfer.receivedBytes - s.bytesDone)
|
|
}));
|
|
lastUiUpdate = nowChunk;
|
|
}
|
|
}
|
|
|
|
if (fileTransfer.credits <= 64) {
|
|
fileTransfer.credits = 128;
|
|
sendCredits(fileTransfer.credits, sender);
|
|
}
|
|
break;
|
|
|
|
case FRAME.FILE_END:
|
|
if (fileTransfer.mode === 'file') {
|
|
transferStats.update(s => ({
|
|
...s,
|
|
bytesDone: s.bytesTotal,
|
|
}));
|
|
}
|
|
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
|
|
fileTransfer.active = false;
|
|
|
|
const buzzerCrc32 = view.getUint32(3, true);
|
|
|
|
const totalElapsed = (performance.now() - fileTransfer.startTime) / 1000;
|
|
const avgSpeed = (fileTransfer.receivedBytes / 1024) / totalElapsed;
|
|
|
|
if (currentFileCrc32 === buzzerCrc32) {
|
|
const fileBlob = new Blob(fileChunks, { type: 'application/octet-stream' });
|
|
|
|
if (fileTransfer.mode === 'file') {
|
|
const fileName = get(transferStats).currentFileName;
|
|
const currentResolve = fileGetResolve;
|
|
const currentReject = fileGetReject;
|
|
|
|
// Direkt hier aufräumen, um Race Conditions bei schnellen Folge-Transfers zu vermeiden
|
|
fileGetResolve = null;
|
|
fileGetReject = null;
|
|
|
|
saveLocalFile(fileName, fileBlob, fileTransfer.totalBytes)
|
|
.then(() => {
|
|
refreshLocal();
|
|
if (currentResolve) {
|
|
currentResolve({ success: true });
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Datenbankfehler:", err);
|
|
addToast(`Speichern von ${fileName} fehlgeschlagen.`, 'error');
|
|
if (currentReject) currentReject(err);
|
|
});
|
|
} else {
|
|
// TAGS Modus: Blob direkt zurückgeben, nichts speichern
|
|
const currentResolve = fileGetResolve;
|
|
fileGetResolve = null;
|
|
fileGetReject = null;
|
|
if (currentResolve) currentResolve({ success: true, blob: fileBlob });
|
|
}
|
|
} else {
|
|
console.error("[CRC] Mismatch! Datei beschädigt.");
|
|
addToast("CRC Fehler: Datei wurde fehlerhaft übertragen.", "error");
|
|
const currentReject = fileGetReject;
|
|
fileGetResolve = null;
|
|
fileGetReject = null;
|
|
if (currentReject) currentReject(new Error("CRC Mismatch"));
|
|
}
|
|
break;
|
|
|
|
case FRAME.ACK:
|
|
if (uploadState.active && payloadLength >= 2) {
|
|
const creditsAdded = view.getUint16(3, true);
|
|
uploadState.credits += creditsAdded;
|
|
if (uploadState.onCreditsAdded) {
|
|
uploadState.onCreditsAdded();
|
|
}
|
|
}
|
|
|
|
case FRAME.SUCCESS:
|
|
if (payloadLength >= 1) {
|
|
const successDataType = view.getUint8(3);
|
|
if (uploadState.active && successDataType === DATA.FILE_PUT || successDataType === DATA.TAGS_PUT) {
|
|
if (uploadState.onSuccess) uploadState.onSuccess();
|
|
}
|
|
}
|
|
break;
|
|
|
|
case FRAME.ERROR:
|
|
const errorCode = view.getUint16(3, true);
|
|
console.error(`Received error frame with code: 0x${errorCode.toString(16)}`);
|
|
showErrorToast(errorCode);
|
|
if (lsReject) {
|
|
const currentReject = lsReject;
|
|
lsResolve = null;
|
|
lsReject = null;
|
|
currentReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
|
|
}
|
|
if (fileGetReject && fileTransfer.active) {
|
|
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
|
|
fileTransfer.active = false;
|
|
const currentReject = fileGetReject;
|
|
fileGetResolve = null;
|
|
fileGetReject = null;
|
|
currentReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
|
|
}
|
|
if (uploadState.active && uploadState.onError) {
|
|
uploadState.onError(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
|
|
}
|
|
break;
|
|
|
|
default:
|
|
console.error(`Unknown frame type received: 0x${frameType.toString(16)}`);
|
|
addToast(`Unbekannter Frame-Typ empfangen: <span class="font-mono">0x${frameType.toString(16)}</span>`, 'error');
|
|
}
|
|
}
|
|
|
|
export function buildProtocolInfoRequest(): ArrayBuffer {
|
|
const buffer = new ArrayBuffer(4);
|
|
const view = new DataView(buffer);
|
|
|
|
view.setUint8(0, FRAME.REQUEST);
|
|
view.setUint16(1, 1, true);
|
|
view.setUint8(3, DATA.PROTO_INFO);
|
|
|
|
return buffer;
|
|
}
|
|
|
|
export function buildDeviceInfoRequest(): ArrayBuffer {
|
|
const buffer = new ArrayBuffer(4);
|
|
const view = new DataView(buffer);
|
|
|
|
view.setUint8(0, FRAME.REQUEST);
|
|
view.setUint16(1, 1, true);
|
|
view.setUint8(3, DATA.DEVICE_INFO);
|
|
|
|
return buffer;
|
|
}
|
|
|
|
export function buildFSInfoRequest(): ArrayBuffer {
|
|
const buffer = new ArrayBuffer(4);
|
|
const view = new DataView(buffer);
|
|
|
|
view.setUint8(0, FRAME.REQUEST);
|
|
view.setUint16(1, 1, true);
|
|
view.setUint8(3, DATA.FS_INFO);
|
|
|
|
return buffer;
|
|
}
|
|
|
|
export function buildFWInfoRequest(): ArrayBuffer {
|
|
const buffer = new ArrayBuffer(4);
|
|
const view = new DataView(buffer);
|
|
|
|
view.setUint8(0, FRAME.REQUEST);
|
|
view.setUint16(1, 1, true);
|
|
view.setUint8(3, DATA.FW_INFO);
|
|
|
|
return buffer;
|
|
}
|
|
|
|
export function buildLSRequest(path: string): ArrayBuffer {
|
|
const encoder = new TextEncoder();
|
|
const pathBytes = encoder.encode(path);
|
|
const buffer = new ArrayBuffer(4 + pathBytes.length);
|
|
const view = new DataView(buffer);
|
|
|
|
view.setUint8(0, FRAME.REQUEST);
|
|
view.setUint16(1, 1 + pathBytes.length, true); // Payload: DataType(1) + String
|
|
view.setUint8(3, DATA.LS);
|
|
|
|
const uint8Buffer = new Uint8Array(buffer);
|
|
uint8Buffer.set(pathBytes, 4);
|
|
|
|
return buffer;
|
|
}
|
|
|
|
async function handleLsStart(send: FrameSender) {
|
|
lsBuffer = [];
|
|
await sendCredits(64, send);
|
|
}
|
|
|
|
async function sendCredits(count: number, send: FrameSender) {
|
|
const buffer = new ArrayBuffer(5);
|
|
const view = new DataView(buffer);
|
|
|
|
view.setUint8(0, FRAME.ACK);
|
|
view.setUint16(1, 2, true);
|
|
view.setUint16(3, count, true);
|
|
|
|
await send(buffer);
|
|
}
|
|
|
|
function resetLsWatchdog() {
|
|
if (lsTimeout) clearTimeout(lsTimeout);
|
|
lsTimeout = setTimeout(() => {
|
|
addToast("Verzeichnis-Streaming abgebrochen (Timeout)", "warning");
|
|
lsBuffer = [];
|
|
if (lsReject) {
|
|
const currentReject = lsReject;
|
|
lsResolve = null;
|
|
lsReject = null;
|
|
currentReject(new Error("Timeout beim Lesen des Verzeichnisses"));
|
|
}
|
|
}, 3000);
|
|
}
|
|
|
|
export function setLsResolver(resolve: (data: any[]) => void, reject: (error: Error) => void) {
|
|
lsResolve = resolve;
|
|
lsReject = reject;
|
|
}
|
|
|
|
const fileTransfer = {
|
|
active: false,
|
|
mode: 'file' as 'file' | 'tags',
|
|
startTime: 0,
|
|
totalBytes: 0,
|
|
receivedBytes: 0,
|
|
lastReceivedBytes: 0,
|
|
stalledSeconds: 0,
|
|
credits: 0,
|
|
metricsTimer: null as ReturnType<typeof setInterval> | null
|
|
};
|
|
|
|
export const uploadState = {
|
|
active: false,
|
|
credits: 0,
|
|
onCreditsAdded: null as (() => void) | null,
|
|
onSuccess: null as (() => void) | null,
|
|
onError: null as ((err: Error) => void) | null,
|
|
};
|
|
|
|
export function setFileGetResolver(
|
|
resolve: (result: { success: boolean, blob?: Blob }) => void,
|
|
reject: (error: Error) => void,
|
|
mode: 'file' | 'tags' = 'file' // Standard ist 'file'
|
|
) {
|
|
fileGetResolve = resolve;
|
|
fileGetReject = reject;
|
|
fileTransfer.mode = mode;
|
|
}
|
|
|
|
export function buildFileGetRequest(path: string): ArrayBuffer {
|
|
const encoder = new TextEncoder();
|
|
const pathBytes = encoder.encode(path);
|
|
const buffer = new ArrayBuffer(4 + pathBytes.length);
|
|
const view = new DataView(buffer);
|
|
|
|
view.setUint8(0, FRAME.REQUEST);
|
|
view.setUint16(1, 1 + pathBytes.length, true);
|
|
view.setUint8(3, DATA.FILE_GET);
|
|
|
|
const uint8Buffer = new Uint8Array(buffer);
|
|
uint8Buffer.set(pathBytes, 4);
|
|
|
|
return buffer;
|
|
}
|
|
|
|
export function buildTagsGetRequest(path: string): ArrayBuffer {
|
|
const encoder = new TextEncoder();
|
|
const pathBytes = encoder.encode(path);
|
|
const buffer = new ArrayBuffer(4 + pathBytes.length);
|
|
const view = new DataView(buffer);
|
|
|
|
view.setUint8(0, FRAME.REQUEST);
|
|
view.setUint16(1, 1 + pathBytes.length, true);
|
|
view.setUint8(3, DATA.TAGS_GET);
|
|
|
|
const uint8Buffer = new Uint8Array(buffer);
|
|
uint8Buffer.set(pathBytes, 4);
|
|
|
|
return buffer;
|
|
} |