guter zwischenstand
This commit is contained in:
@@ -8,6 +8,12 @@ menuconfig BLE_MGMT
|
|||||||
Library for initializing and managing Bluetooth functionality.
|
Library for initializing and managing Bluetooth functionality.
|
||||||
|
|
||||||
if BLE_MGMT
|
if BLE_MGMT
|
||||||
|
config BLE_MGMT_TX_QUEUE_DEPTH
|
||||||
|
int "BLE TX queue depth"
|
||||||
|
default 32
|
||||||
|
help
|
||||||
|
Number of notification payloads that can be queued in the BLE transport.
|
||||||
|
|
||||||
config BLE_MGMT_DEFAULT_DEVICE_NAME
|
config BLE_MGMT_DEFAULT_DEVICE_NAME
|
||||||
string "Default Bluetooth Device Name"
|
string "Default Bluetooth Device Name"
|
||||||
default "Edis Buzzer"
|
default "Edis Buzzer"
|
||||||
@@ -22,7 +28,6 @@ if BLE_MGMT
|
|||||||
help
|
help
|
||||||
Maximal advertising interval. 160 equals to 100ms.
|
Maximal advertising interval. 160 equals to 100ms.
|
||||||
|
|
||||||
# 1. MTU und Data Length (Maximale Paketgrößen)
|
|
||||||
config BT_L2CAP_TX_MTU
|
config BT_L2CAP_TX_MTU
|
||||||
default 247
|
default 247
|
||||||
config BT_BUF_ACL_RX_SIZE
|
config BT_BUF_ACL_RX_SIZE
|
||||||
@@ -33,14 +38,12 @@ if BLE_MGMT
|
|||||||
default 251
|
default 251
|
||||||
config BT_USER_DATA_LEN_UPDATE
|
config BT_USER_DATA_LEN_UPDATE
|
||||||
default y
|
default y
|
||||||
|
|
||||||
# 2. Physical Layer (Erlaubt 2M PHY)
|
|
||||||
config BT_USER_PHY_UPDATE
|
config BT_USER_PHY_UPDATE
|
||||||
default y
|
default y
|
||||||
|
|
||||||
# 3. Flow-Control und Queues (High Throughput, Host + SDC Controller synchronisiert)
|
|
||||||
config BT_HCI_ACL_FLOW_CONTROL
|
config BT_HCI_ACL_FLOW_CONTROL
|
||||||
default y
|
default y
|
||||||
|
config BT_BUF_CMD_TX_COUNT
|
||||||
|
default 24
|
||||||
config BT_BUF_EVT_RX_COUNT
|
config BT_BUF_EVT_RX_COUNT
|
||||||
default 22
|
default 22
|
||||||
config BT_BUF_ACL_TX_COUNT
|
config BT_BUF_ACL_TX_COUNT
|
||||||
@@ -49,12 +52,12 @@ if BLE_MGMT
|
|||||||
default 20
|
default 20
|
||||||
config BT_CONN_TX_MAX
|
config BT_CONN_TX_MAX
|
||||||
default 20
|
default 20
|
||||||
|
|
||||||
# 4. SDC Controller Buffering (an Host-Tiefen angeglichen)
|
|
||||||
config BT_CTLR_SDC_TX_PACKET_COUNT
|
config BT_CTLR_SDC_TX_PACKET_COUNT
|
||||||
default 20
|
default 20
|
||||||
config BT_CTLR_SDC_RX_PACKET_COUNT
|
config BT_CTLR_SDC_RX_PACKET_COUNT
|
||||||
default 20
|
default 20
|
||||||
|
config BT_MAX_CONN
|
||||||
|
default 2
|
||||||
|
|
||||||
module = BLE_MGMT
|
module = BLE_MGMT
|
||||||
module-str = ble_mgmt
|
module-str = ble_mgmt
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ void buzz_proto_buf_free(uint8_t **buf)
|
|||||||
{
|
{
|
||||||
if (buf && *buf)
|
if (buf && *buf)
|
||||||
{
|
{
|
||||||
k_mem_slab_free(&buzz_proto_slabs, (void **)*buf);
|
k_mem_slab_free(&buzz_proto_slabs, *buf);
|
||||||
*buf = NULL;
|
*buf = NULL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,8 @@ int buzz_proto_submit_frame(struct buzz_frame_msg *msg)
|
|||||||
return k_msgq_put(&buzz_proto_msgq, msg, K_NO_WAIT);
|
return k_msgq_put(&buzz_proto_msgq, msg, K_NO_WAIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void send_stream_error(buzz_transport_reply_fn reply_cb, uint16_t error_code);
|
||||||
|
|
||||||
static void send_error_frame(struct buzz_frame_msg *msg, uint16_t error_code)
|
static void send_error_frame(struct buzz_frame_msg *msg, uint16_t error_code)
|
||||||
{
|
{
|
||||||
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
|
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
|
||||||
@@ -99,6 +101,18 @@ static void send_error_frame(struct buzz_frame_msg *msg, uint16_t error_code)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void send_stream_error(buzz_transport_reply_fn reply_cb, uint16_t error_code)
|
||||||
|
{
|
||||||
|
uint8_t *buf = NULL;
|
||||||
|
if (reply_cb == NULL || buzz_proto_buf_alloc(&buf) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
struct buzz_frame_msg err_msg = {.data_ptr = buf, .reply_cb = reply_cb};
|
||||||
|
send_error_frame(&err_msg, error_code);
|
||||||
|
buzz_proto_buf_free(&buf);
|
||||||
|
}
|
||||||
|
|
||||||
static void handle_proto_version_request(struct buzz_frame_msg *msg)
|
static void handle_proto_version_request(struct buzz_frame_msg *msg)
|
||||||
{
|
{
|
||||||
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
|
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
|
||||||
@@ -284,6 +298,8 @@ static void handle_file_get_request(struct buzz_frame_msg *msg)
|
|||||||
fs_mgmt_pm_close(&get_file_state.file);
|
fs_mgmt_pm_close(&get_file_state.file);
|
||||||
get_file_state.active = false;
|
get_file_state.active = false;
|
||||||
current_stream = STREAM_IDLE;
|
current_stream = STREAM_IDLE;
|
||||||
|
k_sleep(K_MSEC(10));
|
||||||
|
send_error_frame(msg, EIO);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,16 +379,20 @@ static void process_file_get_stream(void)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daten gelesen -> CRC aktualisieren und Chunk senden
|
// Chunk senden; CRC/Offset erst nach erfolgreichem Enqueue aktualisieren
|
||||||
get_file_state.crc32 = crc32_ieee_update(get_file_state.crc32, payload_ptr, read_len);
|
|
||||||
get_file_state.offset += read_len;
|
|
||||||
|
|
||||||
hdr->frame_type = BUZZ_FRAME_FILE_CHUNK;
|
hdr->frame_type = BUZZ_FRAME_FILE_CHUNK;
|
||||||
hdr->payload_length = sys_cpu_to_le16(read_len);
|
hdr->payload_length = sys_cpu_to_le16(read_len);
|
||||||
|
|
||||||
if (get_file_state.reply_cb)
|
if (get_file_state.reply_cb)
|
||||||
{
|
{
|
||||||
int send_rc = get_file_state.reply_cb(buf, sizeof(*hdr) + read_len);
|
int send_rc = get_file_state.reply_cb(buf, sizeof(*hdr) + read_len);
|
||||||
|
if (send_rc == -ENOMEM)
|
||||||
|
{
|
||||||
|
// BLE TX queue voll - Datei zurücksetzen, nächster Zyklus wiederholt den Chunk
|
||||||
|
fs_seek(&get_file_state.file, -(off_t)read_len, FS_SEEK_CUR);
|
||||||
|
buzz_proto_buf_free(&buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (send_rc)
|
if (send_rc)
|
||||||
{
|
{
|
||||||
LOG_ERR("Failed to send FILE_CHUNK (err %d)", send_rc);
|
LOG_ERR("Failed to send FILE_CHUNK (err %d)", send_rc);
|
||||||
@@ -380,10 +400,15 @@ static void process_file_get_stream(void)
|
|||||||
get_file_state.active = false;
|
get_file_state.active = false;
|
||||||
current_stream = STREAM_IDLE;
|
current_stream = STREAM_IDLE;
|
||||||
buzz_proto_buf_free(&buf);
|
buzz_proto_buf_free(&buf);
|
||||||
|
k_sleep(K_MSEC(10));
|
||||||
|
send_stream_error(get_file_state.reply_cb, EIO);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Erfolgreich eingereiht: State aktualisieren
|
||||||
|
get_file_state.crc32 = crc32_ieee_update(get_file_state.crc32, payload_ptr, read_len);
|
||||||
|
get_file_state.offset += read_len;
|
||||||
get_file_state.credits--;
|
get_file_state.credits--;
|
||||||
get_file_state.retry_counter = 0;
|
get_file_state.retry_counter = 0;
|
||||||
buzz_proto_buf_free(&buf);
|
buzz_proto_buf_free(&buf);
|
||||||
@@ -590,6 +615,7 @@ static void buzz_proto_thread_fn(void *p1, void *p2, void *p3)
|
|||||||
{
|
{
|
||||||
LOG_WRN("LS timeout waiting for ACK");
|
LOG_WRN("LS timeout waiting for ACK");
|
||||||
fs_mgmt_pm_closedir(&ls_state.dir);
|
fs_mgmt_pm_closedir(&ls_state.dir);
|
||||||
|
send_stream_error(ls_state.reply_cb, ETIMEDOUT);
|
||||||
ls_state.active = false;
|
ls_state.active = false;
|
||||||
current_stream = STREAM_IDLE;
|
current_stream = STREAM_IDLE;
|
||||||
}
|
}
|
||||||
@@ -608,6 +634,7 @@ static void buzz_proto_thread_fn(void *p1, void *p2, void *p3)
|
|||||||
{
|
{
|
||||||
LOG_WRN("FILE_GET timeout waiting for ACK");
|
LOG_WRN("FILE_GET timeout waiting for ACK");
|
||||||
fs_close(&get_file_state.file);
|
fs_close(&get_file_state.file);
|
||||||
|
send_stream_error(get_file_state.reply_cb, ETIMEDOUT);
|
||||||
get_file_state.active = false;
|
get_file_state.active = false;
|
||||||
current_stream = STREAM_IDLE;
|
current_stream = STREAM_IDLE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,13 @@ CONFIG_LOG=y
|
|||||||
|
|
||||||
### File System
|
### File System
|
||||||
CONFIG_FS_MGMT=y
|
CONFIG_FS_MGMT=y
|
||||||
CONFIG_FS_MGMT_LOG_LEVEL_DBG=y
|
# CONFIG_FS_MGMT_LOG_LEVEL_DBG=y
|
||||||
CONFIG_FS_LOG_LEVEL_WRN=y
|
CONFIG_FS_LOG_LEVEL_WRN=y
|
||||||
|
|
||||||
### Bluetooth
|
### Bluetooth
|
||||||
CONFIG_BLE_MGMT=y
|
CONFIG_BLE_MGMT=y
|
||||||
# CONFIG_BLE_MGMT_LOG_LEVEL_DBG=y
|
# CONFIG_BLE_MGMT_LOG_LEVEL_DBG=y
|
||||||
|
|
||||||
# Explicit throughput tuning in project config (wins over competing defaults)
|
|
||||||
CONFIG_BT_HCI_ACL_FLOW_CONTROL=y
|
|
||||||
CONFIG_BT_BUF_CMD_TX_COUNT=24
|
|
||||||
CONFIG_BT_BUF_ACL_TX_COUNT=20
|
|
||||||
CONFIG_BT_L2CAP_TX_BUF_COUNT=20
|
|
||||||
CONFIG_BT_CONN_TX_MAX=20
|
|
||||||
CONFIG_BT_CTLR_SDC_TX_PACKET_COUNT=20
|
|
||||||
CONFIG_BT_CTLR_SDC_RX_PACKET_COUNT=20
|
|
||||||
|
|
||||||
# Advertising 500ms - 1s
|
# Advertising 500ms - 1s
|
||||||
CONFIG_BLE_MGMT_ADV_INT_MIN=160
|
CONFIG_BLE_MGMT_ADV_INT_MIN=160
|
||||||
CONFIG_BLE_MGMT_ADV_INT_MAX=320
|
CONFIG_BLE_MGMT_ADV_INT_MAX=320
|
||||||
|
|||||||
1202
webpage/package-lock.json
generated
1202
webpage/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,9 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/svelte": "^7.2.5",
|
"@astrojs/svelte": "^8.0.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"astro": "^5.17.1",
|
"astro": "^6.0.3",
|
||||||
"prettier-plugin-svelte": "^3.5.1",
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
"svelte": "^5.53.7",
|
"svelte": "^5.53.7",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
|||||||
31
webpage/src/components/App.svelte
Normal file
31
webpage/src/components/App.svelte
Normal 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">
|
||||||
|
© 2026 Edis Buzzer Management Studio | Nerd Mode Active
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
@@ -1,61 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { isInitializing, isBluetoothSupported, isSerialSupported } from "../lib/store";
|
import { isInitializing, isBluetoothSupported } from "../lib/store";
|
||||||
import { performHardwareCheck, getBrowserName } from "../lib/init";
|
import { performHardwareCheck } from "../lib/init";
|
||||||
import ToastContainer from "./ToastContainer.svelte";
|
import ToastContainer from "./ToastContainer.svelte";
|
||||||
import { injectDummyDevices } from "../lib/store";
|
|
||||||
|
|
||||||
let browserName = "";
|
onMount(async () => {
|
||||||
onMount(() => {
|
|
||||||
browserName = getBrowserName();
|
|
||||||
performHardwareCheck();
|
performHardwareCheck();
|
||||||
injectDummyDevices(); // Fügt Dummy-Geräte für Testzwecke hinzu
|
|
||||||
|
if ($isBluetoothSupported) {
|
||||||
|
const { restoreSession } = await import("../lib/bluetooth");
|
||||||
|
await restoreSession();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $isInitializing}
|
{#if $isInitializing}
|
||||||
<div class="fixed inset-0 bg-slate-50 flex items-center justify-center z-[100]">
|
<div class="fixed inset-0 bg-surface flex items-center justify-center z-[100]">
|
||||||
<p class="text-slate-600 font-mono animate-pulse">SYSTEM_CHECK_RUNNING...</p>
|
<p class="text-on-surface font-mono animate-pulse text-base md:text-lg text-center">
|
||||||
</div>
|
Browserkompatibilität wird geprüft...
|
||||||
{: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>
|
</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>
|
||||||
<div class="flex justify-between border-b border-slate-100 pb-1">
|
{:else if !$isBluetoothSupported}
|
||||||
<span>Web Serial:</span>
|
<div
|
||||||
<span class="text-red-600 font-bold">NICHT UNTERSTÜTZT</span>
|
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>
|
<div
|
||||||
</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"
|
||||||
|
|
||||||
<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
|
<h1 class="text-2xl font-bold mb-2 text-center">Dein Browser ist... suboptimal</h1>
|
||||||
</a>
|
<div class="text-center text-7xl md:text-9xl font-bold mb-4">🥺</div>
|
||||||
|
|
||||||
<p class="text-xs text-slate-500 mb-6 italic">
|
<div class="space-y-4 text-base md:text-lg text-center md:text-left">
|
||||||
Gerüchten zufolge soll <b>Winzigweichs Kante</b>
|
<p>
|
||||||
-Browser diese Technologien auch unterstützen. Aber wer nutzt schon diese Weichware?
|
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>
|
||||||
|
<p>
|
||||||
|
Rundreise auf iOS unterstützt Bluetooth leider nicht, aber du kannst es mit einem vernünftigen Gerät oder Browser versuchen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ToastContainer client:load />
|
<ToastContainer />
|
||||||
<slot />
|
<slot />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
fsInfo,
|
fsInfo,
|
||||||
loadConnectionState,
|
loadConnectionState,
|
||||||
availableDevices,
|
availableDevices,
|
||||||
|
transferStats,
|
||||||
|
resetTransferStats,
|
||||||
} from "../lib/store";
|
} from "../lib/store";
|
||||||
import { refreshRemote } from "../lib/sync";
|
import { refreshRemote } from "../lib/sync";
|
||||||
import { fetchFileThroughputTest } from "../lib/transport";
|
import { getFile } from "../lib/transport";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
restoreSession();
|
restoreSession();
|
||||||
@@ -127,11 +129,40 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="mt-4 w-full text-left text-xs text-slate-500 hover:text-slate-700 transition"
|
class="mt-4 w-full text-left text-xs text-slate-500 hover:text-slate-700 transition-all"
|
||||||
on:click={() => {
|
on:click={async () => {
|
||||||
fetchFileThroughputTest("/lfs/a/countdown");
|
// 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
72
webpage/src/components/DeviceInfo.svelte
Normal file
72
webpage/src/components/DeviceInfo.svelte
Normal 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;
|
||||||
|
}
|
||||||
13
webpage/src/components/FileList.svelte
Normal file
13
webpage/src/components/FileList.svelte
Normal 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>
|
||||||
35
webpage/src/components/FileListItem.svelte
Normal file
35
webpage/src/components/FileListItem.svelte
Normal 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}
|
||||||
|
 - 
|
||||||
|
<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))} kB
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{#if file.metaTags.a}<span class="text-text-muted"> | Author:</span> {file.metaTags.a}{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -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>
|
|
||||||
@@ -2,39 +2,37 @@
|
|||||||
import { storageUsage } from "../lib/store";
|
import { storageUsage } from "../lib/store";
|
||||||
</script>
|
</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
|
<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}%"
|
style="width: {$storageUsage?.systemPercent ?? 0}%"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<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}%"
|
style="width: {$storageUsage?.audioPercent ?? 0}%"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<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}%"
|
style="width: {$storageUsage?.freePercent ?? 0}%"
|
||||||
></div>
|
></div>
|
||||||
</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}
|
{#if $storageUsage}
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-semibold text-slate-400">System:
|
<span class="font-semibold text-slate-400">System:
|
||||||
{($storageUsage.systemBytes / 1048576).toFixed(2)} MB</span>
|
{($storageUsage.systemBytes / 1048576).toFixed(2)} MB</span>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="font-semibold text-indigo-500">Audio:
|
<span class="font-semibold text-indigo-500">Audio:
|
||||||
{($storageUsage.audioBytes / 1048576).toFixed(2)} MB</span>
|
{($storageUsage.audioBytes / 1048576).toFixed(2)} MB</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="font-semibold text-emerald-500">Frei:
|
<span class="font-semibold text-emerald-500">Frei:
|
||||||
{($storageUsage.freeBytes / 1048576).toFixed(2)} MB</span>
|
{($storageUsage.freeBytes / 1048576).toFixed(2)} MB</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-slate-400">Speicherdaten nicht verfügbar</div>
|
<div class="text-slate-400">Speicherdaten nicht verfügbar</div>
|
||||||
|
|||||||
205
webpage/src/components/Header.svelte
Normal file
205
webpage/src/components/Header.svelte
Normal 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 Buzzer</span>
|
||||||
|
|
||||||
|
<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>
|
||||||
66
webpage/src/components/HeaderDeviceListItem.svelte
Normal file
66
webpage/src/components/HeaderDeviceListItem.svelte
Normal 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>
|
||||||
301
webpage/src/components/MainGrid.svelte
Normal file
301
webpage/src/components/MainGrid.svelte
Normal 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>
|
||||||
61
webpage/src/components/TransferProgress.svelte
Normal file
61
webpage/src/components/TransferProgress.svelte
Normal 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>
|
||||||
@@ -1,29 +1,15 @@
|
|||||||
|
<!-- MainLayout.astro -->
|
||||||
---
|
---
|
||||||
import "../styles/global.css";
|
import "../styles/app.css";
|
||||||
const year = new Date().getFullYear();
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Edis Buzzer</title>
|
<title>Edis Buzzer</title>
|
||||||
</head>
|
</head>
|
||||||
|
<body class="bg-surface text-on-surface antialiased transition-colors duration-300">
|
||||||
<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 />
|
<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">
|
|
||||||
© 2026-{year} iten engineering. Alle Rechte vorbehalten.
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { get } from 'svelte/store';
|
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 { BLE } from './protocol/constants';
|
||||||
import { parseIncomingFrame } from './protocol';
|
import { parseIncomingFrame } from './protocol';
|
||||||
import { registerTransport, handleTransportDisconnect, handleTransportConnect } from './transport';
|
import { registerTransport, handleTransportDisconnect, handleTransportConnect } from './transport';
|
||||||
@@ -9,27 +9,25 @@ import { SETTINGS } from './settings';
|
|||||||
let rxCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
|
let rxCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
|
||||||
let txCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
|
let txCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
|
||||||
let device: BluetoothDevice | null = null;
|
let device: BluetoothDevice | null = null;
|
||||||
|
let writeQueue = Promise.resolve();
|
||||||
|
|
||||||
export async function restoreSession() {
|
export async function restoreSession() {
|
||||||
try {
|
try {
|
||||||
const devices = await getPairedDevices();
|
const devices = await getPairedDevices();
|
||||||
if (devices.length > 0) {
|
if (devices.length > 0) {
|
||||||
isPaired.set(true);
|
isPaired.set(true);
|
||||||
startScanningAdvertisements(devices);
|
|
||||||
|
|
||||||
|
// Zuerst das Zielgerät definieren
|
||||||
const savedState = loadConnectionState();
|
const savedState = loadConnectionState();
|
||||||
if (savedState && savedState.autoConnect && savedState.transport === 'ble') {
|
if (savedState) {
|
||||||
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);
|
targetDeviceId.set(savedState.deviceId);
|
||||||
device = devices.find(d => d.id === savedState.deviceId) || devices[0];
|
device = devices.find(d => d.id === savedState.deviceId) || devices[0];
|
||||||
} else {
|
} else {
|
||||||
device = devices[0];
|
device = devices[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Danach das Scanning starten (die Auto-Connect-Logik liegt nun in den Callbacks)
|
||||||
|
startScanningAdvertisements(devices);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Session-Wiederherstellung fehlgeschlagen:", error);
|
console.error("Session-Wiederherstellung fehlgeschlagen:", error);
|
||||||
@@ -38,19 +36,24 @@ export async function restoreSession() {
|
|||||||
|
|
||||||
async function startScanningAdvertisements(devices: BluetoothDevice[]) {
|
async function startScanningAdvertisements(devices: BluetoothDevice[]) {
|
||||||
for (const dev of devices) {
|
for (const dev of devices) {
|
||||||
// Sicherheits-Check für Mock-Objekte
|
|
||||||
if (typeof dev.addEventListener !== 'function') continue;
|
if (typeof dev.addEventListener !== 'function') continue;
|
||||||
|
|
||||||
dev.addEventListener('advertisementreceived', () => {
|
dev.addEventListener('advertisementreceived', async () => {
|
||||||
|
// Gerät als verfügbar markieren
|
||||||
availableDevices.update(set => {
|
availableDevices.update(set => {
|
||||||
const newSet = new Set(set);
|
const newSet = new Set(set);
|
||||||
newSet.add(dev.id);
|
newSet.add(dev.id);
|
||||||
return newSet;
|
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 {
|
try {
|
||||||
// Auch hier vorher prüfen
|
|
||||||
if (typeof dev.watchAdvertisements === 'function') {
|
if (typeof dev.watchAdvertisements === 'function') {
|
||||||
await dev.watchAdvertisements();
|
await dev.watchAdvertisements();
|
||||||
}
|
}
|
||||||
@@ -201,22 +204,17 @@ export async function forgetDevice(targetDevice: BluetoothDevice) {
|
|||||||
export async function getPairedDevices() {
|
export async function getPairedDevices() {
|
||||||
let rawDevices: BluetoothDevice[] = [];
|
let rawDevices: BluetoothDevice[] = [];
|
||||||
|
|
||||||
// 1. Physische Geräte abrufen, falls die API verfügbar ist
|
|
||||||
if ('bluetooth' in navigator && 'getDevices' in navigator.bluetooth) {
|
if ('bluetooth' in navigator && 'getDevices' in navigator.bluetooth) {
|
||||||
try {
|
try {
|
||||||
rawDevices = await navigator.bluetooth.getDevices();
|
rawDevices = await navigator.bluetooth.getDevices();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Abrufen der gekoppelten Geräte:", 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);
|
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);
|
return get(pairedDevices);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +224,7 @@ function handleDisconnect() {
|
|||||||
if (get(isConnected)) {
|
if (get(isConnected)) {
|
||||||
addToast("Verbindung zu Buzzer verloren", "warning");
|
addToast("Verbindung zu Buzzer verloren", "warning");
|
||||||
}
|
}
|
||||||
|
writeQueue = Promise.resolve();
|
||||||
resetRemote();
|
resetRemote();
|
||||||
registerTransport(null);
|
registerTransport(null);
|
||||||
rxCharacteristic = 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!
|
// TODO: MTU Check einfügen!
|
||||||
if (!rxCharacteristic) return;
|
if (!rxCharacteristic) return Promise.resolve();
|
||||||
await rxCharacteristic.writeValueWithoutResponse(buffer);
|
|
||||||
|
writeQueue = writeQueue.then(() =>
|
||||||
|
rxCharacteristic!.writeValueWithoutResponse(buffer)
|
||||||
|
).catch(error => {
|
||||||
|
console.error("BLE Sende-Fehler:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return writeQueue;
|
||||||
}
|
}
|
||||||
|
|||||||
16
webpage/src/lib/protocol/crc32.ts
Normal file
16
webpage/src/lib/protocol/crc32.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { FRAME, DATA, ZEPHYR_ERRORS } from './constants';
|
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 { addToast } from '../toast';
|
||||||
|
import { SETTINGS } from '../settings';
|
||||||
|
import { crc32 } from './crc32';
|
||||||
|
|
||||||
|
let lastUiUpdate = 0;
|
||||||
|
let currentFileCrc32 = 0;
|
||||||
|
|
||||||
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
|
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
|
||||||
|
|
||||||
@@ -90,12 +95,25 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case FRAME.FILE_START:
|
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.receivedBytes = 0;
|
||||||
fileTransfer.lastReceivedBytes = 0;
|
|
||||||
fileTransfer.stalledSeconds = 0;
|
|
||||||
fileTransfer.active = true;
|
fileTransfer.active = true;
|
||||||
fileTransfer.startTime = performance.now();
|
fileTransfer.startTime = nowStart;
|
||||||
|
lastUiUpdate = 0;
|
||||||
|
|
||||||
console.log(`[FILE_GET] Stream gestartet. Erwartete Größe: ${fileTransfer.totalBytes} Bytes.`);
|
console.log(`[FILE_GET] Stream gestartet. Erwartete Größe: ${fileTransfer.totalBytes} Bytes.`);
|
||||||
|
|
||||||
@@ -112,8 +130,7 @@ case FRAME.FILE_START:
|
|||||||
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
|
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
|
||||||
fileTransfer.active = false;
|
fileTransfer.active = false;
|
||||||
|
|
||||||
// Hier optional einen Toast anzeigen lassen, falls importiert:
|
addToast("Dateitransfer abgebrochen (Timeout)", "error");
|
||||||
// addToast("Dateitransfer abgebrochen (Timeout)", "error");
|
|
||||||
|
|
||||||
if (fileGetReject) {
|
if (fileGetReject) {
|
||||||
fileGetReject(new Error("Timeout beim Dateitransfer"));
|
fileGetReject(new Error("Timeout beim Dateitransfer"));
|
||||||
@@ -123,18 +140,9 @@ case FRAME.FILE_START:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Daten fließen -> Watchdog zurücksetzen
|
|
||||||
fileTransfer.stalledSeconds = 0;
|
fileTransfer.stalledSeconds = 0;
|
||||||
fileTransfer.lastReceivedBytes = fileTransfer.receivedBytes;
|
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);
|
}, 1000);
|
||||||
|
|
||||||
// Initiale Credits (z.B. 64)
|
// Initiale Credits (z.B. 64)
|
||||||
@@ -145,32 +153,61 @@ case FRAME.FILE_START:
|
|||||||
case FRAME.FILE_CHUNK:
|
case FRAME.FILE_CHUNK:
|
||||||
if (!fileTransfer.active) break;
|
if (!fileTransfer.active) break;
|
||||||
|
|
||||||
|
const chunkData = new Uint8Array(view.buffer, 3, payloadLength);
|
||||||
|
currentFileCrc32 = crc32(chunkData, currentFileCrc32);
|
||||||
|
|
||||||
|
const previousReceived = fileTransfer.receivedBytes;
|
||||||
fileTransfer.receivedBytes += payloadLength;
|
fileTransfer.receivedBytes += payloadLength;
|
||||||
fileTransfer.credits--;
|
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) {
|
if (fileTransfer.credits <= 64) {
|
||||||
fileTransfer.credits = 128;
|
fileTransfer.credits = 128;
|
||||||
sendCredits(fileTransfer.credits, sender);
|
sendCredits(fileTransfer.credits, sender);
|
||||||
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case FRAME.FILE_END:
|
case FRAME.FILE_END:
|
||||||
if (fileTransfer.metricsTimer) {
|
transferStats.update(s => {
|
||||||
clearInterval(fileTransfer.metricsTimer);
|
return {
|
||||||
fileTransfer.metricsTimer = null;
|
...s,
|
||||||
}
|
bytesDone: s.bytesTotal,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Watchdog stoppen
|
||||||
|
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
|
||||||
fileTransfer.active = false;
|
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 totalElapsed = (performance.now() - fileTransfer.startTime) / 1000;
|
||||||
const avgSpeed = (fileTransfer.receivedBytes / 1024) / totalElapsed;
|
const avgSpeed = (fileTransfer.receivedBytes / 1024) / totalElapsed;
|
||||||
|
|
||||||
console.log(`[FILE_GET] Stream beendet.`);
|
console.log(`[FILE_GET] Stream beendet.`);
|
||||||
console.log(`[FILE_GET] Empfangen: ${fileTransfer.receivedBytes} Bytes in ${totalElapsed.toFixed(2)}s.`);
|
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] 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) {
|
if (fileGetResolve) {
|
||||||
fileGetResolve(true);
|
fileGetResolve(true);
|
||||||
@@ -281,8 +318,8 @@ const fileTransfer = {
|
|||||||
startTime: 0,
|
startTime: 0,
|
||||||
totalBytes: 0,
|
totalBytes: 0,
|
||||||
receivedBytes: 0,
|
receivedBytes: 0,
|
||||||
lastReceivedBytes: 0, // NEU: Für die Timeout-Berechnung
|
lastReceivedBytes: 0,
|
||||||
stalledSeconds: 0, // NEU: Zähler für Stillstand
|
stalledSeconds: 0,
|
||||||
credits: 0,
|
credits: 0,
|
||||||
metricsTimer: null as ReturnType<typeof setInterval> | null
|
metricsTimer: null as ReturnType<typeof setInterval> | null
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export const SETTINGS = {
|
|||||||
connectionTimeoutMs: 10000 // Timeout für den Verbindungsaufbau
|
connectionTimeoutMs: 10000 // Timeout für den Verbindungsaufbau
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
toastDurationMs: 5000
|
toastDurationMs: 5000,
|
||||||
|
transferUpdateIntervalMs: 100,
|
||||||
|
kbpsCalculationWindowMs: 10000,
|
||||||
|
transferOverlayPersistMs: 4000,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { writable, derived } from 'svelte/store';
|
import { writable, derived } from 'svelte/store';
|
||||||
import type { BuzzerFile } from './types';
|
import type { BuzzerFile } from './types';
|
||||||
|
import { SETTINGS } from './settings';
|
||||||
|
|
||||||
|
const CONNECTION_STATE_KEY = 'buzzer_connection_state';
|
||||||
|
|
||||||
// Fallback-Typ fuer Build-Umgebungen ohne DOM-Library.
|
// Fallback-Typ fuer Build-Umgebungen ohne DOM-Library.
|
||||||
interface BluetoothDevice {
|
interface BluetoothDevice {
|
||||||
@@ -46,8 +49,6 @@ export interface StorageUsage {
|
|||||||
freePercent: number;
|
freePercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONNECTION_STATE_KEY = 'buzzer_connection_state';
|
|
||||||
|
|
||||||
// App-Status: Initialisierung und Feature-Support
|
// App-Status: Initialisierung und Feature-Support
|
||||||
export const isInitializing = writable<boolean>(true);
|
export const isInitializing = writable<boolean>(true);
|
||||||
export const isBluetoothSupported = writable<boolean | null>(null);
|
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
|
// Für die Anzeige der Transferdetails (Dateiname, Fortschritt, Geschwindigkeit, ETA)
|
||||||
export function injectDummyDevices(): void {
|
export const transferStats = writable({
|
||||||
const dummy1 = {
|
currentFileName: '',
|
||||||
id: 'dummy-1',
|
pendingFileName: '',
|
||||||
name: 'Dev Buzzer (Erreichbar)',
|
bytesDone: 0,
|
||||||
forget: async () => {
|
bytesTotal: 0,
|
||||||
console.log('Forget dummy-1');
|
overallDone: 0,
|
||||||
},
|
overallTotal: 0,
|
||||||
addEventListener: () => {},
|
bulkStartTime: 0,
|
||||||
removeEventListener: () => {},
|
fileStartTime: 0
|
||||||
watchAdvertisements: async () => {},
|
});
|
||||||
gatt: { connected: false, disconnect: () => {} },
|
|
||||||
} as unknown as BluetoothDevice;
|
|
||||||
|
|
||||||
const dummy2 = {
|
export const resetTransferStats = () => {
|
||||||
id: 'dummy-2',
|
transferStats.set({
|
||||||
name: 'Dev Buzzer (Offline)',
|
currentFileName: '',
|
||||||
forget: async () => {
|
pendingFileName: '',
|
||||||
console.log('Forget dummy-2');
|
bytesDone: 0,
|
||||||
},
|
bytesTotal: 0,
|
||||||
addEventListener: () => {},
|
overallDone: 0,
|
||||||
removeEventListener: () => {},
|
overallTotal: 0,
|
||||||
watchAdvertisements: async () => {},
|
bulkStartTime: 0,
|
||||||
gatt: { connected: false, disconnect: () => {} },
|
fileStartTime: 0
|
||||||
} as unknown as BluetoothDevice;
|
});
|
||||||
|
};
|
||||||
|
|
||||||
pairedDevices.update((devices) => {
|
let speedHistory: { bytes: number, time: number }[] = [];
|
||||||
if (!devices.find((d) => d.id === 'dummy-1')) {
|
|
||||||
return [...devices, dummy1, dummy2];
|
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 };
|
||||||
}
|
}
|
||||||
return devices;
|
|
||||||
});
|
|
||||||
|
|
||||||
availableDevices.update((set) => {
|
speedHistory.push({ bytes: $s.overallDone, time: now });
|
||||||
const newSet = new Set(set);
|
speedHistory = speedHistory.filter(p => now - p.time < SETTINGS.ui.kbpsCalculationWindowMs);
|
||||||
newSet.add('dummy-1');
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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 {
|
export function resetRemote(): void {
|
||||||
isConnected.set(false);
|
isConnected.set(false);
|
||||||
isConnecting.set(false);
|
isConnecting.set(false);
|
||||||
@@ -169,6 +190,7 @@ export function resetRemote(): void {
|
|||||||
buzzerAudioFiles.set([]);
|
buzzerAudioFiles.set([]);
|
||||||
buzzerSysFiles.set([]);
|
buzzerSysFiles.set([]);
|
||||||
isFetchingRemote.set(false);
|
isFetchingRemote.set(false);
|
||||||
|
resetTransferStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetLocal(): void {
|
export function resetLocal(): void {
|
||||||
@@ -180,3 +202,26 @@ export function resetAll(): void {
|
|||||||
resetRemote();
|
resetRemote();
|
||||||
resetLocal();
|
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
|
||||||
|
);
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { get } from 'svelte/store';
|
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 { requestProtocolInfo, requestFSInfo, fetchDirectory } from './transport';
|
||||||
import type { BuzzerFile } from './types';
|
import type { BuzzerFile } from './types';
|
||||||
|
import { getFile } from './transport';
|
||||||
|
import { addToast } from './toast';
|
||||||
|
|
||||||
function mapToBuzzerFile(rawFile: any): BuzzerFile {
|
function mapToBuzzerFile(rawFile: any): BuzzerFile {
|
||||||
return {
|
return {
|
||||||
@@ -10,7 +12,8 @@ function mapToBuzzerFile(rawFile: any): BuzzerFile {
|
|||||||
type: rawFile.type,
|
type: rawFile.type,
|
||||||
tagsLoaded: false,
|
tagsLoaded: false,
|
||||||
sysTags: { format: null, crc32: null },
|
sysTags: { format: null, crc32: null },
|
||||||
metaTags: {}
|
metaTags: {},
|
||||||
|
selected: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,3 +60,53 @@ export async function refreshLocal() {
|
|||||||
isFetchingLocal.set(false);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ export function handleTransportDisconnect() {
|
|||||||
|
|
||||||
let isFileTransferring = false;
|
let isFileTransferring = false;
|
||||||
|
|
||||||
export async function fetchFileThroughputTest(path: string): Promise<boolean> {
|
export async function getFile(path: string): Promise<boolean> {
|
||||||
if (isFileTransferring) {
|
if (isFileTransferring) {
|
||||||
throw new Error("Ein Dateitransfer läuft bereits.");
|
throw new Error("Ein Dateitransfer läuft bereits.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ export interface BuzzerFile {
|
|||||||
tagsLoaded: boolean;
|
tagsLoaded: boolean;
|
||||||
sysTags: SystemTags;
|
sysTags: SystemTags;
|
||||||
metaTags: MetadataTags;
|
metaTags: MetadataTags;
|
||||||
|
selected: boolean;
|
||||||
}
|
}
|
||||||
@@ -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 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>
|
<MainLayout>
|
||||||
<AppGuard client:load>
|
<AppGuard client:load>
|
||||||
<div class="max-w-4xl mx-auto mt-4">
|
<Header client:load/>
|
||||||
<header class="mb-12 text-center">
|
<MainGrid client:load/>
|
||||||
<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>
|
|
||||||
</AppGuard>
|
</AppGuard>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
73
webpage/src/styles/app.css
Normal file
73
webpage/src/styles/app.css
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user