vor ble umbau
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
{
|
||||
"files": ["*.svelte", "*.astro"],
|
||||
"options": {
|
||||
"printWidth": 1000
|
||||
"printWidth": 100
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
21
webpage/package-lock.json
generated
21
webpage/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-astro": "^0.14.1"
|
||||
}
|
||||
@@ -4490,6 +4491,26 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/phosphor-svelte": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/phosphor-svelte/-/phosphor-svelte-3.1.0.tgz",
|
||||
"integrity": "sha512-nldtxx+XCgNREvrb7O5xgDsefytXpSkPTx8Rnu3f2qQCUZLDV1rLxYSd2Jcwckuo9lZB1qKMqGR17P4UDC0PrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0 || ^5.0.0-next.96",
|
||||
"vite": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/piccolore": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"phosphor-svelte": "^3.1.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-astro": "^0.14.1"
|
||||
}
|
||||
|
||||
61
webpage/src/components/AppGuard.svelte
Normal file
61
webpage/src/components/AppGuard.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { isInitializing, isBluetoothSupported, isSerialSupported } from "../lib/store";
|
||||
import { performHardwareCheck, getBrowserName } from "../lib/init";
|
||||
import ToastContainer from "./ToastContainer.svelte";
|
||||
import { injectDummyDevices } from "../lib/store";
|
||||
|
||||
let browserName = "";
|
||||
onMount(() => {
|
||||
browserName = getBrowserName();
|
||||
performHardwareCheck();
|
||||
injectDummyDevices(); // Fügt Dummy-Geräte für Testzwecke hinzu
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $isInitializing}
|
||||
<div class="fixed inset-0 bg-slate-50 flex items-center justify-center z-[100]">
|
||||
<p class="text-slate-600 font-mono animate-pulse">SYSTEM_CHECK_RUNNING...</p>
|
||||
</div>
|
||||
{:else if !$isBluetoothSupported && !$isSerialSupported}
|
||||
<div class="min-h-[60vh] flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full bg-white border-2 border-red-600 shadow-2xl rounded-sm p-8 pb-4">
|
||||
<h1 class="text-2xl font-black text-red-600 mb-4 uppercase italic">Inkompatibler Browser</h1>
|
||||
|
||||
<p class="text-slate-800 mb-6">
|
||||
Du nutzt aktuell <strong>{browserName}</strong>
|
||||
. Dieser Browser unterstützt weder Bluetooth noch serielle USB-Verbindungen.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 mb-4 text-sm font-mono">
|
||||
<div class="flex justify-between border-b border-slate-100 pb-1">
|
||||
<span>Web Bluetooth:</span>
|
||||
<span class="text-red-600 font-bold">NICHT UNTERSTÜTZT</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-1">
|
||||
<span>Web Serial:</span>
|
||||
<span class="text-red-600 font-bold">NICHT UNTERSTÜTZT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-500 mb-6 italic">
|
||||
(Info: Firefox und Safari blockieren diese Hardware-Schnittstellen aus Prinzip.)
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="https://www.google.com/chrome/"
|
||||
class="block w-full text-center bg-blue-600 text-white py-3 font-bold hover:bg-blue-700 transition uppercase tracking-widest text-sm mb-4"
|
||||
>
|
||||
Googles Glanzeisen installieren
|
||||
</a>
|
||||
|
||||
<p class="text-xs text-slate-500 mb-6 italic">
|
||||
Gerüchten zufolge soll <b>Winzigweichs Kante</b>
|
||||
-Browser diese Technologien auch unterstützen. Aber wer nutzt schon diese Weichware?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ToastContainer client:load />
|
||||
<slot />
|
||||
{/if}
|
||||
22
webpage/src/components/BLEList.svelte
Normal file
22
webpage/src/components/BLEList.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import BLEListItem from "./BLEListItem.svelte";
|
||||
import { pairedDevices } from "../lib/store";
|
||||
</script>
|
||||
|
||||
<div class="bg-white shadow shadow-slate-300 rounded-lg border border-slate-100 overflow-hidden">
|
||||
<div class="p-6 pb-4 border-b border-slate-100">
|
||||
<h2 class="text-xl font-bold text-slate-800">Verfügbare Geräte</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
{#if $pairedDevices.length > 0}
|
||||
{#each $pairedDevices as device (device.id)}
|
||||
<BLEListItem {device} />
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="px-6 py-8 text-center text-slate-500 text-sm">
|
||||
Keine gepairten Geräte gefunden. Bitte pairen Sie zunächst ein Gerät.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
55
webpage/src/components/BLEListItem.svelte
Normal file
55
webpage/src/components/BLEListItem.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { BluetoothIcon, BluetoothSlashIcon, BluetoothXIcon } from "phosphor-svelte";
|
||||
import { connectBuzzer, forgetDevice } from "../lib/bluetooth";
|
||||
import { availableDevices, activeDeviceId, isConnected, targetDeviceId } from "../lib/store"; // targetDeviceId importiert
|
||||
|
||||
export let device: BluetoothDevice;
|
||||
|
||||
$: isAvailable = $availableDevices.has(device.id);
|
||||
$: isActive = $activeDeviceId === device.id;
|
||||
|
||||
$: isTarget = $targetDeviceId === device.id;
|
||||
$: showBlueBorder = isActive || (isTarget && !$isConnected);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center border-b border-slate-200/50 last:border-b-0 transition-colors border-l-4
|
||||
{showBlueBorder ? 'border-l-blue-600' : 'border-l-transparent'}
|
||||
{isActive ? 'bg-blue-100' : isAvailable ? 'bg-blue-50 hover:bg-blue-100' : 'bg-white hover:bg-slate-50'}"
|
||||
>
|
||||
<div
|
||||
class="flex-1 px-6 py-3 flex items-center cursor-pointer {isAvailable || isActive ? 'opacity-100' : 'opacity-50'}"
|
||||
on:click={() => { if (isAvailable && !isActive) connectBuzzer(device); }}
|
||||
>
|
||||
{#if isAvailable || isActive}
|
||||
<BluetoothIcon class="text-blue-600 mr-3 w-5 h-5" />
|
||||
{:else}
|
||||
<BluetoothSlashIcon class="text-slate-400 mr-3 w-5 h-5" />
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="font-medium {isActive
|
||||
? 'text-blue-900'
|
||||
: isAvailable
|
||||
? 'text-blue-800'
|
||||
: 'text-slate-500'}"
|
||||
>
|
||||
{device.name || "Unbekanntes Gerät"}
|
||||
</span>
|
||||
{#if isActive}
|
||||
<span class="text-xs text-blue-700 font-semibold">Verbunden</span>
|
||||
{:else if !isAvailable}
|
||||
<span class="text-xs text-slate-400">Nicht in Reichweite</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="px-4 h-full flex items-center text-slate-300 hover:text-red-500 transition-colors"
|
||||
on:click|stopPropagation={() => forgetDevice(device)}
|
||||
title="Gerät entkoppeln"
|
||||
>
|
||||
<BluetoothXIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
137
webpage/src/components/BuzzerControl.svelte
Normal file
137
webpage/src/components/BuzzerControl.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { pairBuzzer, connectBuzzer, disconnectBuzzer, restoreSession } from "../lib/bluetooth";
|
||||
import {
|
||||
isConnected,
|
||||
isPaired,
|
||||
isConnecting,
|
||||
protocolInfo,
|
||||
fsInfo,
|
||||
loadConnectionState,
|
||||
availableDevices,
|
||||
} from "../lib/store";
|
||||
import { refreshRemote } from "../lib/sync";
|
||||
import { fetchFileThroughputTest } from "../lib/transport";
|
||||
|
||||
onMount(() => {
|
||||
restoreSession();
|
||||
});
|
||||
|
||||
// Automatischer Datenabruf, sobald der Transport-Layer isConnected auf true setzt
|
||||
$: if ($isConnected) {
|
||||
refreshRemote();
|
||||
}
|
||||
|
||||
$: lastDeviceId = loadConnectionState()?.deviceId;
|
||||
$: canQuickConnect = lastDeviceId ? $availableDevices.has(lastDeviceId) : false;
|
||||
</script>
|
||||
|
||||
<div class="bg-white shadow shadow-slate-300 rounded-lg p-6 border border-slate-100">
|
||||
<h2 class="text-xl font-bold mb-4 text-slate-800">Geräte-Status</h2>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 font-semibold mb-5 {$isConnected
|
||||
? 'text-green-600'
|
||||
: 'text-slate-500'}"
|
||||
>
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full {$isConnected
|
||||
? 'bg-green-500 animate-pulse'
|
||||
: 'bg-slate-400'}"
|
||||
></div>
|
||||
{$isConnected ? "Bluetooth Verbunden" : "Bluetooth Getrennt"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-slate-50 border border-slate-200 p-4 rounded text-sm font-mono text-slate-600 mb-4 flex flex-col justify-center"
|
||||
>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span>Protokoll Version:</span>
|
||||
<span class="font-semibold text-slate-800">
|
||||
{$protocolInfo ? "v" + $protocolInfo.version : "unbekannt"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Max Chunk Size:</span>
|
||||
<span class="font-semibold text-slate-800">
|
||||
{$protocolInfo ? $protocolInfo.maxChunkSize + " B" : "unbekannt"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Flash-Grösse:</span>
|
||||
<span class="font-semibold text-slate-800">
|
||||
{$fsInfo ? $fsInfo.totalSize.toFixed(2) + " MB" : "unbekannt"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Freier Speicher:</span>
|
||||
<span class="font-semibold text-slate-800">
|
||||
{$fsInfo ? $fsInfo.freeSize.toFixed(2) + " MB" : "unbekannt"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Max. Pfadlänge:</span>
|
||||
<span class="font-semibold text-slate-800">
|
||||
{$fsInfo ? $fsInfo.maxPathLength : "unbekannt"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Systempfad:</span>
|
||||
<span class="font-semibold text-slate-800">
|
||||
{$fsInfo ? $fsInfo.sysPath : "unbekannt"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Audiopfad:</span>
|
||||
<span class="font-semibold text-slate-800">
|
||||
{$fsInfo ? $fsInfo.audioPath : "unbekannt"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-4">
|
||||
{#if !$isConnected}
|
||||
<button
|
||||
class="bg-blue-600 text-white px-4 py-2.5 rounded shadow-sm hover:bg-blue-700 hover:shadow transition text-sm font-medium flex-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
on:click={() => pairBuzzer()}
|
||||
disabled={$isConnecting}
|
||||
>
|
||||
Pairen
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-4 py-2.5 rounded transition text-sm font-medium flex-1
|
||||
{$isPaired && canQuickConnect
|
||||
? 'bg-emerald-600 text-white shadow-sm hover:bg-emerald-700 hover:shadow'
|
||||
: 'bg-slate-50 text-slate-400 cursor-not-allowed opacity-70'}
|
||||
{$isConnecting ? 'animate-pulse' : ''}"
|
||||
on:click={() => connectBuzzer()}
|
||||
disabled={!$isPaired || !canQuickConnect || $isConnecting}
|
||||
>
|
||||
{$isConnecting ? "Verbinde..." : "Verbinden"}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="bg-slate-100 text-slate-700 px-4 py-2.5 rounded hover:bg-slate-200 transition text-sm font-medium flex-1"
|
||||
on:click={() => refreshRemote()}
|
||||
>
|
||||
Werte neu laden
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="bg-red-600 text-white px-4 py-2.5 rounded shadow-sm hover:bg-red-700 hover:shadow transition text-sm font-medium flex-1"
|
||||
on:click={() => disconnectBuzzer()}
|
||||
>
|
||||
Trennen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="mt-4 w-full text-left text-xs text-slate-500 hover:text-slate-700 transition"
|
||||
on:click={() => {
|
||||
fetchFileThroughputTest("/lfs/a/countdown");
|
||||
}}
|
||||
>
|
||||
Durchsatztest mit /lfs/a/countdown
|
||||
</button>
|
||||
</div>
|
||||
27
webpage/src/components/FileTransfer.svelte
Normal file
27
webpage/src/components/FileTransfer.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { storageUsage } from "../lib/store";
|
||||
</script>
|
||||
|
||||
<div class="mt-4 w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner">
|
||||
<div
|
||||
class="h-full bg-slate-400 transition-all duration-500"
|
||||
style="width: {$storageUsage?.systemPercent ?? 0}%"
|
||||
></div>
|
||||
|
||||
<div class="mt-1 text-xs text-slate-400 flex justify-between uppercase tracking-tighter font-medium">
|
||||
{#if $storageUsage}
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<span class="font-semibold text-slate-400">
|
||||
Rate: {($storageUsage.systemBytes / 1048576).toFixed(2)} MB
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-semibold text-slate-400">
|
||||
{($storageUsage.freeBytes / 1048576).toFixed(2)} Sekunden
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-slate-400">Kein Transfer aktiv</div>
|
||||
{/if}
|
||||
</div>
|
||||
42
webpage/src/components/FlashUsage.svelte
Normal file
42
webpage/src/components/FlashUsage.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { storageUsage } from "../lib/store";
|
||||
</script>
|
||||
|
||||
<div class="mt-4 w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner">
|
||||
<div
|
||||
class="h-full bg-slate-400 transition-all duration-500"
|
||||
style="width: {$storageUsage?.systemPercent ?? 0}%"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="h-full bg-indigo-500 transition-all duration-500"
|
||||
style="width: {$storageUsage?.audioPercent ?? 0}%"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="h-full bg-emerald-500 transition-all duration-500"
|
||||
style="width: {$storageUsage?.freePercent ?? 0}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-xs text-slate-400 flex justify-between uppercase tracking-tighter font-medium">
|
||||
{#if $storageUsage}
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<span class="font-semibold text-slate-400">System:
|
||||
{($storageUsage.systemBytes / 1048576).toFixed(2)} MB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-semibold text-indigo-500">Audio:
|
||||
{($storageUsage.audioBytes / 1048576).toFixed(2)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="font-semibold text-emerald-500">Frei:
|
||||
{($storageUsage.freeBytes / 1048576).toFixed(2)} MB</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-slate-400">Speicherdaten nicht verfügbar</div>
|
||||
{/if}
|
||||
</div>
|
||||
33
webpage/src/components/ToastContainer.svelte
Normal file
33
webpage/src/components/ToastContainer.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { toasts, removeToast } from "../lib/toast";
|
||||
import { XIcon } from "phosphor-svelte";
|
||||
import { fly } from "svelte/transition"; // Import für DOM-Animationen hinzugefügt
|
||||
|
||||
// Debug-Ausgabe: Zeigt in der Konsole an, sobald ein Toast getriggert wird
|
||||
$: console.debug("Aktuelle Toasts im Store:", $toasts);
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-20 right-6 z-[9999] flex flex-col gap-3 pointer-events-none">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div
|
||||
in:fly={{ y: 20, duration: 300 }}
|
||||
out:fly={{ y: -20, duration: 300 }}
|
||||
class="pointer-events-auto flex items-center justify-between px-5 py-3 rounded-lg border-l-4 shadow-xl min-w-[280px]
|
||||
{toast.type === 'success' ? 'bg-green-100/50 border-green-500 text-green-800' : ''}
|
||||
{toast.type === 'info' ? 'bg-blue-100/50 border-blue-500 text-blue-800' : ''}
|
||||
{toast.type === 'warning' ? 'bg-amber-100/50 border-amber-500 text-amber-800' : ''}
|
||||
{toast.type === 'error' ? 'bg-red-100/50 border-red-500 text-red-800' : ''}
|
||||
"
|
||||
>
|
||||
<span class="text-sm font-medium">{@html toast.message}</span>
|
||||
{#if toast.dismissible}
|
||||
<button
|
||||
on:click={() => removeToast(toast.id)}
|
||||
class="ml-4 opacity-50 hover:opacity-100 transition cursor-pointer"
|
||||
>
|
||||
<XIcon class="w-5 h-5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
247
webpage/src/lib/bluetooth.ts
Normal file
247
webpage/src/lib/bluetooth.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { injectDummyDevices, isConnected, isPaired, isConnecting, pairedDevices, availableDevices, activeDeviceId, saveConnectionState, loadConnectionState, targetDeviceId, resetLocal, resetRemote } from './store';
|
||||
import { BLE } from './protocol/constants';
|
||||
import { parseIncomingFrame } from './protocol';
|
||||
import { registerTransport, handleTransportDisconnect, handleTransportConnect } from './transport';
|
||||
import { addToast, clearAllToasts } from './toast';
|
||||
import { SETTINGS } from './settings';
|
||||
|
||||
let rxCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
|
||||
let txCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
|
||||
let device: BluetoothDevice | null = null;
|
||||
|
||||
export async function restoreSession() {
|
||||
try {
|
||||
const devices = await getPairedDevices();
|
||||
if (devices.length > 0) {
|
||||
isPaired.set(true);
|
||||
startScanningAdvertisements(devices);
|
||||
|
||||
const savedState = loadConnectionState();
|
||||
if (savedState && savedState.autoConnect && savedState.transport === 'ble') {
|
||||
const targetDev = devices.find(d => d.id === savedState.deviceId);
|
||||
if (targetDev) {
|
||||
addToast("Versuche automatische Wiederverbindung...", "info");
|
||||
await connectBuzzer(targetDev);
|
||||
}
|
||||
} else if (savedState) {
|
||||
targetDeviceId.set(savedState.deviceId);
|
||||
device = devices.find(d => d.id === savedState.deviceId) || devices[0];
|
||||
} else {
|
||||
device = devices[0];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Session-Wiederherstellung fehlgeschlagen:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function startScanningAdvertisements(devices: BluetoothDevice[]) {
|
||||
for (const dev of devices) {
|
||||
// Sicherheits-Check für Mock-Objekte
|
||||
if (typeof dev.addEventListener !== 'function') continue;
|
||||
|
||||
dev.addEventListener('advertisementreceived', () => {
|
||||
availableDevices.update(set => {
|
||||
const newSet = new Set(set);
|
||||
newSet.add(dev.id);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
// Auch hier vorher prüfen
|
||||
if (typeof dev.watchAdvertisements === 'function') {
|
||||
await dev.watchAdvertisements();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Scanning für Gerät nicht möglich:", dev.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function pairBuzzer() {
|
||||
try {
|
||||
const newDevice = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ services: [BLE.SERVICE_UUID] }]
|
||||
});
|
||||
|
||||
isPaired.set(true);
|
||||
|
||||
const devices = await getPairedDevices();
|
||||
startScanningAdvertisements(devices);
|
||||
|
||||
await connectBuzzer(newDevice);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Pairing abgebrochen oder fehlgeschlagen", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectBuzzer(targetDevice?: BluetoothDevice | Event) {
|
||||
console.debug("connectBuzzer aufgerufen");
|
||||
if (targetDevice instanceof Event) {
|
||||
targetDevice = undefined;
|
||||
}
|
||||
|
||||
if (get(isConnecting)) return;
|
||||
|
||||
if (targetDevice) {
|
||||
if (device && device.gatt?.connected && device.id !== (targetDevice as BluetoothDevice).id) {
|
||||
device.gatt.disconnect();
|
||||
}
|
||||
device = targetDevice as BluetoothDevice;
|
||||
}
|
||||
|
||||
clearAllToasts();
|
||||
|
||||
if (!device) return;
|
||||
|
||||
device.removeEventListener('gattserverdisconnected', handleDisconnect);
|
||||
device.addEventListener('gattserverdisconnected', handleDisconnect);
|
||||
|
||||
isConnecting.set(true);
|
||||
console.debug("connectBuzzer: Verbindungsversuch mit", device.name);
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
isConnecting.set(false);
|
||||
addToast("Verbindungsaufbau fehlgeschlagen (Timeout).", "error", true);
|
||||
handleTransportDisconnect();
|
||||
}, SETTINGS.bluetooth.connectionTimeoutMs);
|
||||
|
||||
try {
|
||||
const server = await device.gatt?.connect();
|
||||
if (!server) throw new Error("GATT Server nicht gefunden");
|
||||
|
||||
const service = await server.getPrimaryService(BLE.SERVICE_UUID);
|
||||
rxCharacteristic = await service.getCharacteristic(BLE.RX_UUID);
|
||||
txCharacteristic = await service.getCharacteristic(BLE.TX_UUID);
|
||||
|
||||
await txCharacteristic.startNotifications();
|
||||
txCharacteristic.addEventListener('characteristicvaluechanged', handleIncomingData);
|
||||
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
// Hardware-Setup ist fertig -> Übergabe an den Transport-Layer
|
||||
activeDeviceId.set(device.id);
|
||||
saveConnectionState({ transport: 'ble', deviceId: device.id, autoConnect: true });
|
||||
targetDeviceId.set(device.id);
|
||||
|
||||
addToast(`Verbunden mit <b>${device.name}</b>`, "success");
|
||||
|
||||
// Führt die Requests aus und setzt $isConnected = true
|
||||
await handleTransportConnect(sendBleFrame);
|
||||
|
||||
} catch (error) {
|
||||
clearTimeout(connectionTimeout);
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error("Bluetooth Fehler während connectBuzzer:", errMsg);
|
||||
|
||||
if (errMsg.includes("no longer in range")) {
|
||||
addToast("Geräte-Referenz veraltet (Out of Range). Bitte über den 'Pairen'-Button neu autorisieren.", "error", true);
|
||||
|
||||
if (device) {
|
||||
const deadId = device.id;
|
||||
pairedDevices.update(devices => devices.filter(d => d.id !== deadId));
|
||||
availableDevices.update(set => {
|
||||
const newSet = new Set(set);
|
||||
newSet.delete(deadId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
addToast("Verbindungsfehler: " + errMsg, "error");
|
||||
}
|
||||
|
||||
handleTransportDisconnect();
|
||||
} finally {
|
||||
isConnecting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectBuzzer() {
|
||||
console.debug("disconnectBuzer aufgerufen");
|
||||
|
||||
if (device) {
|
||||
saveConnectionState({
|
||||
transport: 'ble',
|
||||
deviceId: device.id,
|
||||
autoConnect: false
|
||||
});
|
||||
|
||||
isConnected.set(false);
|
||||
|
||||
if (device.gatt?.connected) {
|
||||
device.gatt.disconnect();
|
||||
}
|
||||
}
|
||||
resetRemote();
|
||||
addToast("Verbindung mit <b>" + device.name + "</b> getrennt", "info");
|
||||
}
|
||||
|
||||
export async function forgetDevice(targetDevice: BluetoothDevice) {
|
||||
console.debug("forgetDevice aufgerufen");
|
||||
try {
|
||||
if (targetDevice.gatt?.connected) {
|
||||
targetDevice.gatt.disconnect();
|
||||
}
|
||||
await targetDevice.forget();
|
||||
addToast(`Gerät ${targetDevice.name} vergessen`, "success");
|
||||
|
||||
const devices = await getPairedDevices();
|
||||
if (devices.length === 0) {
|
||||
isPaired.set(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Löschen des Geräts:", error);
|
||||
addToast("Konnte Gerät nicht entfernen", "error", true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPairedDevices() {
|
||||
let rawDevices: BluetoothDevice[] = [];
|
||||
|
||||
// 1. Physische Geräte abrufen, falls die API verfügbar ist
|
||||
if ('bluetooth' in navigator && 'getDevices' in navigator.bluetooth) {
|
||||
try {
|
||||
rawDevices = await navigator.bluetooth.getDevices();
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Abrufen der gekoppelten Geräte:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Physische Geräte in den Store schreiben
|
||||
pairedDevices.set(rawDevices);
|
||||
|
||||
// 3. Testdaten anfügen
|
||||
injectDummyDevices();
|
||||
|
||||
// 4. Den aktualisierten Store-Inhalt (inkl. Dummies) für die weiterverarbeitenden Funktionen zurückgeben
|
||||
return get(pairedDevices);
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
console.debug("handleDisconnect aufgerufen");
|
||||
|
||||
if (get(isConnected)) {
|
||||
addToast("Verbindung zu Buzzer verloren", "warning");
|
||||
}
|
||||
|
||||
resetRemote();
|
||||
registerTransport(null);
|
||||
rxCharacteristic = null;
|
||||
txCharacteristic = null;
|
||||
}
|
||||
|
||||
function handleIncomingData(event: Event) {
|
||||
const target = event.target as BluetoothRemoteGATTCharacteristic;
|
||||
if (target.value) {
|
||||
parseIncomingFrame(target.value, (buffer) => sendBleFrame(buffer));
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendBleFrame(buffer: ArrayBuffer) {
|
||||
// TODO: MTU Check einfügen!
|
||||
if (!rxCharacteristic) return;
|
||||
await rxCharacteristic.writeValueWithoutResponse(buffer);
|
||||
}
|
||||
24
webpage/src/lib/init.ts
Normal file
24
webpage/src/lib/init.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/lib/init.ts
|
||||
import { isBluetoothSupported, isSerialSupported, isInitializing } from './store';
|
||||
|
||||
export function getBrowserName(): string {
|
||||
const ua = navigator.userAgent;
|
||||
if (ua.includes("Firefox")) return "Feuerfuchs";
|
||||
if (ua.includes("Safari") && !ua.includes("Chrome")) return "Apfel-Rundreise";
|
||||
if (ua.includes("Edg")) return "Winzigweich Kante";
|
||||
if (ua.includes("Chrome")) return "Google Glanzeisen";
|
||||
return "dein Browser";
|
||||
}
|
||||
|
||||
export function performHardwareCheck() {
|
||||
if (typeof navigator === 'undefined') return;
|
||||
|
||||
// Web Bluetooth Check
|
||||
const hasBT = 'bluetooth' in navigator;
|
||||
// Web Serial Check
|
||||
const hasSerial = 'serial' in navigator;
|
||||
|
||||
isBluetoothSupported.set(hasBT);
|
||||
isSerialSupported.set(hasSerial);
|
||||
isInitializing.set(false);
|
||||
}
|
||||
56
webpage/src/lib/protocol/constants.ts
Normal file
56
webpage/src/lib/protocol/constants.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export const BLE = {
|
||||
SERVICE_UUID: "e517d988-bab5-4574-8479-97c6cb115ca0",
|
||||
RX_UUID: "e517d988-bab5-4574-8479-97c6cb115ca1",
|
||||
TX_UUID: "e517d988-bab5-4574-8479-97c6cb115ca2"
|
||||
};
|
||||
|
||||
export const FRAME = {
|
||||
REQUEST: 0x00,
|
||||
|
||||
RESPONSE: 0x10,
|
||||
ACK: 0x11,
|
||||
ERROR: 0x12,
|
||||
|
||||
FILE_START: 0x20,
|
||||
FILE_CHUNK: 0x21,
|
||||
FILE_END: 0x22,
|
||||
|
||||
LS_START: 0x40,
|
||||
LS_ENTRY: 0x41,
|
||||
LS_END: 0x42,
|
||||
};
|
||||
|
||||
export const DATA = {
|
||||
PROTO_INFO: 0x01,
|
||||
FS_INFO: 0x03,
|
||||
|
||||
FILE_GET: 0x20,
|
||||
FILE_PUT: 0x21,
|
||||
|
||||
|
||||
LS: 0x40
|
||||
};
|
||||
|
||||
export const FS_ENTRY_TYPE = {
|
||||
FILE: 0x00,
|
||||
DIR: 0x01,
|
||||
}
|
||||
|
||||
export interface ZephyrError {
|
||||
text: string;
|
||||
zephyr: string;
|
||||
}
|
||||
|
||||
export const ZEPHYR_ERRORS: Record<number, ZephyrError> = {
|
||||
1: { text: "Fehlende Berechtigung", zephyr: "EPERM" },
|
||||
2: { text: "Datei oder Verzeichnis nicht gefunden", zephyr: "ENOENT" },
|
||||
5: { text: "Ein-/Ausgabefehler auf dem Flash", zephyr: "EIO" },
|
||||
12: { text: "Nicht genügend Speicher frei", zephyr: "ENOMEM" },
|
||||
16: { text: "Gerät oder Ressource belegt", zephyr: "EBUSY" },
|
||||
22: { text: "Ungültiges Argument oder Parameter", zephyr: "EINVAL" },
|
||||
24: { text: "Zu viele offene Dateien", zephyr: "EMFILE" },
|
||||
28: { text: "Kein freier Speicherplatz mehr", zephyr: "ENOSPC" },
|
||||
36: { text: "Dateiname oder Pfad zu lang", zephyr: "ENAMETOOLONG" },
|
||||
88: { text: "Funktion im Buzzer nicht implementiert", zephyr: "ENOSYS" },
|
||||
134: { text: "Operation nicht unterstützt", zephyr: "ENOTSUP" }
|
||||
};
|
||||
2
webpage/src/lib/protocol/index.ts
Normal file
2
webpage/src/lib/protocol/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './constants';
|
||||
export * from './parser';
|
||||
309
webpage/src/lib/protocol/parser.ts
Normal file
309
webpage/src/lib/protocol/parser.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { FRAME, DATA, ZEPHYR_ERRORS } from './constants';
|
||||
import { protocolInfo, fsInfo } from '../store';
|
||||
import { addToast } from '../toast';
|
||||
|
||||
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
|
||||
|
||||
let lsBuffer: any[] = [];
|
||||
let lsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let lsResolve: ((data: any[]) => void) | null = null;
|
||||
let lsReject: ((error: Error) => void) | null = null;
|
||||
let fileGetResolve: ((success: boolean) => 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);
|
||||
|
||||
if (dataType === DATA.PROTO_INFO && payloadLength >= 5) {
|
||||
const version = view.getUint16(4, true);
|
||||
const maxChunkSize = view.getUint16(6, true);
|
||||
protocolInfo.set({ version, maxChunkSize });
|
||||
} else if (dataType === DATA.FS_INFO && payloadLength >= 14) {
|
||||
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 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);
|
||||
console.debug(`LS Stream beendet. Erwartete Einträge: ${total}, empfangen: ${lsBuffer.length}`, lsBuffer);
|
||||
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) {
|
||||
lsResolve([...lsBuffer]);
|
||||
lsResolve = null;
|
||||
lsReject = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case FRAME.FILE_START:
|
||||
fileTransfer.totalBytes = view.getUint32(3, true);
|
||||
fileTransfer.receivedBytes = 0;
|
||||
fileTransfer.lastReceivedBytes = 0;
|
||||
fileTransfer.stalledSeconds = 0;
|
||||
fileTransfer.active = true;
|
||||
fileTransfer.startTime = performance.now();
|
||||
|
||||
console.log(`[FILE_GET] Stream gestartet. Erwartete Größe: ${fileTransfer.totalBytes} Bytes.`);
|
||||
|
||||
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;
|
||||
|
||||
// Hier optional einen Toast anzeigen lassen, falls importiert:
|
||||
// addToast("Dateitransfer abgebrochen (Timeout)", "error");
|
||||
|
||||
if (fileGetReject) {
|
||||
fileGetReject(new Error("Timeout beim Dateitransfer"));
|
||||
fileGetResolve = null;
|
||||
fileGetReject = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Daten fließen -> Watchdog zurücksetzen
|
||||
fileTransfer.stalledSeconds = 0;
|
||||
fileTransfer.lastReceivedBytes = fileTransfer.receivedBytes;
|
||||
}
|
||||
|
||||
const elapsedSec = (performance.now() - fileTransfer.startTime) / 1000;
|
||||
const speedKB = (fileTransfer.receivedBytes / 1024) / elapsedSec;
|
||||
const percent = fileTransfer.totalBytes > 0
|
||||
? ((fileTransfer.receivedBytes / fileTransfer.totalBytes) * 100).toFixed(1)
|
||||
: "0.0";
|
||||
|
||||
console.log(`[FILE_GET] Fortschritt: ${percent}% | Speed: ${speedKB.toFixed(2)} KB/s`);
|
||||
}, 1000);
|
||||
|
||||
// Initiale Credits (z.B. 64)
|
||||
fileTransfer.credits = 128;
|
||||
sendCredits(fileTransfer.credits, sender);
|
||||
break;
|
||||
|
||||
case FRAME.FILE_CHUNK:
|
||||
if (!fileTransfer.active) break;
|
||||
|
||||
fileTransfer.receivedBytes += payloadLength;
|
||||
fileTransfer.credits--;
|
||||
|
||||
// Nachladen, sobald die Credits auf 32 fallen (Dein Vorschlag)
|
||||
if (fileTransfer.credits <= 64) {
|
||||
fileTransfer.credits = 128;
|
||||
sendCredits(fileTransfer.credits, sender);
|
||||
|
||||
}
|
||||
break;
|
||||
|
||||
case FRAME.FILE_END:
|
||||
if (fileTransfer.metricsTimer) {
|
||||
clearInterval(fileTransfer.metricsTimer);
|
||||
fileTransfer.metricsTimer = null;
|
||||
}
|
||||
fileTransfer.active = false;
|
||||
|
||||
const crc32 = view.getUint32(3, true);
|
||||
const totalElapsed = (performance.now() - fileTransfer.startTime) / 1000;
|
||||
const avgSpeed = (fileTransfer.receivedBytes / 1024) / totalElapsed;
|
||||
|
||||
console.log(`[FILE_GET] Stream beendet.`);
|
||||
console.log(`[FILE_GET] Empfangen: ${fileTransfer.receivedBytes} Bytes in ${totalElapsed.toFixed(2)}s.`);
|
||||
console.log(`[FILE_GET] Durchschnitt: ${avgSpeed.toFixed(2)} KB/s`);
|
||||
console.log(`[FILE_GET] Zephyr CRC32: 0x${crc32.toString(16).toUpperCase().padStart(8, '0')}`);
|
||||
|
||||
if (fileGetResolve) {
|
||||
fileGetResolve(true);
|
||||
fileGetResolve = null;
|
||||
fileGetReject = null;
|
||||
}
|
||||
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) {
|
||||
lsReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
|
||||
lsResolve = null;
|
||||
lsReject = null;
|
||||
}
|
||||
if (fileGetReject && fileTransfer.active) {
|
||||
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
|
||||
fileTransfer.active = false;
|
||||
fileGetReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
|
||||
fileGetResolve = null;
|
||||
fileGetReject = null;
|
||||
}
|
||||
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 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 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);
|
||||
|
||||
console.debug(`Sende ${count} Credits für Stream...`);
|
||||
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) {
|
||||
lsReject(new Error("Timeout beim Lesen des Verzeichnisses"));
|
||||
lsResolve = null;
|
||||
lsReject = null;
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function setLsResolver(resolve: (data: any[]) => void, reject: (error: Error) => void) {
|
||||
lsResolve = resolve;
|
||||
lsReject = reject;
|
||||
}
|
||||
|
||||
const fileTransfer = {
|
||||
active: false,
|
||||
startTime: 0,
|
||||
totalBytes: 0,
|
||||
receivedBytes: 0,
|
||||
lastReceivedBytes: 0, // NEU: Für die Timeout-Berechnung
|
||||
stalledSeconds: 0, // NEU: Zähler für Stillstand
|
||||
credits: 0,
|
||||
metricsTimer: null as ReturnType<typeof setInterval> | null
|
||||
};
|
||||
|
||||
export function setFileGetResolver(resolve: (success: boolean) => void, reject: (error: Error) => void) {
|
||||
fileGetResolve = resolve;
|
||||
fileGetReject = reject;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
11
webpage/src/lib/settings.ts
Normal file
11
webpage/src/lib/settings.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const SETTINGS = {
|
||||
storage: {
|
||||
connectionKey: 'buzzer_connection_state'
|
||||
},
|
||||
bluetooth: {
|
||||
connectionTimeoutMs: 10000 // Timeout für den Verbindungsaufbau
|
||||
},
|
||||
ui: {
|
||||
toastDurationMs: 5000
|
||||
}
|
||||
};
|
||||
182
webpage/src/lib/store.ts
Normal file
182
webpage/src/lib/store.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { BuzzerFile } from './types';
|
||||
|
||||
// Fallback-Typ fuer Build-Umgebungen ohne DOM-Library.
|
||||
interface BluetoothDevice {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
gatt?: {
|
||||
connected?: boolean;
|
||||
disconnect?: () => void;
|
||||
};
|
||||
forget?: () => Promise<void>;
|
||||
addEventListener?: (...args: unknown[]) => void;
|
||||
removeEventListener?: (...args: unknown[]) => void;
|
||||
watchAdvertisements?: () => Promise<void>;
|
||||
}
|
||||
|
||||
type TransportType = 'ble' | 'serial';
|
||||
|
||||
export interface ConnectionState {
|
||||
transport: TransportType;
|
||||
deviceId: string;
|
||||
autoConnect: boolean;
|
||||
}
|
||||
|
||||
export interface ProtocolInfo {
|
||||
version: number;
|
||||
maxChunkSize: number;
|
||||
}
|
||||
|
||||
export interface FsInfo {
|
||||
totalSize: number;
|
||||
freeSize: number;
|
||||
maxPathLength: number;
|
||||
sysPath: string;
|
||||
audioPath: string;
|
||||
}
|
||||
|
||||
export interface StorageUsage {
|
||||
totalBytes: number;
|
||||
freeBytes: number;
|
||||
audioBytes: number;
|
||||
systemBytes: number;
|
||||
audioPercent: number;
|
||||
systemPercent: number;
|
||||
freePercent: number;
|
||||
}
|
||||
|
||||
const CONNECTION_STATE_KEY = 'buzzer_connection_state';
|
||||
|
||||
// App-Status: Initialisierung und Feature-Support
|
||||
export const isInitializing = writable<boolean>(true);
|
||||
export const isBluetoothSupported = writable<boolean | null>(null);
|
||||
export const isSerialSupported = writable<boolean | null>(null);
|
||||
|
||||
// Verbindungszustand
|
||||
export const isConnected = writable<boolean>(false);
|
||||
export const isConnecting = writable<boolean>(false);
|
||||
export const isPaired = writable<boolean>(false);
|
||||
export const activeDeviceId = writable<string | null>(null);
|
||||
export const targetDeviceId = writable<string | null>(null);
|
||||
|
||||
// Geräteverwaltung (Pairing + Discovery)
|
||||
export const pairedDevices = writable<BluetoothDevice[]>([]);
|
||||
export const availableDevices = writable<Set<string>>(new Set()); // IDs der derzeit advertisierten Geräte
|
||||
|
||||
// Protokoll- und Dateisystem-Metadaten aus dem Device
|
||||
export const protocolInfo = writable<ProtocolInfo | null>(null);
|
||||
export const fsInfo = writable<FsInfo | null>(null);
|
||||
|
||||
// Dateilisten
|
||||
export const buzzerAudioFiles = writable<BuzzerFile[]>([]);
|
||||
export const buzzerSysFiles = writable<BuzzerFile[]>([]);
|
||||
export const localAudioFiles = writable<BuzzerFile[]>([]);
|
||||
|
||||
// Ladezustände getrennt nach Quelle
|
||||
export const isFetchingRemote = writable<boolean>(false);
|
||||
export const isFetchingLocal = writable<boolean>(false);
|
||||
|
||||
// Persistenz des letzten Verbindungsziels (nur im Browser nutzbar)
|
||||
export function saveConnectionState(state: ConnectionState): void {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(CONNECTION_STATE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function loadConnectionState(): ConnectionState | null {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = localStorage.getItem(CONNECTION_STATE_KEY);
|
||||
return data ? (JSON.parse(data) as ConnectionState) : null;
|
||||
}
|
||||
|
||||
// Abgeleitete Berechnung für die Speicherbalken in der UI
|
||||
export const storageUsage = derived(
|
||||
[fsInfo, buzzerAudioFiles],
|
||||
([$fsInfo, $buzzerAudioFiles]): StorageUsage | null => {
|
||||
if (!$fsInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalBytes = $fsInfo.totalSize * 1024 * 1024;
|
||||
const freeBytes = $fsInfo.freeSize * 1024 * 1024;
|
||||
const audioBytes = $buzzerAudioFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
const systemBytes = Math.max(0, totalBytes - freeBytes - audioBytes);
|
||||
|
||||
return {
|
||||
totalBytes,
|
||||
freeBytes,
|
||||
audioBytes,
|
||||
systemBytes,
|
||||
audioPercent: totalBytes === 0 ? 0 : (audioBytes / totalBytes) * 100,
|
||||
systemPercent: totalBytes === 0 ? 0 : (systemBytes / totalBytes) * 100,
|
||||
freePercent: totalBytes === 0 ? 0 : (freeBytes / totalBytes) * 100,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Nur für Entwicklungszwecke: lokale Dummy-Geräte für UI-Tests
|
||||
export function injectDummyDevices(): void {
|
||||
const dummy1 = {
|
||||
id: 'dummy-1',
|
||||
name: 'Dev Buzzer (Erreichbar)',
|
||||
forget: async () => {
|
||||
console.log('Forget dummy-1');
|
||||
},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
watchAdvertisements: async () => {},
|
||||
gatt: { connected: false, disconnect: () => {} },
|
||||
} as unknown as BluetoothDevice;
|
||||
|
||||
const dummy2 = {
|
||||
id: 'dummy-2',
|
||||
name: 'Dev Buzzer (Offline)',
|
||||
forget: async () => {
|
||||
console.log('Forget dummy-2');
|
||||
},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
watchAdvertisements: async () => {},
|
||||
gatt: { connected: false, disconnect: () => {} },
|
||||
} as unknown as BluetoothDevice;
|
||||
|
||||
pairedDevices.update((devices) => {
|
||||
if (!devices.find((d) => d.id === 'dummy-1')) {
|
||||
return [...devices, dummy1, dummy2];
|
||||
}
|
||||
return devices;
|
||||
});
|
||||
|
||||
availableDevices.update((set) => {
|
||||
const newSet = new Set(set);
|
||||
newSet.add('dummy-1');
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
export function resetRemote(): void {
|
||||
isConnected.set(false);
|
||||
isConnecting.set(false);
|
||||
protocolInfo.set(null);
|
||||
fsInfo.set(null);
|
||||
activeDeviceId.set(null);
|
||||
buzzerAudioFiles.set([]);
|
||||
buzzerSysFiles.set([]);
|
||||
isFetchingRemote.set(false);
|
||||
}
|
||||
|
||||
export function resetLocal(): void {
|
||||
localAudioFiles.set([]);
|
||||
isFetchingLocal.set(false);
|
||||
}
|
||||
|
||||
export function resetAll(): void {
|
||||
resetRemote();
|
||||
resetLocal();
|
||||
}
|
||||
59
webpage/src/lib/sync.ts
Normal file
59
webpage/src/lib/sync.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { isConnected, fsInfo, buzzerAudioFiles, buzzerSysFiles, isFetchingRemote, isFetchingLocal, storageUsage} from './store';
|
||||
import { requestProtocolInfo, requestFSInfo, fetchDirectory } from './transport';
|
||||
import type { BuzzerFile } from './types';
|
||||
|
||||
function mapToBuzzerFile(rawFile: any): BuzzerFile {
|
||||
return {
|
||||
name: rawFile.name,
|
||||
size: rawFile.size,
|
||||
type: rawFile.type,
|
||||
tagsLoaded: false,
|
||||
sysTags: { format: null, crc32: null },
|
||||
metaTags: {}
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshRemote() {
|
||||
if (!get(isConnected)) return;
|
||||
|
||||
isFetchingRemote.set(true);
|
||||
try {
|
||||
await requestProtocolInfo();
|
||||
await requestFSInfo();
|
||||
|
||||
// Kurze Verzögerung für Store-Propagation
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const currentFsInfo = get(fsInfo);
|
||||
|
||||
// Sequenzielle Abfrage via Transport-Layer
|
||||
const sysFiles = await fetchDirectory(currentFsInfo?.sysPath || "/lfs/sys");
|
||||
buzzerSysFiles.set(sysFiles.map(mapToBuzzerFile));
|
||||
|
||||
const audioFiles = await fetchDirectory(currentFsInfo?.audioPath || "/lfs/a");
|
||||
buzzerAudioFiles.set(audioFiles.map(mapToBuzzerFile));
|
||||
|
||||
console.log("Audiodatein: ", audioFiles);
|
||||
console.log("Systemdatein: ", sysFiles);
|
||||
console.log("Aktuelle FS-Info: ", currentFsInfo);
|
||||
console.log("Storage Usage: ", get(storageUsage));
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Aktualisieren der Buzzer-Daten:", error);
|
||||
} finally {
|
||||
isFetchingRemote.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshLocal() {
|
||||
isFetchingLocal.set(true);
|
||||
try {
|
||||
// TODO: Implementierung lokaler Dateisystem-Zugriff (z.B. File System Access API)
|
||||
// const files = await readLocalDirectory();
|
||||
// localAudioFiles.set(files.map(mapToBuzzerFile));
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Aktualisieren der lokalen Daten:", error);
|
||||
} finally {
|
||||
isFetchingLocal.set(false);
|
||||
}
|
||||
}
|
||||
31
webpage/src/lib/toast.ts
Normal file
31
webpage/src/lib/toast.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { SETTINGS } from './settings';
|
||||
|
||||
export type ToastType = 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
dismissible: boolean;
|
||||
}
|
||||
|
||||
export const toasts = writable<ToastMessage[]>([]);
|
||||
|
||||
export function addToast(message: string, type: ToastType = 'info', dismissible: boolean = false) {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
|
||||
toasts.update(all => [...all, { id, type, message, dismissible }]);
|
||||
|
||||
if (!dismissible) {
|
||||
setTimeout(() => removeToast(id), SETTINGS.ui.toastDurationMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeToast(id: string) {
|
||||
toasts.update(all => all.filter(t => t.id !== id));
|
||||
}
|
||||
|
||||
export function clearAllToasts() {
|
||||
toasts.set([]);
|
||||
}
|
||||
92
webpage/src/lib/transport.ts
Normal file
92
webpage/src/lib/transport.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { buildLSRequest, buildProtocolInfoRequest, buildFSInfoRequest, setLsResolver } from './protocol/parser';
|
||||
import { buildFileGetRequest, setFileGetResolver } from './protocol/parser';
|
||||
import { isConnected, resetRemote } from './store';
|
||||
|
||||
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
|
||||
let currentSender: FrameSender | null = null;
|
||||
|
||||
export function registerTransport(sender: FrameSender | null) {
|
||||
currentSender = sender;
|
||||
}
|
||||
|
||||
// NEU: Wird von bluetooth.ts oder serial.ts nach dem physischen Connect gerufen
|
||||
export async function handleTransportConnect(sender: FrameSender) {
|
||||
registerTransport(sender);
|
||||
|
||||
try {
|
||||
// Basis-Informationen zwingend vorab laden
|
||||
await requestProtocolInfo();
|
||||
await requestFSInfo();
|
||||
|
||||
// Erst wenn diese Basisdaten da sind, wird die UI freigeschaltet
|
||||
isConnected.set(true);
|
||||
} catch (error) {
|
||||
console.error("Transport-Initialisierung fehlgeschlagen:", error);
|
||||
handleTransportDisconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendFrame(buffer: ArrayBuffer) {
|
||||
if (!currentSender) throw new Error("Kein Transportweg registriert.");
|
||||
await currentSender(buffer);
|
||||
}
|
||||
|
||||
export async function requestProtocolInfo() {
|
||||
await sendFrame(buildProtocolInfoRequest());
|
||||
}
|
||||
|
||||
export async function requestFSInfo() {
|
||||
await sendFrame(buildFSInfoRequest());
|
||||
}
|
||||
|
||||
let isListing = false;
|
||||
|
||||
export async function fetchDirectory(path: string): Promise<any[]> {
|
||||
if (isListing) {
|
||||
throw new Error("Ein Verzeichnis-Stream läuft bereits. Bitte warten.");
|
||||
}
|
||||
isListing = true;
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
// Dem Parser sagen, wen er bei Erfolg/Fehler anrufen soll
|
||||
setLsResolver(
|
||||
(data) => { isListing = false; resolve(data); },
|
||||
(err) => { isListing = false; reject(err); }
|
||||
);
|
||||
|
||||
try {
|
||||
await sendFrame(buildLSRequest(path));
|
||||
} catch (e) {
|
||||
isListing = false;
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function handleTransportDisconnect() {
|
||||
registerTransport(null);
|
||||
resetRemote();
|
||||
}
|
||||
|
||||
let isFileTransferring = false;
|
||||
|
||||
export async function fetchFileThroughputTest(path: string): Promise<boolean> {
|
||||
if (isFileTransferring) {
|
||||
throw new Error("Ein Dateitransfer läuft bereits.");
|
||||
}
|
||||
isFileTransferring = true;
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
setFileGetResolver(
|
||||
(success) => { isFileTransferring = false; resolve(success); },
|
||||
(err) => { isFileTransferring = false; reject(err); }
|
||||
);
|
||||
|
||||
try {
|
||||
await sendFrame(buildFileGetRequest(path));
|
||||
} catch (e) {
|
||||
isFileTransferring = false;
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
29
webpage/src/lib/types.ts
Normal file
29
webpage/src/lib/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface AudioFormat {
|
||||
codec: number;
|
||||
bitDepth: number;
|
||||
sampleRate: number;
|
||||
}
|
||||
|
||||
export interface SystemTags {
|
||||
format: AudioFormat | null;
|
||||
crc32: number | null;
|
||||
}
|
||||
|
||||
export interface MetadataTags {
|
||||
title?: string; // "t"
|
||||
author?: string; // "a"
|
||||
remarks?: string; // "r"
|
||||
categories?: string[];// "c"
|
||||
dateCreated?: string; // "dc"
|
||||
dateSaved?: string; // "ds"
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface BuzzerFile {
|
||||
name: string;
|
||||
size: number; // in Bytes
|
||||
type: number; // 0 = File, 1 = Dir
|
||||
tagsLoaded: boolean;
|
||||
sysTags: SystemTags;
|
||||
metaTags: MetadataTags;
|
||||
}
|
||||
@@ -1,53 +1,41 @@
|
||||
---
|
||||
import MainLayout from "../layouts/MainLayout.astro";
|
||||
import BuzzerControl from "../components/BuzzerControl.svelte";
|
||||
import BLEList from "../components/BLEList.svelte";
|
||||
import AppGuard from "../components/AppGuard.svelte";
|
||||
import FlashUsage from "../components/FlashUsage.svelte";
|
||||
---
|
||||
|
||||
<MainLayout>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="bg-white shadow shadow-slate-300 rounded-lg p-6">
|
||||
<h1>Titel</h1>
|
||||
<p>
|
||||
Li Europan lingues es membres del sam familie. Lor separat
|
||||
existentie es un myth. Por scientie, musica, sport etc, litot
|
||||
Europa usa li sam vocabular. Li lingues differe solmen in li
|
||||
grammatica, li pronunciation e li plu commun vocabules. Omnicos
|
||||
directe al desirabilite de un nov lingua franca: On refusa
|
||||
continuar payar custosi traductores. At solmen va esser necessi
|
||||
far uniform grammatica, pronunciation e plu sommun paroles. Ma
|
||||
quande lingues coalesce, li grammatica del resultant lingue es
|
||||
plu simplic e regulari quam ti del coalescent lingues. Li nov
|
||||
lingua franca va esser plu simplic e regulari quam li existent
|
||||
Europan lingues.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white shadow shadow-slate-300 rounded-lg p-6">
|
||||
<h1>Titel</h1>
|
||||
<p>
|
||||
Li Europan lingues es membres del sam familie. Lor separat
|
||||
existentie es un myth. Por scientie, musica, sport etc, litot
|
||||
Europa usa li sam vocabular. Li lingues differe solmen in li
|
||||
grammatica, li pronunciation e li plu commun vocabules. Omnicos
|
||||
directe al desirabilite de un nov lingua franca: On refusa
|
||||
continuar payar custosi traductores. At solmen va esser necessi
|
||||
far uniform grammatica, pronunciation e plu sommun paroles. Ma
|
||||
quande lingues coalesce, li grammatica del resultant lingue es
|
||||
plu simplic e regulari quam ti del coalescent lingues. Li nov
|
||||
lingua franca va esser plu simplic e regulari quam li existent
|
||||
Europan lingues.
|
||||
</p>
|
||||
<AppGuard client:load>
|
||||
<div class="max-w-4xl mx-auto mt-4">
|
||||
<header class="mb-12 text-center">
|
||||
<h1 class="text-3xl md:text-4xl font-extrabold text-slate-800 tracking-tight mb-3">
|
||||
Buzzer Management
|
||||
</h1>
|
||||
<p class="text-slate-500 text-lg max-w-2xl mx-auto">
|
||||
Verbinde dich mit dem nRF52840 Buzzer, um Audio-Dateien zu übertragen und
|
||||
Systemparameter auszulesen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
It va esser tam simplic quam Occidental in fact, it va esser
|
||||
Occidental. A un Angleso it va semblar un simplificat Angles,
|
||||
quam un skeptic Cambridge amico dit me que Occidental es. Li
|
||||
Europan lingues es membres del sam familie. Lor separat
|
||||
existentie es un myth. Por scientie, musica, sport etc, litot
|
||||
Europa usa li sam vocabular. Li lingues differe solmen in li
|
||||
grammatica, li pronunciation e li plu commun vocabules. Omnicos
|
||||
directe al desirabilite de un nov lingua franca: On refusa
|
||||
continuar payar custosi traductores. At solmen va esser necessi
|
||||
far uniform grammatica, pronunciation e plu sommun paroles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<BuzzerControl client:load />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<BLEList client:load />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<FlashUsage client:load />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppGuard>
|
||||
</MainLayout>
|
||||
|
||||
Reference in New Issue
Block a user