guter zwischenstand

This commit is contained in:
2026-03-14 16:23:35 +01:00
parent 5bb0d345da
commit 1a4a22eafd
28 changed files with 1486 additions and 1231 deletions

1202
webpage/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,9 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/svelte": "^7.2.5",
"@astrojs/svelte": "^8.0.0",
"@tailwindcss/vite": "^4.2.1",
"astro": "^5.17.1",
"astro": "^6.0.3",
"prettier-plugin-svelte": "^3.5.1",
"svelte": "^5.53.7",
"tailwindcss": "^4.2.1",

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import Header from "./components/Header.svelte";
</script>
<div class="flex flex-col min-h-screen">
<Header />
<main class="main-layout flex-grow">
<section class="buzzer-card p-6 h-64">
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Dateiverarbeitung</h3>
</section>
<section class="buzzer-card p-6 h-64">
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Buzzer Status</h3>
</section>
<section class="buzzer-card p-6 h-96 lg:col-span-1">
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Lokale Sounds</h3>
</section>
<section class="buzzer-card p-6 h-96 lg:col-span-1">
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Buzzer Sounds</h3>
</section>
</main>
<footer class="hidden sm:flex justify-center items-center py-4 bg-slate-50 border-t border-slate-200 text-[10px] text-slate-400 uppercase tracking-widest">
&copy; 2026 Edis Buzzer Management Studio | Nerd Mode Active
</footer>
</div>

View File

@@ -1,61 +1,48 @@
<script lang="ts">
import { onMount } from "svelte";
import { isInitializing, isBluetoothSupported, isSerialSupported } from "../lib/store";
import { performHardwareCheck, getBrowserName } from "../lib/init";
import { isInitializing, isBluetoothSupported } from "../lib/store";
import { performHardwareCheck } from "../lib/init";
import ToastContainer from "./ToastContainer.svelte";
import { injectDummyDevices } from "../lib/store";
let browserName = "";
onMount(() => {
browserName = getBrowserName();
onMount(async () => {
performHardwareCheck();
injectDummyDevices(); // Fügt Dummy-Geräte für Testzwecke hinzu
if ($isBluetoothSupported) {
const { restoreSession } = await import("../lib/bluetooth");
await restoreSession();
}
});
</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 class="fixed inset-0 bg-surface flex items-center justify-center z-[100]">
<p class="text-on-surface font-mono animate-pulse text-base md:text-lg text-center">
Browserkompatibilität wird geprüft...
</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>
{:else if !$isBluetoothSupported}
<div
class="fixed lg:h-screen inset-0 flex flex-col items-center justify-center p-0 lg:p-4 z-[100] bg-white lg:bg-transparent" style="hyphens:auto;">
<div
class="w-full h-full lg:h-auto lg:max-w-md bg-red-50 lg:border border-red-600 lg:shadow-xl lg:rounded-lg p-6 lg:p-8 text-red-600 flex flex-col justify-center"
>
<h1 class="text-2xl font-bold mb-2 text-center">Dein Browser ist... suboptimal</h1>
<div class="text-center text-7xl md:text-9xl font-bold mb-4">🥺</div>
<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 class="space-y-4 text-base md:text-lg text-center md:text-left">
<p>
Leider unterstützt dein Browser die benötigten Bluetooth-Funktionen nicht. Bitte versuche
es mit einem aktuellen <span class="font-semibold">Chrome</span>
oder einem andern Chromium-basierten Browser.
<span class="font-semibold">Winzigweich Kante</span> soll gerüchteweise auch Chromium-basiert sein...
</p>
<p>
Rundreise auf iOS unterstützt Bluetooth leider nicht, aber du kannst es mit einem vernünftigen Gerät oder Browser versuchen.
</p>
</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 />
<ToastContainer />
<slot />
{/if}

View File

@@ -9,9 +9,11 @@
fsInfo,
loadConnectionState,
availableDevices,
transferStats,
resetTransferStats,
} from "../lib/store";
import { refreshRemote } from "../lib/sync";
import { fetchFileThroughputTest } from "../lib/transport";
import { getFile } from "../lib/transport";
onMount(() => {
restoreSession();
@@ -127,11 +129,40 @@
{/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");
class="mt-4 w-full text-left text-xs text-slate-500 hover:text-slate-700 transition-all"
on:click={async () => {
// 1. Alles auf Null setzen
resetTransferStats();
// 2. Gesamtgröße für beide Dateien zusammen setzen (z. B. 320000 + 224800)
const sizeFile1 = 320000;
const sizeFile2 = 224800;
transferStats.update((s) => ({
...s,
overallTotal: sizeFile1 + sizeFile2,
currentFileName: "countdown",
}));
try {
// 3. Erste Datei laden und auf Abschluss warten
const success1 = await getFile("/lfs/a/countdown");
if (success1) {
// 4. Name für die zweite Datei aktualisieren
transferStats.update((s) => ({ ...s, currentFileName: "404" }));
// 5. Zweite Datei laden und auf Abschluss warten
await getFile("/lfs/a/404");
transferStats.update((s) => ({ ...s, overallDone: s.overallTotal }));
}
} catch (err) {
console.error("Fehler beim Test-Transfer:", err);
} finally {
await new Promise(r => setTimeout(r, 2000))
resetTransferStats();
}
}}
>
Durchsatztest mit /lfs/a/countdown
Durchsatztest (Mehrere Dateien)
</button>
</div>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import FlashUsage from "./FlashUsage.svelte"
import { BatteryEmptyIcon, BatteryLowIcon, BatteryMediumIcon, BatteryHighIcon, BatteryFullIcon, BatteryChargingIcon } from "phosphor-svelte"
</script>
<div class="text-sm">
<table>
<tbody>
<tr>
<td class="key">
Modell
</td>
<td class="value">
nrf52840dk-prototyp
</td>
</tr>
<tr>
<td class="key">
Version
</td>
<td class="value">
2.3.22-debug
</td>
</tr>
<tr>
<td class="key">
HW-ID
</td>
<td class="value">
<span class="font-mono">DEAD-BEAF-0102-3456</span>
</td>
</tr>
<tr>
<td class="key">
Batterie
</td>
<td class="value flex items-center gap-2">
85% <BatteryChargingIcon weight="bold" class="w-5 h-5"/> 1200mAh
</td>
</tr>
<tr>
<td class="key">
Speicher
</td>
<td class="value">
<div class="py-1">
<FlashUsage/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<style>
@reference "../styles/app.css";
table {
@apply w-full text-left;
}
tr {
@apply even:bg-slate-100 border-b border-slate-200 last:border-0;
}
.key {
@apply p-1 pl-4 text-right text-text-muted;
}
.value {
@apply p-1 pr-4 font-semibold;
}

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import FileListItem from "./FileListItem.svelte";
import { buzzerAudioFiles } from "../lib/store";
import type { BuzzerFile } from "../lib/types";
export let type: "local" | "buzzer" = "buzzer";
</script>
<div>
{#each $buzzerAudioFiles as file, index(index)}
<FileListItem bind:file={file}/>
{/each}
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import type { BuzzerFile } from "../lib/types";
import { FileAudioIcon } from "phosphor-svelte";
export let file: BuzzerFile;
</script>
<div>
<button
class="w-full text-left flex-1 px-3 py-1 flex items-center cursor-pointer border-l-4 transition-colors border-b border-b-border-card
{file.selected ? 'border-l-blue-600 bg-blue-50 hover:bg-blue-100' : 'border-l-transparent hover:bg-slate-100 hover:border-l-blue-200'} "
on:click={() => {file.selected = !file.selected;}}
>
<FileAudioIcon class="text-blue-600 mr-3 w-5 h-5" />
<div class="flex flex-col">
<span class="font-light">
{file.name || "Unbekannte Datei"}
{#if file.metaTags?.t}
&thinsp;-&thinsp;
<span class="font-medium">{file.metaTags.t}</span>
{/if}
</span>
<div class="text-xs">
<span class="font-light text-text-muted text-xs">
{parseFloat((file.size/1024).toFixed(1))}&thinsp;kB
</span>
<span>
{#if file.metaTags.a}<span class="text-text-muted"> | Author:</span>&thinsp;{file.metaTags.a}{/if}
</span>
</div>
</div>
</button>
</div>
<style>
</style>

View File

@@ -1,27 +0,0 @@
<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

@@ -2,39 +2,37 @@
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="w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner shadow-sm">
<div
class="h-full bg-slate-400 transition-all duration-500"
class="h-full bg-gradient-to-b from-slate-300 to-slate-400 transition-all duration-500"
style="width: {$storageUsage?.systemPercent ?? 0}%"
></div>
<div
class="h-full bg-indigo-500 transition-all duration-500"
class="h-full bg-gradient-to-b from-indigo-300 to-indigo-500 transition-all duration-500"
style="width: {$storageUsage?.audioPercent ?? 0}%"
></div>
<div
class="h-full bg-emerald-500 transition-all duration-500"
class="h-full bg-gradient-to-b from-emerald-300 to-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">
<div class="text-xs text-slate-400 flex justify-between">
{#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>
{($storageUsage.systemBytes / 1048576).toFixed(2)}&thinsp;MB</span>
<span class="font-semibold text-indigo-500">Audio:
{($storageUsage.audioBytes / 1048576).toFixed(2)} MB</span>
{($storageUsage.audioBytes / 1048576).toFixed(2)}&thinsp;MB</span>
</div>
</div>
<div>
<span class="font-semibold text-emerald-500">Frei:
{($storageUsage.freeBytes / 1048576).toFixed(2)} MB</span>
{($storageUsage.freeBytes / 1048576).toFixed(2)}&thinsp;MB</span>
</div>
{:else}
<div class="text-slate-400">Speicherdaten nicht verfügbar</div>

View File

@@ -0,0 +1,205 @@
<script lang="ts">
import HeaderDeviceListItem from "./HeaderDeviceListItem.svelte";
import {
PlugsIcon,
PlugsConnectedIcon,
BluetoothIcon,
CaretDownIcon,
LinkIcon,
UsbIcon,
SquareIcon,
CheckSquareIcon,
} from "phosphor-svelte";
import { slide } from "svelte/transition";
import {
isConnected, isConnecting, availableDevices, pairedDevices,
activeDeviceId, autoConnect, loadConnectionState, fsInfo
} from "../lib/store";
import { connectBuzzer, disconnectBuzzer, pairBuzzer, forgetDevice, getPairedDevices } from "../lib/bluetooth";
let showDropdown = false;
$: lastDeviceId = loadConnectionState()?.deviceId;
$: targetDevice = $pairedDevices.find(d => d.id === lastDeviceId);
$: canQuickConnect = targetDevice ? $availableDevices.has(targetDevice.id) : false;
$: autoConnectIcon = $autoConnect ? CheckSquareIcon : SquareIcon;
async function handleMainAction() {
showDropdown = false;
if ($isConnected) {
disconnectBuzzer();
} else if (canQuickConnect && targetDevice) {
await connectBuzzer(targetDevice);
} else {
await pairBuzzer();
}
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (!node.contains(event.target as Node) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent("outclick"));
}
};
document.addEventListener("click", handleClick, true);
return {
destroy() {
document.removeEventListener("click", handleClick, true);
},
};
}
</script>
<header
class="fixed top-0 left-0 w-full h-16 bg-surface-card border-b border-border-card z-50 flex items-center justify-between px-4 lg:px-8 bg-gradient-to-b from-white to-slate-100"
>
<div class="flex items-center">
<span class="text-lg uppercase text-brand">
<span class="font-extrabold">Edis&nbsp;Buzzer</span>
&nbsp;
<span class="font-light">CONTROL</span>
</span>
</div>
<div class="relative" class:disabled={$isConnecting} use:clickOutside on:outclick={() => (showDropdown = false)}>
<div
class="btn-connect-group"
class:connected={$isConnected}
class:last-available={!$isConnected && canQuickConnect}
class:last-unavailable={!$isConnected && !canQuickConnect}
>
<button class="btn-main" on:click={handleMainAction} disabled={$isConnecting}>
{#if $isConnected}
<PlugsIcon weight="fill" class="w-4 h-4" />
<span class="hidden sm:inline">Trennen</span>
{:else if canQuickConnect}
<PlugsConnectedIcon weight="fill" class="w-4 h-4" />
<span class="hidden sm:inline">Verbinden</span>
{:else}
<LinkIcon weight="bold" class="w-4 h-4" />
<span class="hidden sm:inline">Neues Gerät</span>
{/if}
</button>
<button class="btn-dropdown" on:click={() => (showDropdown = !showDropdown)} disabled={$isConnecting}>
<CaretDownIcon
weight="bold"
class="w-4 h-4 transition-transform duration-300 {showDropdown ? '-scale-y-100' : ''}"
/>
</button>
</div>
{#if showDropdown}
<div
transition:slide={{ duration: 300 }}
class="absolute right-0 top-12 bg-surface-card shadow-xl rounded-lg border border-border-card p-0 z-50 overflow-hidden
w-max max-w-[calc(100vw-2rem)] sm:max-w-md min-w-[16rem]"
>
{#each $pairedDevices as dev (dev.id)}
<HeaderDeviceListItem
device={dev}
name={dev.name || ''}
isConnected={$activeDeviceId === dev.id}
isLastConnectedDevice={lastDeviceId === dev.id}
isAvailable={$availableDevices.has(dev.id)}
on:connect={(e) => { connectBuzzer(e.detail); showDropdown = false; }}
on:forget={(e) => forgetDevice(e.detail)}
/>
{/each}
{#if $pairedDevices.length === 0}
<div class="px-4 py-3 text-xs text-text-muted text-center italic border-b border-border-card">
Keine Geräte gekoppelt
</div>
{/if}
<div
class="flex-1 pr-3 pl-4 py-1 flex items-center menu-connect relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1"
>
<button class="flex items-center w-full text-left" on:click={() => { pairBuzzer(); showDropdown = false; }}>
<LinkIcon weight="bold" class="mr-2 w-5 h-5" />
<div class="flex-1">
<div class="flex flex-col">
<span class="py-2">
Buzzer über Bluetooth <BluetoothIcon weight="bold" class="w-4 h-4 flex inline" /> verbinden
</span>
</div>
</div>
</button>
</div>
<div
class="flex-1 pr-3 pl-4 py-1 flex items-center menu-connect relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1"
>
<button class="flex items-center w-full text-left">
<LinkIcon weight="bold" class="mr-2 w-5 h-5 opacity-50" />
<div class="flex-1 opacity-50">
<div class="flex flex-col">
<span class="py-2">
Buzzer über USB <UsbIcon weight="bold" class="w-4 h-4 flex inline" /> verbinden
</span>
</div>
</div>
</button>
</div>
<div
class="flex-1 pr-3 pl-4 py-1 flex items-center menu-auto relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1"
>
<button class="flex items-center w-full text-left" on:click={() => $autoConnect = !$autoConnect}>
<svelte:component this={autoConnectIcon} class="mr-2 w-5 h-5" />
<div class="flex-1">
<div class="flex flex-col">
<span class="py-2">Automatisch verbinden</span>
</div>
</div>
</button>
</div>
</div>
{/if}
</div>
</header>
<style>
@reference "../styles/app.css";
.connected {
@apply border-border-card;
}
.connected .btn-main, .connected .btn-dropdown {
@apply hover:bg-surface-hover text-slate-700;
}
.last-available, .last-unavailable {
@apply border-transparent;
}
.last-available .btn-main, .last-available .btn-dropdown {
@apply bg-emerald-600 hover:bg-emerald-700 text-white;
}
.last-unavailable .btn-main, .last-unavailable .btn-dropdown {
@apply bg-indigo-600 hover:bg-indigo-700 text-white;
}
.btn-connect-group {
@apply flex items-stretch rounded-lg overflow-hidden shadow-sm border;
}
.btn-main {
@apply flex items-center gap-2 p-2 text-sm font-semibold transition-colors outline-none cursor-pointer disabled:opacity-50;
}
.btn-dropdown {
@apply flex items-center justify-center px-2 py-2 transition-colors outline-none cursor-pointer disabled:opacity-50;
}
.menu-connect {
@apply text-blue-700 hover:bg-surface-hover font-semibold border-b last:border-b-0 border-border-card cursor-pointer;
}
.menu-auto {
@apply hover:bg-surface-hover font-semibold border-b last:border-b-0 border-border-card cursor-pointer;
}
</style>

View File

@@ -0,0 +1,66 @@
<!-- HeaderDeviceListItem.svelte -->
<script lang="ts">
import { BluetoothIcon, BluetoothSlashIcon, LinkBreakIcon } from "phosphor-svelte";
import { createEventDispatcher } from "svelte";
export let device: any = null;
export let isAvailable: boolean = false;
export let isLastConnectedDevice: boolean = false;
export let isConnected: boolean = false;
export let name: string = "";
export let type: string = "ble";
let isHovered = false;
const dispatch = createEventDispatcher();
</script>
<div
class="flex-1 pr-3 pl-4 py-1 flex items-center relative
border-b border-border-card
before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1
{isAvailable || isConnected ? '' : 'text-text-muted cursor-not-allowed'}
{isConnected && isLastConnectedDevice ? 'bg-bg-selected ' : ''}
{isAvailable && !isConnected ? 'hover:bg-surface-hover' : ''}
{isLastConnectedDevice ? 'before:bg-border-selected' : ''}
"
>
<button
class="flex-1 flex items-center text-left min-2-0"
on:click={() => dispatch("connect", device)}
disabled={!isAvailable || isConnected}
>
{#if type === "ble"}
{#if isAvailable || isConnected}
<BluetoothIcon class="text-blue-600 mr-2 w-5 h-5" />
{:else}
<BluetoothSlashIcon class="mr-2 w-5 h-5" />
{/if}
{/if}
<div class="flex-1 min-w-0">
<div class="flex flex-col">
<span class="font-medium text-sm truncate">
{name || "Unbekanntes Gerät"}
</span>
{#if isConnected}
<span class="text-xs font-semibold">Verbunden</span>
{:else if isAvailable}
<span class="text-xs">In Reichweite</span>
{:else if !isAvailable}
<span class="text-xs">Nicht in Reichweite</span>
{/if}
</div>
</div>
</button>
<button
on:mouseenter={() => (isHovered = true)}
on:mouseleave={() => (isHovered = false)}
title="'{name}' entfernen"
class="flex-shrink-0"
>
<LinkBreakIcon
weight={isHovered ? "bold" : "regular"}
class="w-7 h-7 p-1 ml-3 rounded text-red-600 hover:bg-red-600 hover:text-white"
/>
</button>
</div>

View File

@@ -0,0 +1,301 @@
<script lang="ts">
import { isConnected, fsInfo } from "../lib/store";
import {
GearIcon,
CloudArrowUpIcon,
ListIcon,
ArrowClockwiseIcon,
DotsThreeVerticalIcon,
CheckSquareOffsetIcon,
SquareIcon,
DownloadIcon,
XIcon,
} from "phosphor-svelte";
import FileList from "./FileList.svelte";
import DeviceInfo from "./DeviceInfo.svelte";
import { refreshRemote, downloadSelectedFiles } from "../lib/sync";
import {
buzzerAudioFiles,
buzzerFilesCount,
selectedBuzzerFilesCount,
transferStats,
isFetchingRemote,
pairedDevices,
activeDeviceId,
transferDetails,
} from "../lib/store";
import { SETTINGS } from "../lib/settings";
import { fade } from "svelte/transition";
let showOverlay = false;
let isTransferFinished = false;
let overlayTimeout: ReturnType<typeof setTimeout>;
$: currentDevice = $pairedDevices.find((d) => d.id === $activeDeviceId);
$: if ($isConnected) {
refreshRemote();
}
$: if ($isFetchingRemote && $transferStats.overallTotal > 0) {
// Transfer startet oder läuft
showOverlay = true;
isTransferFinished = false;
clearTimeout(overlayTimeout);
} else if (showOverlay && !$isFetchingRemote && $transferStats.overallDone > 0) {
// Transfer wurde soeben abgeschlossen
isTransferFinished = true;
overlayTimeout = setTimeout(closeOverlay, SETTINGS.ui.transferOverlayPersistMs);
}
function closeOverlay() {
clearTimeout(overlayTimeout);
showOverlay = false;
isTransferFinished = false;
// Optional: resetTransferStats() aufrufen, um die Werte zu nullen
}
function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return "∞";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")} Min.`;
}
</script>
<div class="main-layout mt-16 lg:mt-20 pb-12">
<section class="buzzer-card flex flex-col">
<div class="card-header">
<h3 class="card-title">Dateiverarbeitung</h3>
<button class="btn" aria-label="Einstellungen">
<GearIcon class="icon" />
</button>
</div>
<div class="card-body p-4">
<div class="flex justify-center items-center">
<div class="text-center text-2xs tracking-tight font-mono font-semibold">
16kHz 16bit MONO | <span class="text-emerald-700">NORMALIZER ON</span>
|
<span class="text-red-700">COMPRESSOR OFF</span>
</div>
</div>
<div class="flex items-center justify-center">
<CloudArrowUpIcon class="w-24 h-24 text-slate-500" />
</div>
</div>
</section>
<section class="buzzer-card flex flex-col">
<div class="card-header">
<h3 class="card-title">
{#if $isConnected && currentDevice?.name}
Geräteinfos: <span class="font-normal">{currentDevice.name}</span>
{/if}
</h3>
<button class="btn" aria-label="Einstellungen" disabled={!$isConnected}>
<GearIcon class="icon" />
</button>
</div>
<div class="relative overflow-hidden h-full">
<div class="card-body transition-all duration-500 h-full" class:disconnected={!$isConnected}>
<DeviceInfo />
</div>
{#if showOverlay}
<div
class="absolute inset-0 z-10 bg-white/95 backdrop-blur-[2px] p-4 flex flex-col justify-end"
transition:fade={{ duration: 300 }}
>
{#if isTransferFinished}
<button
class="absolute top-2 right-2 p-1 text-slate-400 hover:text-slate-700 transition-colors"
on:click={closeOverlay}
aria-label="Overlay schließen"
>
<XIcon class="w-5 h-5" />
</button>
{/if}
<div class="w-full flex flex-col gap-1">
<div class="flex flex-col gap-1">
<div
class="relative w-full h-3 bg-slate-100 rounded-sm overflow-hidden shadow-inner shadow-sm"
>
<div
class="absolute top-0 left-0 h-full bg-gradient-to-b from-indigo-300 to-indigo-500"
style="width: {$transferDetails.filePercent}%; transition: {$transferDetails.filePercent ===
0
? 'none'
: `width ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
></div>
<div
class="absolute inset-0 flex items-center justify-center text-[10px] text-slate-500"
>
{$transferDetails.filePercent}%
</div>
<div
class="absolute inset-0 flex items-center justify-center text-[10px] text-white"
style="clip-path: inset(0 {100 -
$transferDetails.filePercent}% 0 0); transition: {$transferDetails.filePercent ===
0
? 'none'
: `clip-path ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
>
{$transferDetails.filePercent}%
</div>
</div>
<div class="flex justify-between text-[10px] px-1">
<span class="truncate max-w-[60%]">
{$transferStats.currentFileName || "Lade..."}
</span>
<span>{formatTime($transferDetails.fileEta)}</span>
</div>
</div>
<div class="flex flex-col gap-1">
<div
class="relative w-full h-3 bg-slate-100 rounded-sm overflow-hidden shadow-inner shadow-sm"
>
<div
class="absolute top-0 left-0 h-full bg-gradient-to-b from-indigo-300 to-indigo-500"
style="width: {$transferDetails.totalPercent}%; transition: {$transferDetails.totalPercent ===
0
? 'none'
: `width ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
></div>
<div
class="absolute inset-0 flex items-center justify-center text-[10px] text-slate-500"
>
{$transferDetails.totalPercent}%
</div>
<div
class="absolute inset-0 flex items-center justify-center text-[10px] text-white"
style="clip-path: inset(0 {100 -
$transferDetails.totalPercent}% 0 0); transition: {$transferDetails.totalPercent ===
0
? 'none'
: `clip-path ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
>
{$transferDetails.totalPercent}%
</div>
</div>
<div class="flex justify-between text-[10px] px-1">
<span>{$transferDetails.speedKbs} kB/s</span>
<span>{formatTime($transferDetails.totalEta)}</span>
</div>
</div>
<div class="flex justify-center mt-1">
<button
class="px-4 py-1.5 text-xs font-semibold text-red-600 bg-red-50 hover:bg-red-100 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={true}
>
Transfer abbrechen
</button>
</div>
</div>
</div>
{/if}
</div>
</section>
<section class="buzzer-card">
<div class="card-header">
<h3 class="card-title">Lokale Bibliothek</h3>
<button class="btn" aria-label="Menu">
<ListIcon class="icon" />
</button>
</div>
<div class="card-body">
<div class="py-10 text-center">
<span class="text-slate-300 uppercase italic tracking-widest">Bibliothek leer</span>
</div>
</div>
</section>
<section class="buzzer-card relative">
<div class="card-header">
<div>
<h3 class="card-title">
Gerätebibliothek <span class="text-text-muted text-xs font-mono">
{$fsInfo?.audioPath}
</span>
</h3>
</div>
<div>
<div class="btn-connect-group group">
<button
class="btn-main"
aria-label="Alle ausgewählten Dateien herunterladen"
on:click={() => {
downloadSelectedFiles();
}}
disabled={!$isConnected || $selectedBuzzerFilesCount === 0}
title="Alle ausgewählten Dateien herunterladen"
>
<DownloadIcon class="icon" />
</button>
<button
class="btn-main"
aria-label="Alles Auswählen"
on:click={() => {
buzzerAudioFiles.update((files) => files.map((f) => ({ ...f, selected: true })));
}}
disabled={!$isConnected || $buzzerFilesCount === $selectedBuzzerFilesCount}
title="Alle auswählen"
>
<CheckSquareOffsetIcon class="icon" />
</button>
<button
class="btn-main"
on:click={() => {
buzzerAudioFiles.update((files) => files.map((f) => ({ ...f, selected: false })));
}}
aria-label="Auswahl löschen"
disabled={!$isConnected || $selectedBuzzerFilesCount === 0}
title="Auswahl aufheben"
>
<SquareIcon class="icon" />
</button>
<button
class="btn-main"
on:click={() => refreshRemote()}
aria-label="Reload"
disabled={!$isConnected}
title="Dateiliste neu laden"
>
<ArrowClockwiseIcon class="icon" />
</button>
<button class="btn-main" aria-label="Menu" disabled={!$isConnected} title="Menu">
<DotsThreeVerticalIcon weight="bold" class="icon" />
</button>
</div>
</div>
</div>
<div class="card-body" class:disconnected={!$isConnected}>
<FileList type="buzzer" />
</div>
</section>
</div>
<style>
@reference "../styles/app.css";
.btn-connect-group {
@apply flex items-stretch rounded overflow-hidden border-transparent transition-all hover:border-slate-200 hover:shadow-sm;
}
.btn-main {
@apply border-r-1 border-r-transparent last:border-r-0;
&:not(:disabled) {
@apply hover:border-border-card hover:shadow-sm hover:bg-slate-200 cursor-pointer group-hover:border-r-slate-200;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed group-hover:border-r-slate-200;
}
}
</style>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { transferDetails, transferStats } from "../lib/store";
import { fade, slide } from 'svelte/transition';
// Formatiert Sekunden zu "M:SS m" oder "S s" mit schmalem Leerzeichen
$: formatTime = (seconds: number): string => {
if (seconds === Infinity || seconds > 3600) return '∞';
const narrowNbsp = '\u202F';
if (seconds >= 60) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}${narrowNbsp}m`;
}
return `${Math.floor(seconds)}${narrowNbsp}s`;
};
</script>
<div class="mt-4 min-h-[100px]">
{#if $transferStats.bytesTotal > 0}
<div class="flex flex-col gap-3 mt-4 w-full" transition:slide={{ duration: 400 }}>
<div class="flex flex-col gap-1">
<div class="flex justify-between text-[10px] tracking-tighter font-bold text-slate-500">
<span class="truncate">Datei: {$transferStats.currentFileName || 'Übertragung...'}</span>
<span>{formatTime($transferDetails.fileEta)}</span>
</div>
<div class="w-full h-2 bg-slate-100 rounded-full overflow-hidden shadow-inner">
<div
class="h-full bg-blue-500 transition-all duration-500 ease-out"
style="width: {$transferDetails.filePercent}%"
></div>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between text-[10px] tracking-tighter font-bold text-slate-500">
<span>Gesamtfortschritt</span>
<span>{formatTime($transferDetails.totalEta)}</span>
</div>
<div class="w-full h-3 bg-slate-100 rounded-full overflow-hidden shadow-inner">
<div
class="h-full bg-slate-400 transition-all duration-500 ease-out"
style="width: {$transferDetails.totalPercent}%"
></div>
</div>
</div>
<div class="flex justify-between text-[10px] tracking-tighter font-medium text-slate-400">
<span>Rate: {$transferDetails.speedKbs.toFixed(0)}kBps</span>
<span>{$transferDetails.totalPercent}% abgeschlossen</span>
</div>
</div>
{:else}
<div class="mt-4 text-xs text-slate-400 italic text-center tracking-widest" transition:slide={{ duration: 400 }}>
Kein Transfer aktiv
</div>
{/if}
</div>

View File

@@ -1,29 +1,15 @@
<!-- MainLayout.astro -->
---
import "../styles/global.css";
const year = new Date().getFullYear();
import "../styles/app.css";
---
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Edis Buzzer</title>
</head>
<body class="antialiased bg-slate-50 text-primary pt-16 pb-12">
<nav class="fixed top-0 left-0 w-full z-50 bg-white shadow-bottom px-4 py-3 h-16 flex items-center">
<span class="uppercase font-bold text-xl tracking-narrow font-mono italic">EDIS_BUZZER</span>
</nav>
<main class="mx-auto max-w-screen-lg px-4 py-8 w-full">
<slot />
</main>
<footer class="fixed bottom-0 left-0 w-full z-50 bg-white text-xs text-slate-500 h-12 flex items-center shadow-top">
<div class="mx-auto px-4 w-full text-center">
&copy; 2026-{year} iten engineering. Alle Rechte vorbehalten.
</div>
</footer>
<body class="bg-surface text-on-surface antialiased transition-colors duration-300">
<slot />
</body>
</html>
</html>

View File

@@ -1,5 +1,5 @@
import { get } from 'svelte/store';
import { injectDummyDevices, isConnected, isPaired, isConnecting, pairedDevices, availableDevices, activeDeviceId, saveConnectionState, loadConnectionState, targetDeviceId, resetLocal, resetRemote } from './store';
import { isConnected, isPaired, isConnecting, pairedDevices, availableDevices, activeDeviceId, saveConnectionState, loadConnectionState, targetDeviceId, resetLocal, resetRemote, autoConnect } from './store';
import { BLE } from './protocol/constants';
import { parseIncomingFrame } from './protocol';
import { registerTransport, handleTransportDisconnect, handleTransportConnect } from './transport';
@@ -9,27 +9,25 @@ import { SETTINGS } from './settings';
let rxCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
let txCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
let device: BluetoothDevice | null = null;
let writeQueue = Promise.resolve();
export async function restoreSession() {
try {
const devices = await getPairedDevices();
if (devices.length > 0) {
isPaired.set(true);
startScanningAdvertisements(devices);
// Zuerst das Zielgerät definieren
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) {
if (savedState) {
targetDeviceId.set(savedState.deviceId);
device = devices.find(d => d.id === savedState.deviceId) || devices[0];
} else {
device = devices[0];
}
// Danach das Scanning starten (die Auto-Connect-Logik liegt nun in den Callbacks)
startScanningAdvertisements(devices);
}
} catch (error) {
console.error("Session-Wiederherstellung fehlgeschlagen:", error);
@@ -38,19 +36,24 @@ export async function restoreSession() {
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', () => {
dev.addEventListener('advertisementreceived', async () => {
// Gerät als verfügbar markieren
availableDevices.update(set => {
const newSet = new Set(set);
newSet.add(dev.id);
return newSet;
});
// Auto-Connect ausführen, sobald das Gerät funkt und falls die Voraussetzungen stimmen
if (get(autoConnect) && get(targetDeviceId) === dev.id && !get(isConnected) && !get(isConnecting)) {
console.debug("Auto-Connect: Gerät in Reichweite, starte Verbindung.");
await connectBuzzer(dev);
}
});
try {
// Auch hier vorher prüfen
if (typeof dev.watchAdvertisements === 'function') {
await dev.watchAdvertisements();
}
@@ -201,22 +204,17 @@ export async function forgetDevice(targetDevice: BluetoothDevice) {
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);
}
console.log("Bluetooth-Devices", rawDevices);
}
// 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);
}
@@ -226,7 +224,7 @@ function handleDisconnect() {
if (get(isConnected)) {
addToast("Verbindung zu Buzzer verloren", "warning");
}
writeQueue = Promise.resolve();
resetRemote();
registerTransport(null);
rxCharacteristic = null;
@@ -240,8 +238,15 @@ function handleIncomingData(event: Event) {
}
}
export async function sendBleFrame(buffer: ArrayBuffer) {
export function sendBleFrame(buffer: ArrayBuffer): Promise<void> {
// TODO: MTU Check einfügen!
if (!rxCharacteristic) return;
await rxCharacteristic.writeValueWithoutResponse(buffer);
if (!rxCharacteristic) return Promise.resolve();
writeQueue = writeQueue.then(() =>
rxCharacteristic!.writeValueWithoutResponse(buffer)
).catch(error => {
console.error("BLE Sende-Fehler:", error);
});
return writeQueue;
}

View File

@@ -0,0 +1,16 @@
const CRC32_TABLE = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
CRC32_TABLE[i] = c;
}
export function crc32(buffer: Uint8Array, previousCrc = 0): number {
let crc = previousCrc ^ -1;
for (let i = 0; i < buffer.length; i++) {
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ buffer[i]) & 0xFF];
}
return (crc ^ -1) >>> 0; // Rückgabe als unsigned 32-bit
}

View File

@@ -1,6 +1,11 @@
import { FRAME, DATA, ZEPHYR_ERRORS } from './constants';
import { protocolInfo, fsInfo } from '../store';
import { protocolInfo, fsInfo, transferStats, resetTransferStats, transferDetails } from '../store';
import { addToast } from '../toast';
import { SETTINGS } from '../settings';
import { crc32 } from './crc32';
let lastUiUpdate = 0;
let currentFileCrc32 = 0;
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
@@ -90,30 +95,42 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
break;
case FRAME.FILE_START:
fileTransfer.totalBytes = view.getUint32(3, true);
currentFileCrc32 = 0;
const totalBytes = view.getUint32(3, true);
const nowStart = performance.now();
transferStats.update(s => ({
...s,
bytesTotal: totalBytes,
bytesDone: 0,
currentFileName: s.pendingFileName || s.currentFileName,
fileStartTime: nowStart,
bulkStartTime: s.bulkStartTime === 0 ? nowStart : s.bulkStartTime
}));
// Parser-interne Metriken (Watchdog etc.)
fileTransfer.totalBytes = totalBytes;
fileTransfer.receivedBytes = 0;
fileTransfer.lastReceivedBytes = 0;
fileTransfer.stalledSeconds = 0;
fileTransfer.active = true;
fileTransfer.startTime = performance.now();
fileTransfer.startTime = nowStart;
lastUiUpdate = 0;
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");
addToast("Dateitransfer abgebrochen (Timeout)", "error");
if (fileGetReject) {
fileGetReject(new Error("Timeout beim Dateitransfer"));
@@ -123,18 +140,9 @@ case FRAME.FILE_START:
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)
@@ -145,32 +153,61 @@ case FRAME.FILE_START:
case FRAME.FILE_CHUNK:
if (!fileTransfer.active) break;
const chunkData = new Uint8Array(view.buffer, 3, payloadLength);
currentFileCrc32 = crc32(chunkData, currentFileCrc32);
const previousReceived = fileTransfer.receivedBytes;
fileTransfer.receivedBytes += payloadLength;
fileTransfer.credits--;
// Nachladen, sobald die Credits auf 32 fallen (Dein Vorschlag)
const nowChunk = performance.now();
if (nowChunk - lastUiUpdate > SETTINGS.ui.transferUpdateIntervalMs) {
const delta = fileTransfer.receivedBytes - previousReceived; // Das Delta seit dem letzten Paket
transferStats.update(s => ({
...s,
bytesDone: fileTransfer.receivedBytes,
overallDone: s.overallDone + (fileTransfer.receivedBytes - s.bytesDone)
}));
console.log("[FILE_GET] Fortschritt: " + ((fileTransfer.receivedBytes / fileTransfer.totalBytes) * 100).toFixed(2) + "%");
lastUiUpdate = nowChunk;
}
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;
}
transferStats.update(s => {
return {
...s,
bytesDone: s.bytesTotal,
};
});
// Watchdog stoppen
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
fileTransfer.active = false;
const buzzerCrc32 = view.getUint32(3, true);
const crc32 = view.getUint32(3, true);
console.log(`[CRC] Lokal: 0x${currentFileCrc32.toString(16).toUpperCase()}`);
console.log(`[CRC] Buzzer: 0x${buzzerCrc32.toString(16).toUpperCase()}`);
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 (currentFileCrc32 === buzzerCrc32) {
console.log("%c[CRC] Match! Datei ist integer.", "color: green; font-weight: bold;");
} else {
console.error("[CRC] Mismatch! Datei beschädigt.");
addToast("CRC Fehler: Datei wurde fehlerhaft übertragen.", "error");
if (fileGetReject) fileGetReject(new Error("CRC Mismatch"));
break;
}
if (fileGetResolve) {
fileGetResolve(true);
@@ -281,8 +318,8 @@ const fileTransfer = {
startTime: 0,
totalBytes: 0,
receivedBytes: 0,
lastReceivedBytes: 0, // NEU: Für die Timeout-Berechnung
stalledSeconds: 0, // NEU: Zähler für Stillstand
lastReceivedBytes: 0,
stalledSeconds: 0,
credits: 0,
metricsTimer: null as ReturnType<typeof setInterval> | null
};
@@ -301,7 +338,7 @@ export function buildFileGetRequest(path: string): ArrayBuffer {
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);

View File

@@ -6,6 +6,9 @@ export const SETTINGS = {
connectionTimeoutMs: 10000 // Timeout für den Verbindungsaufbau
},
ui: {
toastDurationMs: 5000
toastDurationMs: 5000,
transferUpdateIntervalMs: 100,
kbpsCalculationWindowMs: 10000,
transferOverlayPersistMs: 4000,
}
};

View File

@@ -1,5 +1,8 @@
import { writable, derived } from 'svelte/store';
import type { BuzzerFile } from './types';
import { SETTINGS } from './settings';
const CONNECTION_STATE_KEY = 'buzzer_connection_state';
// Fallback-Typ fuer Build-Umgebungen ohne DOM-Library.
interface BluetoothDevice {
@@ -46,8 +49,6 @@ export interface StorageUsage {
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);
@@ -120,46 +121,66 @@ export const storageUsage = derived(
},
);
// 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;
// Für die Anzeige der Transferdetails (Dateiname, Fortschritt, Geschwindigkeit, ETA)
export const transferStats = writable({
currentFileName: '',
pendingFileName: '',
bytesDone: 0,
bytesTotal: 0,
overallDone: 0,
overallTotal: 0,
bulkStartTime: 0,
fileStartTime: 0
});
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;
export const resetTransferStats = () => {
transferStats.set({
currentFileName: '',
pendingFileName: '',
bytesDone: 0,
bytesTotal: 0,
overallDone: 0,
overallTotal: 0,
bulkStartTime: 0,
fileStartTime: 0
});
};
availableDevices.update((set) => {
const newSet = new Set(set);
newSet.add('dummy-1');
return newSet;
});
}
let speedHistory: { bytes: number, time: number }[] = [];
export const transferDetails = derived(transferStats, ($s) => {
const now = performance.now();
if ($s.overallTotal === 0) {
speedHistory = [];
return { filePercent: 0, totalPercent: 0, speedKbs: 0, fileEta: Infinity, totalEta: Infinity };
}
speedHistory.push({ bytes: $s.overallDone, time: now });
speedHistory = speedHistory.filter(p => now - p.time < SETTINGS.ui.kbpsCalculationWindowMs);
let speedKbs = 0;
if (speedHistory.length > 1) {
const first = speedHistory[0];
const last = speedHistory[speedHistory.length - 1];
const timeDiff = (last.time - first.time) / 1000;
const bytesDiff = last.bytes - first.bytes;
if (timeDiff > 0) speedKbs = (bytesDiff / 1024) / timeDiff;
}
const speedBytesPerSec = speedKbs * 1024;
return {
filePercent: Math.round(($s.bytesTotal > 0 ? $s.bytesDone / $s.bytesTotal : 0) * 100),
totalPercent: Math.round(($s.overallTotal > 0 ? $s.overallDone / $s.overallTotal : 0) * 100),
speedKbs: parseFloat(speedKbs.toFixed(2)),
// Wenn Speed zu gering, direkt Infinity für das ∞ Symbol
fileEta: speedBytesPerSec > 100 ? ($s.bytesTotal - $s.bytesDone) / speedBytesPerSec : Infinity,
totalEta: speedBytesPerSec > 100 ? ($s.overallTotal - $s.overallDone) / speedBytesPerSec : Infinity
};
});
// Reset-Funktionen für verschiedene Anwendungsfälle
export function resetRemote(): void {
isConnected.set(false);
isConnecting.set(false);
@@ -169,6 +190,7 @@ export function resetRemote(): void {
buzzerAudioFiles.set([]);
buzzerSysFiles.set([]);
isFetchingRemote.set(false);
resetTransferStats();
}
export function resetLocal(): void {
@@ -179,4 +201,27 @@ export function resetLocal(): void {
export function resetAll(): void {
resetRemote();
resetLocal();
}
}
// Initialisierung aus dem bestehenden LocalStorage-Eintrag
const initialState = loadConnectionState();
export const autoConnect = writable<boolean>(initialState?.autoConnect ?? true);
// Automatische Speicherung bei Änderungen
autoConnect.subscribe(value => {
// Verhindert Fehler beim serverseitigen Rendern (Astro)
if (typeof window !== 'undefined') {
const currentState = loadConnectionState() || { transport: 'ble', deviceId: '', autoConnect: true };
saveConnectionState({ ...currentState, autoConnect: value });
}
});
// Abgeleitete Stores für die Anzahl der ausgewählten Dateien und der Dateien
export const buzzerFilesCount = derived(
buzzerAudioFiles,
($files) => $files.length
);
export const selectedBuzzerFilesCount = derived(
buzzerAudioFiles,
($files) => $files.filter(f => f.selected).length
);

View File

@@ -1,7 +1,9 @@
import { get } from 'svelte/store';
import { isConnected, fsInfo, buzzerAudioFiles, buzzerSysFiles, isFetchingRemote, isFetchingLocal, storageUsage} from './store';
import { isConnected, fsInfo, buzzerAudioFiles, buzzerSysFiles, isFetchingRemote, isFetchingLocal, storageUsage, transferDetails, transferStats } from './store';
import { requestProtocolInfo, requestFSInfo, fetchDirectory } from './transport';
import type { BuzzerFile } from './types';
import { getFile } from './transport';
import { addToast } from './toast';
function mapToBuzzerFile(rawFile: any): BuzzerFile {
return {
@@ -10,7 +12,8 @@ function mapToBuzzerFile(rawFile: any): BuzzerFile {
type: rawFile.type,
tagsLoaded: false,
sysTags: { format: null, crc32: null },
metaTags: {}
metaTags: {},
selected: false,
};
}
@@ -21,12 +24,12 @@ export async function refreshRemote() {
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));
@@ -56,4 +59,54 @@ export async function refreshLocal() {
} finally {
isFetchingLocal.set(false);
}
}
export async function downloadSelectedFiles() {
const files = get(buzzerAudioFiles).filter(f => f.selected);
const pathPrefix = get(fsInfo)?.audioPath || "/lfs/a";
if (files.length === 0) {
addToast("Keine Dateien zum Herunterladen ausgewählt.", "warning");
return;
}
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
const bulkStart = performance.now(); // Startzeitpunkt exakt erfassen
transferStats.update(s => ({
...s,
overallTotal: totalBytes,
overallDone: 0,
bulkStartTime: bulkStart
}));
isFetchingRemote.set(true);
try {
for (const file of files) {
console.log(`Starte Download von: ${file.name}`);
transferStats.update(s => ({ ...s, pendingFileName: file.name }));
const fullPath = `${pathPrefix}/${file.name}`;
await new Promise(r => setTimeout(r, 10));
await getFile(fullPath);
}
// Echte Durchschnittsgeschwindigkeit für den gesamten Bulk-Transfer berechnen
const totalTimeSec = (performance.now() - bulkStart) / 1000;
const avgSpeedKbs = ((totalBytes / 1024) / totalTimeSec).toFixed(1);
addToast(`${files.length} ${files.length === 1 ? "Datei" : "Dateien"} erfolgreich heruntergeladen. (${avgSpeedKbs} kB/s)`, "success");
} catch (error) {
console.error("Bulk-Download Fehler:", error);
addToast("Download abgebrochen: " + (error instanceof Error ? error.message : "Unbekannter Fehler"), "error");
} finally {
transferStats.update(s => ({
...s,
overallDone: s.overallTotal, // Schließt den Ladebalken visuell sauber ab
}));
// Das console.log für den Live-Speed wurde entfernt, da es hier falsche Werte liefert
isFetchingRemote.set(false);
}
}

View File

@@ -70,7 +70,7 @@ export function handleTransportDisconnect() {
let isFileTransferring = false;
export async function fetchFileThroughputTest(path: string): Promise<boolean> {
export async function getFile(path: string): Promise<boolean> {
if (isFileTransferring) {
throw new Error("Ein Dateitransfer läuft bereits.");
}

View File

@@ -26,4 +26,5 @@ export interface BuzzerFile {
tagsLoaded: boolean;
sysTags: SystemTags;
metaTags: MetadataTags;
selected: boolean;
}

View File

@@ -1,41 +1,13 @@
<!-- index.astro -->
---
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";
import MainLayout from "../layouts/MainLayout.astro";
import Header from "../components/Header.svelte";
import MainGrid from "../components/MainGrid.svelte";
---
<MainLayout>
<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>
<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>
<Header client:load/>
<MainGrid client:load/>
</AppGuard>
</MainLayout>
</MainLayout>

View File

@@ -0,0 +1,73 @@
/* app.css */
@import "tailwindcss";
@slot base;
@slot components;
@slot utilities;
@theme {
--color-surface: var(--color-slate-50);
--color-on-surface: var(--color-slate-900);
--color-light-on-surface: var(--color-slate-700);
--color-accent: var(--color-blue-600);
--color-accent-bg: var(--color-blue-50);
--color-accent-hover: var(--color-blue-100);
--color-accent-border-separator: var(--color-blue-200);
--color-surface-card: var(--color-white);
--color-surface-hover: var(--color-slate-100);
--color-border-card: var(--color-slate-200);
--color-border-separator: var(--color-slate-100);
--color-text-muted: var(--color-slate-400);
--color-border-selected: var(--color-blue-500);
--color-bg-selected: var(--color-blue-50);
--color-bg-selected-hover: var(--color-blue-200);
}
@layer base {
body {
@apply bg-surface text-on-surface;
}
}
@layer components {
.main-layout {
@apply grid grid-cols-1 lg:grid-cols-2 gap-0 lg:gap-6 p-0 lg:p-6 w-full max-w-7xl mx-auto;
}
.disconnected {
@apply grayscale opacity-30 blur-[1px];
}
.buzzer-card {
@apply bg-surface-card border-border-card transition-all duration-300;
@apply border-b lg:border lg:rounded-xl lg:shadow-sm overflow-hidden;
}
.card-header {
@apply px-3 py-2 flex justify-between items-center bg-gradient-to-b from-white to-slate-100 border-b border-border-card h-12;
}
.card-title {
@apply font-light font-features-['smcp'] tracking-tight;
}
/* .card-body {
@apply;
} */
.buzzer-card .icon {
@apply w-7 h-7 p-1;
}
.card-header .btn {
@apply border rounded border-transparent hover:bg-slate-200 hover:border-border-card hover:shadow-sm;
}
.text-2xs {
@apply text-[0.625rem];
}
}