vor ble umbau

This commit is contained in:
2026-03-12 07:07:00 +01:00
parent 96aed70fc6
commit 5bb0d345da
45 changed files with 3681 additions and 48 deletions

View File

@@ -6,7 +6,7 @@
{
"files": ["*.svelte", "*.astro"],
"options": {
"printWidth": 1000
"printWidth": 100
}
}
]

View File

@@ -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",

View File

@@ -18,6 +18,7 @@
"typescript": "^5.9.3"
},
"devDependencies": {
"phosphor-svelte": "^3.1.0",
"prettier": "^3.8.1",
"prettier-plugin-astro": "^0.14.1"
}

View 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}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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);
}

View 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" }
};

View File

@@ -0,0 +1,2 @@
export * from './constants';
export * from './parser';

View 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;
}

View 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
View 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
View 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
View 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([]);
}

View 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
View 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;
}

View File

@@ -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>