Compare commits

..

10 Commits

80 changed files with 7584 additions and 1701 deletions

122
Tags.md Normal file
View File

@@ -0,0 +1,122 @@
# Edi's Buzzer - Metadata Tags Format
## Architektur-Übersicht
Die Metadaten werden transparent an das Ende der rohen Audio-Daten angehängt. Das Format basiert auf einer strikten **Little-Endian** Byte-Reihenfolge und nutzt eine erweiterbare **TLV-Struktur** (Type-Length-Value) für die eigentlichen Datenblöcke.
Das physische Layout einer Datei im Flash-Speicher sieht wie folgt aus:
`[Audio-Rohdaten] [TLV-Block 1] ... [TLV-Block N] [Footer (8 Bytes)]`
---
## 1. Footer-Struktur
Der Footer liegt exakt auf den letzten 8 Bytes der Datei (EOF - 8). Er dient als Ankerpunkt für den Parser, um die Metadaten rückwärts aus der Datei zu extrahieren. Das 8-Byte-Alignment stellt speichersichere Casts auf 32-Bit-ARM-Architekturen sicher.
| Offset | Feld | Typ | Beschreibung |
| :--- | :--- | :--- | :--- |
| 0 | `total_size` | `uint16_t` | Gesamtgröße in Bytes (Summe aller TLV-Blöcke + 8 Bytes Footer). |
| 2 | `version` | `uint16_t` | Format-Version. Aktuell `0x0001`. |
| 4 | `magic` | `char[4]` | Fixe Signatur: `"TAG!"` (Hex: `54 41 47 21`). |
---
## 2. TLV-Header (Type-Length-Value)
Jeder Metadaten-Block beginnt mit einem exakt 4 Bytes großen Header. Unbekannte Typen können vom Controller durch einen relativen Sprung (`fs_seek` um `length` Bytes) übersprungen werden.
| Offset | Feld | Typ | Beschreibung |
| :--- | :--- | :--- | :--- |
| 0 | `type` | `uint8_t` | Definiert den Inhalt des Blocks (siehe Typen-Definitionen). |
| 1 | `index` | `uint8_t` | Erlaubt die Fragmentierung großer Datensätze (z.B. bei JSON > 64 KB). Standard: `0x00`. |
| 2 | `length` | `uint16_t` | Größe der folgenden Payload in Bytes (ohne diesen Header). |
---
## 3. Typen-Definitionen
### Type `0x00`: Binary System Metadata
Dieser Typ gruppiert maschinenlesbare, binäre Systeminformationen. Die Unterscheidung erfolgt über das `Index`-Feld.
#### Index `0x00`: Audio Format
Dieser Block konfiguriert den I2S-Treiber vor der Wiedergabe.
* **Typ:** `0x00`
* **Index:** `0x00`
* **Länge:** `0x0008` (8 Bytes)
* **Payload:** `[codec: 1 Byte] [bit_depth: 1 Byte] [reserved: 2 Bytes] [samplerate: 4 Bytes]`
#### Index `0x01`: Audio CRC32
Speichert die CRC32-Prüfsumme (IEEE) der reinen Audiodaten (vom Dateianfang bis zum Beginn des ersten TLV-Blocks). Dient Synchronisations-Tools für einen schnellen Integritäts- und Abgleich-Check, ohne die gesamte Datei neu hashen zu müssen.
* **Typ:** `0x00`
* **Index:** `0x01`
* **Länge:** `0x0004` (4 Bytes)
* **Payload:** `uint32_t` (Little-Endian)
### Type `0x10`: JSON Metadata
Dieser Block enthält Metadaten, die primär für das Host-System (z. B. das Python-Tool) zur Verwaltung, Kategorisierung und Anzeige bestimmt sind. Der Mikrocontroller ignoriert und überspringt diesen Block während der Audiowiedergabe.
* **Typ:** `0x10`
* **Länge:** Variabel
* **Payload:** UTF-8-kodierter JSON-String (ohne Null-Terminator).
#### Standardisierte JSON-Schlüssel
Die nachfolgenden Schlüssel (Keys) sind im Basis-Standard definiert. Die Integration weiterer, proprietärer Schlüssel ist technisch möglich. Es wird jedoch empfohlen, dies mit Vorsicht zu handhaben, da zukünftige Standardisierungen diese Schlüsselnamen belegen könnten (Namenskollision).
| Schlüssel | Datentyp | Beschreibung |
| :--- | :--- | :--- |
| `t` | String | Titel der Audiodatei |
| `a` | String | Autor oder Ersteller |
| `r` | String | Bemerkungen (Remarks) oder Beschreibung |
| `c` | Array of Strings | Kategorien zur Gruppierung |
| `dc` | String | Erstellungsdatum (Date Created), idealerweise nach ISO 8601 |
| `ds` | String | Speicher- oder Änderungsdatum (Date Saved), idealerweise nach ISO 8601 |
**Beispiel-Payload:**
Ein vollständiger JSON-Datensatz gemäß dieser Spezifikation hat folgendes Format:
```json
{
"t": "Testaufnahme System A",
"a": "Entwickler-Team",
"r": "Überprüfung der Mikrofon-Aussteuerung.",
"c": ["Test", "Audio", "V1"],
"dc": "2026-03-05T13:00:00Z",
"ds": "2026-03-05T13:10:00Z"
}
```
*(Hinweis zur Skalierbarkeit: Für zukünftige Erweiterungen können dedizierte TLV-Typen definiert werden, wie beispielsweise 0x11 für GZIP-komprimierte JSON-Daten oder 0x20 für binäre Bilddaten wie PNG-Cover).*
---
## 4. Lese-Algorithmus (Parser-Logik)
Der Controller extrahiert die Hardware-Parameter nach folgendem Ablauf:
1. **Footer lokalisieren:** * Gehe zu `EOF - 8`. Lese 8 Bytes in das `tag_footer_t` Struct.
* Validiere `magic == "TAG!"` und `version == 0x0001` (unter Berücksichtigung von Little-Endian Konvertierung via `sys_le16_to_cpu`).
2. **Grenzen berechnen:**
* Lese `total_size`.
* Die reinen Audiodaten enden bei `audio_limit = EOF - total_size`.
* Gehe zur Position `audio_limit`.
3. **TLV-Blöcke iterieren:**
* Solange die aktuelle Leseposition kleiner als `EOF - 8` ist:
* Lese 4 Bytes in den `tlv_header_t`.
* Wenn `type == 0x00`: Lese die nächsten 8 Bytes in das `tlv_audio_format_t` Struct.
* Wenn `type != 0x00`: Führe `fs_seek(header.length, FS_SEEK_CUR)` aus.
---
## 5. Hex-Beispiel
Eine fiktive Datei enthält Audio-Daten. Es soll ein PCM-Mono Format (16 Bit, 16 kHz) sowie ein kurzes JSON `{"t":"A"}` (9 Bytes) angehängt werden.
**1. TLV 0x00 (Audio Format):**
* Header: `00 00 08 00` (Type 0, Index 0, Length 8)
* Payload: `00 10 00 00 80 3E 00 00` (Mono, 16-Bit, Reserved, 16000 Hz)
**2. TLV 0x10 (JSON):**
* Header: `10 00 09 00` (Type 16, Index 0, Length 9)
* Payload: `7B 22 74 22 3A 22 41 22 7D` (`{"t":"A"}`)
**3. Footer:**
* Total Size: `2D 00` (45 Bytes = 12 Bytes Audio-TLV + 13 Bytes JSON-TLV + 12 Bytes Padding/Zusatz + 8 Bytes Footer) -> *Hinweis: Size ist in diesem Konstrukt abhängig vom genauen Payload.*
* Version: `01 00`
* Magic: `54 41 47 21` (`TAG!`)

View File

@@ -4,6 +4,8 @@ list(APPEND ZEPHYR_EXTRA_MODULES ${CMAKE_CURRENT_SOURCE_DIR}/libs)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(firmware)
project(buzzer)
include(${ZEPHYR_BASE}/samples/subsys/usb/common/common.cmake)
target_sources(app PRIVATE src/main.c)

9
firmware/VERSION Normal file
View File

@@ -0,0 +1,9 @@
VERSION_MAJOR = 0
VERSION_MINOR = 0
PATCHLEVEL = 2
VERSION_TWEAK = 0
#if (IS_ENABLED(CONFIG_LOG))
EXTRAVERSION = debug
#else
EXTRAVERSION = 0
#endif

View File

@@ -4,5 +4,32 @@
};
aliases {
qspi-flash = &mx25r64;
i2s-audio = &i2s0;
};
};
&pinctrl {
i2s0_default: i2s0_default {
group1 {
psels = <NRF_PSEL(I2S_SCK_M, 0, 31)>, /* SCK/Bit Clock */
<NRF_PSEL(I2S_LRCK_M, 0, 30)>, /* WS/Word Select */
<NRF_PSEL(I2S_SDOUT, 0, 29)>; /* SD/Serial Data */
};
};
i2s0_sleep: i2s0_sleep {
group1 {
psels = <NRF_PSEL(I2S_SCK_M, 0, 31)>,
<NRF_PSEL(I2S_LRCK_M, 0, 30)>,
<NRF_PSEL(I2S_SDOUT, 0, 29)>;
low-power-enable;
};
};
};
&i2s0 {
status = "okay";
pinctrl-0 = <&i2s0_default>;
pinctrl-1 = <&i2s0_sleep>;
pinctrl-names = "default", "sleep";
};

View File

@@ -1,3 +1,6 @@
add_subdirectory(fw_mgmt)
add_subdirectory(fs_mgmt)
add_subdirectory(ble_mgmt)
add_subdirectory(buzz_proto)
add_subdirectory(buzz_proto)
add_subdirectory(audio)
add_subdirectory(event_mgmt)

View File

@@ -1,3 +1,6 @@
rsource "fw_mgmt/Kconfig"
rsource "fs_mgmt/Kconfig"
rsource "ble_mgmt/Kconfig"
rsource "buzz_proto/Kconfig"
rsource "buzz_proto/Kconfig"
rsource "audio/Kconfig"
rsource "event_mgmt/Kconfig"

View File

@@ -0,0 +1,5 @@
if(CONFIG_AUDIO)
zephyr_library()
zephyr_library_sources(src/audio.c)
zephyr_include_directories(include)
endif()

View File

@@ -0,0 +1,60 @@
menuconfig AUDIO
bool "Audio handling"
default y
select I2S
select POLL
if AUDIO
config AUDIO_NO_SAMPLES_SAMPLE
string "Audio no samples sample"
default "404"
help
Sound do play when no audio files are available. Must be in the sys directory of the filesystem.
config AUDIO_CACHE_SLAB_SIZE
int "Audio slab size"
default 4096
help
Audio cache slab size
config AUDIO_CACHE_SLAB_COUNT
int "Audio slab count"
default 4
help
Number of audio slabs in cache
config AUDIO_THREAD_STACK_SIZE
int "Audio thread stack size"
default 4096
help
Stack size for audio processing thread
config AUDIO_THREAD_PRIORITY
int "Audio thread priority"
default 5
help
Priority for audio processing thread (lower number = higher priority)
config AUDIO_PUMP_THREAD_STACK_SIZE
int "Audio pump thread stack size"
default 8192
help
Stack size for audio pump thread
config AUDIO_PUMP_THREAD_PRIORITY
int "Audio pump thread priority"
default 4
help
Priority for audio pump thread (lower number = higher priority)
config AUDIO_WORKQUEUE_STACK_SIZE
int "Audio workqueue stack size"
default 2048
help
Stack size for audio workqueue
config AUDIO_WORKQUEUE_PRIORITY
int "Audio workqueue priority"
default 10
help
Priority for audio workqueue (lower number = higher priority)
module = AUDIO
module-str = audio
source "subsys/logging/Kconfig.template.log_config"
endif # AUDIO

View File

@@ -0,0 +1,33 @@
#ifndef AUDIO_H
#define AUDIO_H
#include <zephyr/kernel.h>
#include "fs_mgmt.h"
/** Audio command message structure */
struct audio_cmd_msg
{
char filename[CONFIG_FS_MGMT_MAX_PATH_LENGTH]; // Wenn leer, nutze Fallback-Sound
bool is_interrupt; // True = sofort abbrechen, False = Enqueue
};
/**
* @brief Queues an audio playback command to the audio thread
* @param filename Name of the audio file to play. If empty, a random sound will be played
* @param is_interrupt If true, the command will interrupt any currently playing audio. If
* false, it will be enqueued and played after the current audio finishes
* @return 0 on success, negative error code on failure
*/
int audio_queue_play(const char *filename, bool is_interrupt);
/**
* @brief Starts playback of a random audio file from the audio directory. This is a non-blocking call that signals the audio thread to select and play a random sound.
*/
void audio_start_random_playback(void);
/**
* @brief Refreshes the list of available audio files
*/
void audio_refresh_files(void);
#endif /* AUDIO_H */

View File

@@ -0,0 +1,622 @@
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <zephyr/fs/fs.h>
#include <zephyr/drivers/i2s.h>
#include "audio.h"
#include "fs_mgmt.h"
#include "event_mgmt.h"
LOG_MODULE_REGISTER(audio, CONFIG_AUDIO_LOG_LEVEL);
const struct device *i2s_dev = DEVICE_DT_GET(DT_ALIAS(i2s_audio));
K_MSGQ_DEFINE(audio_cmd_q, sizeof(struct audio_cmd_msg), 10, 4);
K_SEM_DEFINE(audio_files_count_sem, 0, 1);
K_SEM_DEFINE(audio_file_select_sem, 0, 1);
K_MEM_SLAB_DEFINE(audio_cache_slab, CONFIG_AUDIO_CACHE_SLAB_SIZE, CONFIG_AUDIO_CACHE_SLAB_COUNT, 4);
struct k_work_q audio_work_q;
K_THREAD_STACK_DEFINE(audio_work_q_stack, CONFIG_AUDIO_WORKQUEUE_STACK_SIZE);
struct k_work select_next_file_work;
enum audio_thread_state_t
{
AUDIO_ARMED,
AUDIO_PRECACHING,
AUDIO_WAIT_FOR_CACHE,
AUDIO_PLAYING,
AUDIO_DRAINING,
};
#define EV_PLAY_RANDOM BIT(0)
#define EV_MSGQ_NOT_EMPTY BIT(1)
#define EV_CACHE_READY BIT(2)
#define EV_CACHE_DONE BIT(3)
#define EV_STATE_STEP BIT(4)
#define EV_AUTOSTART BIT(5)
#define EV_ALL (EV_PLAY_RANDOM | EV_MSGQ_NOT_EMPTY | EV_CACHE_READY | EV_CACHE_DONE | EV_STATE_STEP | EV_AUTOSTART)
K_EVENT_DEFINE(audio_events);
#define AUDIO_CACHE_EVT_START BIT(0)
#define AUDIO_CACHE_EVT_STOP BIT(1)
K_EVENT_DEFINE(audio_cache_event);
struct audio_ctx_t
{
char next_file_name[CONFIG_FS_MGMT_MAX_PATH_LENGTH];
struct fs_file_t file;
bool is_file_open;
ssize_t audio_size;
ssize_t cached_bytes;
} audio_ctx;
static struct i2s_config i2s_cfg = {
.word_size = 16,
.channels = 2,
.format = I2S_FMT_DATA_FORMAT_I2S,
.options = I2S_OPT_BIT_CLK_MASTER | I2S_OPT_FRAME_CLK_MASTER,
.frame_clk_freq = 16000,
.mem_slab = &audio_cache_slab,
.block_size = CONFIG_AUDIO_CACHE_SLAB_SIZE,
.timeout = SYS_FOREVER_MS,
};
K_MUTEX_DEFINE(audio_ctx_mutex);
atomic_t thread_state = ATOMIC_INIT(0);
atomic_t num_files = ATOMIC_INIT(0);
static uint8_t audio_mono_stage[CONFIG_AUDIO_CACHE_SLAB_SIZE / 2];
int audio_queue_play(const char *filename, bool is_interrupt)
{
if (is_interrupt)
{
/* Keep hardware state changes inside audio_thread to avoid cross-thread races. */
k_msgq_purge(&audio_cmd_q);
}
if (strlen(filename) == 0)
{
return 0;
}
if (k_msgq_num_free_get(&audio_cmd_q) == 0)
{
LOG_ERR("Audio command queue is full, cannot enqueue new command");
return -ENOMEM;
}
struct audio_cmd_msg cmd;
strncpy(cmd.filename, filename, sizeof(cmd.filename) - 1);
cmd.filename[sizeof(cmd.filename) - 1] = '\0';
cmd.is_interrupt = is_interrupt;
if (k_msgq_put(&audio_cmd_q, &cmd, K_FOREVER) != 0)
{
LOG_ERR("Failed to enqueue audio command");
return -EFAULT;
}
/*
* Wake immediately for interrupts or when not currently playing.
* Non-interrupt commands during playback are picked up after drain.
*/
if (is_interrupt || (atomic_get(&thread_state) != AUDIO_PLAYING))
{
k_event_set(&audio_events, EV_MSGQ_NOT_EMPTY);
}
LOG_DBG("Enqueued audio command: filename='%s', is_interrupt=%d", cmd.filename, cmd.is_interrupt);
return 0;
}
static int audio_select_random_sound(void)
{
k_sem_reset(&audio_file_select_sem);
if (k_sem_take(&audio_files_count_sem, K_FOREVER) != 0)
{
LOG_ERR("Failed to take audio files count semaphore");
k_sem_give(&audio_files_count_sem);
k_mutex_unlock(&audio_ctx_mutex);
return -EFAULT;
}
int count = atomic_get(&num_files);
k_mutex_lock(&audio_ctx_mutex, K_FOREVER);
if (count == 0)
{
LOG_WRN("No audio files available to select, returning no files sound");
FS_MGMT_ASSEMBLE_PATH(audio_ctx.next_file_name, FS_SYSTEM_PATH, CONFIG_AUDIO_NO_SAMPLES_SAMPLE);
k_sem_give(&audio_file_select_sem);
k_sem_give(&audio_files_count_sem);
k_mutex_unlock(&audio_ctx_mutex);
return -ENOENT;
}
int random_index = k_cycle_get_32() % count;
struct fs_dir_t dir;
struct fs_dirent entry;
int rc;
fs_dir_t_init(&dir);
rc = fs_mgmt_pm_opendir(&dir, FS_AUDIO_PATH);
if (rc < 0)
{
LOG_ERR("Failed to open audio directory '%s': %d", FS_AUDIO_PATH, rc);
k_sem_give(&audio_file_select_sem);
k_sem_give(&audio_files_count_sem);
k_mutex_unlock(&audio_ctx_mutex);
return rc;
}
int current_index = 0;
bool found = false;
while (1)
{
rc = fs_readdir(&dir, &entry);
if (rc < 0)
{
LOG_ERR("Directory read error: %d", rc);
break;
}
if (entry.name[0] == '\0')
{
break;
}
if (entry.type == FS_DIR_ENTRY_FILE)
{
if (current_index == random_index)
{
FS_MGMT_ASSEMBLE_PATH(audio_ctx.next_file_name, FS_AUDIO_PATH, entry.name);
LOG_DBG("Selected random audio file: %s", audio_ctx.next_file_name);
found = true;
break;
}
current_index++;
}
}
fs_mgmt_pm_closedir(&dir);
k_sem_give(&audio_file_select_sem);
k_sem_give(&audio_files_count_sem);
k_mutex_unlock(&audio_ctx_mutex);
return found ? 0 : -ENOENT;
}
void audio_refresh_files(void)
{
// Lokale Strukturen verwenden, um Reentrancy-Probleme zu vermeiden
struct fs_dir_t dir;
struct fs_dirent entry;
int count = 0;
int rc;
k_sem_reset(&audio_files_count_sem);
fs_dir_t_init(&dir);
// Nutze deinen PM-Wrapper für den Flash-Zugriff
rc = fs_mgmt_pm_opendir(&dir, FS_AUDIO_PATH);
if (rc < 0)
{
LOG_ERR("Failed to open audio directory '%s': %d", FS_AUDIO_PATH, rc);
atomic_set(&num_files, 0);
return;
}
while (1)
{
rc = fs_readdir(&dir, &entry);
if (rc < 0)
{
LOG_ERR("Directory read error: %d", rc);
break;
}
if (entry.name[0] == '\0')
{
break;
}
if (entry.type == FS_DIR_ENTRY_FILE)
{
count++;
}
}
fs_mgmt_pm_closedir(&dir);
k_sem_give(&audio_files_count_sem);
atomic_set(&num_files, count);
audio_select_random_sound();
}
static void select_next_file_work_handler(struct k_work *work)
{
ARG_UNUSED(work);
LOG_DBG("Select next file work handler");
audio_select_random_sound();
}
static int audio_init(void)
{
struct k_work_queue_config audio_work_q_config = {
.name = "audio_work_q",
.no_yield = false,
.essential = true,
.work_timeout_ms = 0};
k_work_queue_start(&audio_work_q,
audio_work_q_stack,
K_THREAD_STACK_SIZEOF(audio_work_q_stack),
CONFIG_AUDIO_WORKQUEUE_PRIORITY, &audio_work_q_config);
k_work_init(&select_next_file_work, select_next_file_work_handler);
if (!device_is_ready(i2s_dev))
{
LOG_ERR("I2S device not ready");
return -ENODEV;
}
int rc = i2s_configure(i2s_dev, I2S_DIR_TX, &i2s_cfg);
if (rc < 0)
{
LOG_ERR("Failed to configure I2S: %d", rc);
return rc;
}
LOG_DBG("Audio module initialized");
return 0;
}
SYS_INIT(audio_init, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY); // Stelle sicher, dass dies nach der FS-Initialisierung erfolgt
static void audio_trigger_next_file_selection(void)
{
k_sem_reset(&audio_file_select_sem);
LOG_DBG("Triggering workq file selection");
k_work_submit_to_queue(&audio_work_q, &select_next_file_work);
}
void audio_start_random_playback(void)
{
LOG_DBG("audio_start_random_playback called");
k_event_set(&audio_events, EV_PLAY_RANDOM);
}
void audio_thread(void *arg1, void *arg2, void *arg3)
{
ARG_UNUSED(arg1);
ARG_UNUSED(arg2);
ARG_UNUSED(arg3);
k_event_wait(&event_mgmt_events, EVENT_MGMT_FS_READY, false, K_FOREVER);
audio_refresh_files();
atomic_set(&thread_state, AUDIO_PRECACHING);
k_event_set(&audio_events, EV_STATE_STEP);
while (1)
{
enum audio_thread_state_t state = atomic_get(&thread_state);
k_timeout_t timeout = (state == AUDIO_DRAINING) ? K_MSEC(10) : K_FOREVER;
uint32_t active_events = k_event_wait(&audio_events, EV_ALL, false, timeout);
if (active_events & EV_STATE_STEP)
{
k_event_clear(&audio_events, EV_STATE_STEP);
}
if (active_events & EV_PLAY_RANDOM)
{
LOG_DBG("Play random event received");
k_event_clear(&audio_events, EV_PLAY_RANDOM);
if (state == AUDIO_ARMED)
{
i2s_trigger(i2s_dev, I2S_DIR_TX, I2S_TRIGGER_START);
atomic_set(&thread_state, AUDIO_PLAYING);
}
else
{
k_event_set(&audio_cache_event, AUDIO_CACHE_EVT_STOP);
i2s_trigger(i2s_dev, I2S_DIR_TX, I2S_TRIGGER_DROP);
audio_select_random_sound();
atomic_set(&thread_state, AUDIO_PRECACHING);
k_event_set(&audio_events, EV_STATE_STEP);
}
continue;
}
switch (state)
{
case AUDIO_PRECACHING:
LOG_DBG("Audio thread starting precache task");
k_event_set(&audio_cache_event, AUDIO_CACHE_EVT_START);
atomic_set(&thread_state, AUDIO_WAIT_FOR_CACHE);
break;
case AUDIO_WAIT_FOR_CACHE:
if (active_events & EV_CACHE_READY)
{
k_event_clear(&audio_events, EV_CACHE_READY);
atomic_set(&thread_state, AUDIO_ARMED);
if (k_event_wait(&audio_events, EV_AUTOSTART, false, K_NO_WAIT) & EV_AUTOSTART)
{
k_event_clear(&audio_events, EV_AUTOSTART);
i2s_trigger(i2s_dev, I2S_DIR_TX, I2S_TRIGGER_START);
atomic_set(&thread_state, AUDIO_PLAYING);
LOG_DBG("Autostarting queued audio playback");
}
else
{
LOG_DBG("System Armed. Waiting for Buzzer...");
audio_trigger_next_file_selection();
}
}
break;
case AUDIO_ARMED:
if (active_events & EV_MSGQ_NOT_EMPTY)
{
k_event_clear(&audio_events, EV_MSGQ_NOT_EMPTY);
i2s_trigger(i2s_dev, I2S_DIR_TX, I2S_TRIGGER_DROP);
k_event_set(&audio_cache_event, AUDIO_CACHE_EVT_STOP);
struct audio_cmd_msg cmd;
k_msgq_get(&audio_cmd_q, &cmd, K_NO_WAIT);
k_mutex_lock(&audio_ctx_mutex, K_FOREVER);
strncpy(audio_ctx.next_file_name, cmd.filename, sizeof(audio_ctx.next_file_name) - 1);
audio_ctx.next_file_name[sizeof(audio_ctx.next_file_name) - 1] = '\0';
k_mutex_unlock(&audio_ctx_mutex);
k_event_set(&audio_events, EV_AUTOSTART);
atomic_set(&thread_state, AUDIO_PRECACHING);
k_event_set(&audio_events, EV_STATE_STEP);
}
break;
case AUDIO_PLAYING:
if (active_events & EV_MSGQ_NOT_EMPTY)
{
k_event_clear(&audio_events, EV_MSGQ_NOT_EMPTY);
struct audio_cmd_msg cmd;
if (k_msgq_peek(&audio_cmd_q, &cmd) == 0)
{
if (cmd.is_interrupt)
{
i2s_trigger(i2s_dev, I2S_DIR_TX, I2S_TRIGGER_DROP);
k_event_set(&audio_cache_event, AUDIO_CACHE_EVT_STOP);
if (k_msgq_get(&audio_cmd_q, &cmd, K_NO_WAIT) == 0)
{
k_mutex_lock(&audio_ctx_mutex, K_FOREVER);
strncpy(audio_ctx.next_file_name, cmd.filename, sizeof(audio_ctx.next_file_name) - 1);
audio_ctx.next_file_name[sizeof(audio_ctx.next_file_name) - 1] = '\0';
k_mutex_unlock(&audio_ctx_mutex);
k_event_set(&audio_events, EV_AUTOSTART);
atomic_set(&thread_state, AUDIO_PRECACHING);
k_event_set(&audio_events, EV_STATE_STEP);
}
}
else
{
LOG_DBG("Non-interrupt command queued during playback; will process after drain");
}
}
break;
}
if (active_events & EV_CACHE_DONE)
{
k_event_clear(&audio_events, EV_CACHE_DONE);
i2s_trigger(i2s_dev, I2S_DIR_TX, I2S_TRIGGER_DRAIN);
atomic_set(&thread_state, AUDIO_DRAINING);
}
break;
case AUDIO_DRAINING:
if (k_mem_slab_num_free_get(&audio_cache_slab) == CONFIG_AUDIO_CACHE_SLAB_COUNT)
{
LOG_DBG("Audio for file drained, ready for next file");
if (k_msgq_num_used_get(&audio_cmd_q) > 0)
{
k_event_set(&audio_events, EV_MSGQ_NOT_EMPTY);
}
else
{
atomic_set(&thread_state, AUDIO_PRECACHING);
k_event_set(&audio_events, EV_STATE_STEP);
}
}
break;
}
}
}
K_THREAD_DEFINE(audio_thread_id, CONFIG_AUDIO_THREAD_STACK_SIZE, audio_thread, NULL, NULL, NULL,
CONFIG_AUDIO_THREAD_PRIORITY, 0, 0);
void audio_pump_thread(void *arg1, void *arg2, void *arg3)
{
ARG_UNUSED(arg1);
ARG_UNUSED(arg2);
ARG_UNUSED(arg3);
uint8_t num_channels = 1;
void *mem_slab;
while (1)
{
uint32_t events = k_event_wait(&audio_cache_event, AUDIO_CACHE_EVT_START | AUDIO_CACHE_EVT_STOP, false, K_FOREVER);
if (events & AUDIO_CACHE_EVT_STOP)
{
k_mutex_lock(&audio_ctx_mutex, K_FOREVER);
if (audio_ctx.is_file_open)
{
fs_close(&audio_ctx.file);
audio_ctx.is_file_open = false;
}
k_mutex_unlock(&audio_ctx_mutex);
k_event_clear(&audio_cache_event, AUDIO_CACHE_EVT_START | AUDIO_CACHE_EVT_STOP);
continue;
}
int rc = k_mem_slab_alloc(&audio_cache_slab, &mem_slab, K_NO_WAIT);
if (rc == -ENOMEM)
{
k_event_set(&audio_events, EV_CACHE_READY);
rc = k_mem_slab_alloc(&audio_cache_slab, &mem_slab, K_FOREVER);
if (k_event_wait(&audio_cache_event, AUDIO_CACHE_EVT_STOP, false, K_NO_WAIT))
{
k_mem_slab_free(&audio_cache_slab, &mem_slab);
continue;
}
}
if (rc == 0)
{
k_mutex_lock(&audio_ctx_mutex, K_FOREVER);
if (!audio_ctx.is_file_open)
{
rc = fs_mgmt_pm_open(&audio_ctx.file, audio_ctx.next_file_name, FS_O_READ);
if (rc < 0)
{
LOG_ERR("Failed to open audio file '%s': %d", audio_ctx.next_file_name, rc);
k_mem_slab_free(&audio_cache_slab, &mem_slab);
k_event_clear(&audio_cache_event, AUDIO_CACHE_EVT_START);
k_mutex_unlock(&audio_ctx_mutex);
continue;
}
audio_ctx.is_file_open = true;
// NEU: Größen- und Zähler-Initialisierung exklusiv hier!
audio_ctx.audio_size = fs_mgmt_get_audio_data_len(&audio_ctx.file);
audio_ctx.cached_bytes = 0;
LOG_DBG("Audio file '%s' opened for caching, size: %d", audio_ctx.next_file_name, (int)audio_ctx.audio_size);
}
if (audio_ctx.audio_size <= 0)
{
LOG_ERR("Invalid audio size for '%s': %d", audio_ctx.next_file_name, (int)audio_ctx.audio_size);
k_mem_slab_free(&audio_cache_slab, &mem_slab);
fs_close(&audio_ctx.file);
audio_ctx.is_file_open = false;
k_event_clear(&audio_cache_event, AUDIO_CACHE_EVT_START);
k_mutex_unlock(&audio_ctx_mutex);
continue;
}
ssize_t remaining_bytes = audio_ctx.audio_size - audio_ctx.cached_bytes;
if (remaining_bytes <= 0)
{
k_mem_slab_free(&audio_cache_slab, &mem_slab);
fs_close(&audio_ctx.file);
audio_ctx.is_file_open = false;
k_event_clear(&audio_cache_event, AUDIO_CACHE_EVT_START);
k_mutex_unlock(&audio_ctx_mutex);
k_event_set(&audio_events, EV_CACHE_DONE);
continue;
}
ssize_t bytes_to_read = MIN(CONFIG_AUDIO_CACHE_SLAB_SIZE / 2, remaining_bytes);
ssize_t bytes_read = fs_read(&audio_ctx.file, audio_mono_stage, bytes_to_read);
if (bytes_read <= 0) // <= 0, um EOF (0) und Fehler (< 0) abzufangen
{
if (bytes_read < 0)
{
LOG_ERR("Failed to read audio data: %d", (int)bytes_read);
}
k_mem_slab_free(&audio_cache_slab, &mem_slab);
// EOF erreicht -> Datei schließen und START-Bit löschen
fs_close(&audio_ctx.file);
audio_ctx.is_file_open = false;
k_event_clear(&audio_cache_event, AUDIO_CACHE_EVT_START);
k_mutex_unlock(&audio_ctx_mutex);
k_event_set(&audio_events, EV_CACHE_DONE);
continue;
}
audio_ctx.cached_bytes += bytes_read;
// LOG_DBG("Cached %u%% ", (int)((audio_ctx.cached_bytes * 100) / audio_ctx.audio_size));
if (bytes_read > CONFIG_AUDIO_CACHE_SLAB_SIZE / 2)
{
LOG_ERR("Read size %d exceeds half slab size %d", (int)bytes_read, CONFIG_AUDIO_CACHE_SLAB_SIZE / 2);
k_mem_slab_free(&audio_cache_slab, &mem_slab);
fs_close(&audio_ctx.file);
audio_ctx.is_file_open = false;
k_event_clear(&audio_cache_event, AUDIO_CACHE_EVT_START);
k_mutex_unlock(&audio_ctx_mutex);
continue;
}
if ((bytes_read & 0x1) != 0)
{
if (bytes_read >= (CONFIG_AUDIO_CACHE_SLAB_SIZE / 2))
{
LOG_ERR("Odd mono byte count at half-slab boundary: %d", (int)bytes_read);
k_mem_slab_free(&audio_cache_slab, &mem_slab);
fs_close(&audio_ctx.file);
audio_ctx.is_file_open = false;
k_event_clear(&audio_cache_event, AUDIO_CACHE_EVT_START);
k_mutex_unlock(&audio_ctx_mutex);
continue;
}
audio_mono_stage[bytes_read] = 0;
bytes_read++;
}
if (bytes_read < CONFIG_AUDIO_CACHE_SLAB_SIZE / 2)
{
memset(audio_mono_stage + bytes_read, 0, (CONFIG_AUDIO_CACHE_SLAB_SIZE / 2) - bytes_read);
bytes_read = CONFIG_AUDIO_CACHE_SLAB_SIZE / 2;
}
if (num_channels == 1)
{
uint8_t *dst = (uint8_t *)mem_slab;
for (size_t i = 0; i < (size_t)bytes_read; i += 2)
{
uint8_t lo = audio_mono_stage[i];
uint8_t hi = audio_mono_stage[i + 1];
size_t out = i * 2;
dst[out] = lo;
dst[out + 1] = hi;
dst[out + 2] = lo;
dst[out + 3] = hi;
}
}
k_mutex_unlock(&audio_ctx_mutex);
if (i2s_write(i2s_dev, mem_slab, CONFIG_AUDIO_CACHE_SLAB_SIZE) < 0)
{
LOG_ERR("Failed to write audio data to I2S");
k_mem_slab_free(&audio_cache_slab, &mem_slab);
continue;
}
}
}
}
K_THREAD_DEFINE(audio_cache_thread_id, CONFIG_AUDIO_PUMP_THREAD_STACK_SIZE, audio_pump_thread, NULL, NULL, NULL,
CONFIG_AUDIO_PUMP_THREAD_PRIORITY, 0, 0);

View File

@@ -1,5 +1,6 @@
menuconfig BLE_MGMT
bool "Bluetooth Management"
default n
select BT
select BT_PERIPHERAL
select BT_LOG_LEVEL_WARN
@@ -8,6 +9,12 @@ menuconfig BLE_MGMT
Library for initializing and managing Bluetooth functionality.
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
string "Default Bluetooth Device Name"
default "Edis Buzzer"
@@ -21,41 +28,63 @@ if BLE_MGMT
default 160
help
Maximal advertising interval. 160 equals to 100ms.
# 1. MTU und Data Length (Maximale Paketgrößen)
config BT_L2CAP_TX_MTU
default 247
# Airtime
config BT_CTLR_SDC_MAX_CONN_EVENT_LEN_DEFAULT
default 4000000
# MTU Setup
config BT_BUF_ACL_RX_SIZE
default 251
default 502
config BT_BUF_ACL_TX_SIZE
default 251
default 502
config BT_L2CAP_TX_MTU
default 498
config BT_CTLR_DATA_LENGTH_MAX
default 251
# Buffers
config BT_BUF_ACL_TX_COUNT
default 15
config BT_L2CAP_TX_BUF_COUNT
default 15
config BT_CONN_TX_MAX
default 15
config BT_CTLR_SDC_TX_PACKET_COUNT
default 15
config BT_CTLR_SDC_RX_PACKET_COUNT
default 15
config BT_BUF_EVT_RX_COUNT
default 16
# Callbacks
config BT_USER_PHY_UPDATE
default y
config BT_USER_DATA_LEN_UPDATE
default y
# 2. Physical Layer (Erlaubt 2M PHY)
config BT_USER_PHY_UPDATE
# Automatic updates
config BT_AUTO_PHY_UPDATE
default y
config BT_AUTO_DATA_LEN_UPDATE
default y
config BT_GAP_AUTO_UPDATE_CONN_PARAMS
default y
# 3. Flow-Control und Queues (High Throughput, Host + SDC Controller synchronisiert)
config BT_HCI_ACL_FLOW_CONTROL
default y
config BT_BUF_EVT_RX_COUNT
default 22
config BT_BUF_ACL_TX_COUNT
default 20
config BT_L2CAP_TX_BUF_COUNT
default 20
config BT_CONN_TX_MAX
default 20
# 4. SDC Controller Buffering (an Host-Tiefen angeglichen)
config BT_CTLR_SDC_TX_PACKET_COUNT
default 20
config BT_CTLR_SDC_RX_PACKET_COUNT
default 20
# Preferred defaults
config BT_PERIPHERAL_PREF_MIN_INT
default 6
config BT_PERIPHERAL_PREF_MAX_INT
default 40
config BT_PERIPHERAL_PREF_LATENCY
default 0
config BT_PERIPHERAL_PREF_TIMEOUT
default 400
# Connections
config BT_MAX_CONN
default 2
module = BLE_MGMT
module-str = ble_mgmt
source "subsys/logging/Kconfig.template.log_config"

View File

@@ -5,6 +5,8 @@
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/logging/log.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/conn.h>
#include <string.h>
#include "ble_mgmt.h"
@@ -24,7 +26,9 @@ static struct bt_uuid_128 buzz_tx_uuid = BT_UUID_INIT_128(BUZZ_TX_UUID_VAL);
static ble_mgmt_rx_cb_t app_rx_cb = NULL;
static bool notify_enabled = false;
static uint16_t current_tx_mtu = 23;
static uint16_t current_rx_mtu = 23;
#define MAX_ADV_NAME_LEN 29
static char current_device_name[MAX_ADV_NAME_LEN + 1];
@@ -52,6 +56,7 @@ static void att_mtu_updated(struct bt_conn *conn, uint16_t tx, uint16_t rx)
{
LOG_INF("MTU exchanged: TX %u bytes, RX %u bytes", tx, rx);
current_tx_mtu = tx;
current_rx_mtu = rx;
}
static ssize_t rx_cb(struct bt_conn *conn, const struct bt_gatt_attr *attr,
@@ -59,8 +64,9 @@ static ssize_t rx_cb(struct bt_conn *conn, const struct bt_gatt_attr *attr,
{
LOG_DBG("Received %u bytes", len);
LOG_HEXDUMP_DBG(buf, len, "Data:");
if (app_rx_cb) {
if (app_rx_cb)
{
app_rx_cb((const uint8_t *)buf, len);
}
return len;
@@ -69,25 +75,25 @@ static ssize_t rx_cb(struct bt_conn *conn, const struct bt_gatt_attr *attr,
static void tx_ccc_cfg_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
notify_enabled = (value == BT_GATT_CCC_NOTIFY);
LOG_DBG("Notifications %s", notify_enabled ? "enabled" : "disabled");
LOG_INF("Notifications %s", notify_enabled ? "enabled" : "disabled");
}
BT_GATT_SERVICE_DEFINE(ble_mgmt_svc,
BT_GATT_PRIMARY_SERVICE(&buzz_service_uuid),
BT_GATT_CHARACTERISTIC(&buzz_rx_uuid.uuid, BT_GATT_CHRC_WRITE_WITHOUT_RESP,
BT_GATT_PERM_WRITE, NULL, rx_cb, NULL),
BT_GATT_CHARACTERISTIC(&buzz_tx_uuid.uuid, BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_NONE, NULL, NULL, NULL),
BT_GATT_CCC(tx_ccc_cfg_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE)
);
BT_GATT_PRIMARY_SERVICE(&buzz_service_uuid),
BT_GATT_CHARACTERISTIC(&buzz_rx_uuid.uuid, BT_GATT_CHRC_WRITE_WITHOUT_RESP,
BT_GATT_PERM_WRITE, NULL, rx_cb, NULL),
BT_GATT_CHARACTERISTIC(&buzz_tx_uuid.uuid, BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_NONE, NULL, NULL, NULL),
BT_GATT_CCC(tx_ccc_cfg_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE));
uint16_t ble_mgmt_get_max_payload(void)
{
/* Kappe die verhandelte MTU auf die hart konfigurierte Zephyr-Puffergrenze */
uint16_t effective_mtu = current_tx_mtu;
uint16_t effective_mtu = MIN(current_tx_mtu, current_rx_mtu);
#ifdef CONFIG_BT_L2CAP_TX_MTU
if (effective_mtu > CONFIG_BT_L2CAP_TX_MTU) {
if (effective_mtu > CONFIG_BT_L2CAP_TX_MTU)
{
effective_mtu = CONFIG_BT_L2CAP_TX_MTU;
}
#endif
@@ -98,19 +104,23 @@ uint16_t ble_mgmt_get_max_payload(void)
int ble_mgmt_send(const uint8_t *data, uint16_t len)
{
if (!notify_enabled) {
if (!notify_enabled)
{
return -EACCES;
}
int rc;
do {
do
{
rc = bt_gatt_notify(NULL, &ble_mgmt_svc.attrs[4], data, len);
if (rc == -ENOMEM) {
if (rc == -ENOMEM)
{
k_sleep(K_MSEC(5)); // Thread pausieren, bis TX-Buffer frei wird
}
} while (rc == -ENOMEM);
if (rc) {
if (rc)
{
LOG_ERR("Failed to send notification (err %d)", rc);
return rc;
}
@@ -120,13 +130,14 @@ int ble_mgmt_send(const uint8_t *data, uint16_t len)
/* Interne Hilfsfunktion zur Zuweisung des Namens */
static void set_device_name(const char *name)
{
if (!name) {
if (!name)
{
return;
}
strncpy(current_device_name, name, MAX_ADV_NAME_LEN);
current_device_name[MAX_ADV_NAME_LEN] = '\0';
/* Längen-Update im Scan-Response Array */
sd[0].data_len = strlen(current_device_name);
@@ -144,7 +155,8 @@ int ble_mgmt_update_adv_name(const char *new_name)
set_device_name(new_name);
rc = bt_le_adv_start(&adv_param, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
if (rc) {
if (rc)
{
LOG_ERR("Advertising failed to restart after name update (err %d)", rc);
return rc;
}
@@ -153,40 +165,10 @@ int ble_mgmt_update_adv_name(const char *new_name)
return 0;
}
int ble_mgmt_init(ble_mgmt_rx_cb_t rx_cb, const char *device_name)
{
int rc;
app_rx_cb = rx_cb;
static struct bt_gatt_cb gatt_callbacks = {
.att_mtu_updated = att_mtu_updated,
};
bt_gatt_cb_register(&gatt_callbacks);
rc = bt_enable(NULL);
if (rc) {
LOG_ERR("Bluetooth init failed (err %d)", rc);
return rc;
}
const char *name_to_use = (device_name != NULL) ? device_name : CONFIG_BLE_MGMT_DEFAULT_DEVICE_NAME;
set_device_name(name_to_use);
rc = bt_le_adv_start(&adv_param, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
if (rc) {
LOG_ERR("Advertising failed to start (err %d)", rc);
return rc;
}
LOG_INF("Bluetooth initialized. Adv-Name: %s", current_device_name);
return 0;
}
static void connected(struct bt_conn *conn, uint8_t err)
{
if (err) {
if (err)
{
LOG_ERR("Connection failed (err 0x%02x)", err);
return;
}
@@ -195,50 +177,38 @@ static void connected(struct bt_conn *conn, uint8_t err)
struct bt_conn_info info;
int rc = bt_conn_get_info(conn, &info);
if (rc == 0) {
if (rc == 0)
{
bt_addr_le_to_str(info.le.dst, addr_str, sizeof(addr_str));
LOG_INF("Connected to %s", addr_str);
/* Nur noch die Rolle ausgeben, da Timing-Parameter hier deprecated sind */
LOG_DBG("Role: %s", info.role == BT_CONN_ROLE_CENTRAL ? "Central" : "Peripheral");
} else {
LOG_INF("Role: %s", info.role == BT_CONN_ROLE_CENTRAL ? "Central" : "Peripheral");
}
else
{
LOG_INF("Connected (info retrieval failed)");
}
struct bt_conn_le_phy_param phy_param = {
.options = BT_CONN_LE_PHY_OPT_NONE,
.pref_tx_phy = BT_GAP_LE_PHY_2M,
.pref_rx_phy = BT_GAP_LE_PHY_2M,
};
rc = bt_conn_le_phy_update(conn, &phy_param);
if (rc) {
LOG_WRN("PHY update failed (err %d)", rc);
}
struct bt_le_conn_param *param = BT_LE_CONN_PARAM(12, 24, 0, 400);
rc = bt_conn_le_param_update(conn, param);
if (rc) {
LOG_WRN("Connection update failed (err %d)", rc);
}
}
static void disconnected(struct bt_conn *conn, uint8_t reason)
{
LOG_DBG("Disconnected (reason 0x%02x)", reason);
LOG_INF("Disconnected (reason 0x%02x)", reason);
/* Startet Advertising mit dem global definierten Setup neu */
int rc = bt_le_adv_start(&adv_param, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
if (rc) {
if (rc)
{
LOG_ERR("Advertising failed to restart (err %d)", rc);
} else {
LOG_DBG("Advertising successfully restarted");
}
else
{
LOG_INF("Advertising successfully restarted");
}
}
static void le_phy_updated(struct bt_conn *conn, struct bt_conn_le_phy_info *param)
{
const char *tx_phy_str = (param->tx_phy == BT_GAP_LE_PHY_2M) ? "2M" :
(param->tx_phy == BT_GAP_LE_PHY_1M) ? "1M" : "Coded/Unknown";
const char *rx_phy_str = (param->rx_phy == BT_GAP_LE_PHY_2M) ? "2M" :
(param->rx_phy == BT_GAP_LE_PHY_1M) ? "1M" : "Coded/Unknown";
const char *tx_phy_str = (param->tx_phy == BT_GAP_LE_PHY_2M) ? "2M" : (param->tx_phy == BT_GAP_LE_PHY_1M) ? "1M" : "Coded/Unknown";
const char *rx_phy_str = (param->rx_phy == BT_GAP_LE_PHY_2M) ? "2M" : (param->rx_phy == BT_GAP_LE_PHY_1M) ? "1M" : "Coded/Unknown";
LOG_INF("LE PHY updated: TX PHY %s, RX PHY %s", tx_phy_str, rx_phy_str);
}
@@ -256,3 +226,35 @@ BT_CONN_CB_DEFINE(conn_callbacks) = {
.le_param_updated = le_param_updated,
.le_phy_updated = le_phy_updated,
};
int ble_mgmt_init(ble_mgmt_rx_cb_t rx_cb, const char *device_name)
{
int rc;
app_rx_cb = rx_cb;
static struct bt_gatt_cb gatt_callbacks = {
.att_mtu_updated = att_mtu_updated,
};
bt_gatt_cb_register(&gatt_callbacks);
rc = bt_enable(NULL);
if (rc)
{
LOG_ERR("Bluetooth init failed (err %d)", rc);
return rc;
}
const char *name_to_use = (device_name != NULL) ? device_name : CONFIG_BLE_MGMT_DEFAULT_DEVICE_NAME;
set_device_name(name_to_use);
rc = bt_le_adv_start(&adv_param, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
if (rc)
{
LOG_ERR("Advertising failed to start (err %d)", rc);
return rc;
}
LOG_INF("Bluetooth initialized. Adv-Name: %s", current_device_name);
return 0;
}

View File

@@ -1,12 +1,13 @@
menuconfig BUZZ_PROTO
bool "Buzzer Protocol"
default y
select CRC
help
Library for initializing and managing the buzzer protocol.
config BUZZ_PROTO_SLAB_SIZE
int "Slab Size"
default 256
default 512
help
Size of the memory slabs used for message buffers. Must be large enough to hold the largest expected message.
@@ -18,7 +19,7 @@ menuconfig BUZZ_PROTO
config BUZZ_PROTO_MSGQ_SIZE
int "Message Queue Size"
default 16
default 64
help
Number of messages that can be queued for processing. Adjust based on expected message burstiness.

View File

@@ -15,6 +15,7 @@ enum buzz_frame_type
BUZZ_FRAME_RESPONSE = 0x10,
BUZZ_FRAME_ACK = 0x11,
BUZZ_FRAME_ERROR = 0x12,
BUZZ_FRAME_SUCCESS = 0x13,
BUZZ_FRAME_FILE_START = 0x20,
BUZZ_FRAME_FILE_CHUNK = 0x21,
@@ -34,9 +35,16 @@ enum buzz_data_type
BUZZ_DATA_PROTO_INFO = 0x01,
BUZZ_DATA_DEVICE_INFO = 0x02,
BUZZ_DATA_FS_INFO = 0x03,
BUZZ_DATA_FW_INFO = 0x04,
BUZZ_DATA_FILE_GET = 0x20,
BUZZ_DATA_FILE_PUT = 0x21,
BUZZ_DATA_TAGS_GET = 0x22,
BUZZ_DATA_TAGS_PUT = 0x23,
BUZZ_DATA_RM_FILE = 0x24,
BUZZ_DATA_RENAME_FILE = 0x25,
BUZZ_DATA_FW_UPDATE = 0x30,
BUZZ_DATA_LS = 0x40,
};
@@ -62,6 +70,11 @@ struct __attribute__((packed)) buzz_resp_error
uint16_t error_code; /* Bis 0xFF reserviert für Standard-Fehler, 0x100+ für spezifische Fehler */
};
struct __attribute__((packed)) buzz_resp_success
{
uint8_t data_type; /* Der Befehl, der erfolgreich war (z.B. BUZZ_DATA_FILE_PUT) */
};
/* Payload für eine Standard-Anfrage (Request) */
struct __attribute__((packed)) buzz_request_payload
{
@@ -76,6 +89,17 @@ struct __attribute__((packed)) buzz_resp_proto_version
uint16_t max_chunk_size; /* Little Endian */
};
/* Payload für die Geräteinformationen */
struct __attribute__((packed)) buzz_resp_device_info
{
uint8_t data_type; /* BUZZ_DATA_DEVICE_INFO */
uint8_t device_id[8]; /* EUI64 oder ähnliche eindeutige ID */
uint8_t board_name_length; /* Länge des Board-Namens */
uint8_t board_revision_length; /* Länge der Board-Revision */
uint8_t soc_name_length; /* Länge des SOC-Namens */
char data[]; /* Variabler String ohne Null-Terminierung: [board_name][board_revision][soc_name] */
};
/* Payload für die Dateisystem-Informationen */
struct __attribute__((packed)) buzz_resp_fs_info
{
@@ -88,6 +112,33 @@ struct __attribute__((packed)) buzz_resp_fs_info
uint8_t data[]; /* Pfadnamen */
};
/* Payload für die Firmware-Infos */
struct __attribute__((packed)) buzz_resp_fw_info
{
uint8_t data_type; /* BUZZ_DATA_FW_INFO */
uint8_t fw_status; /* fw_state_t */
uint32_t slot1_size; /* Größe des Slot1-Partitionsbereichs (Little Endian) */
uint8_t fw_version_length; /* Länge der Firmware-Versionszeichenkette */
uint8_t kernel_version_length; /* Länge der Kernel-Versionszeichenkette */
char data[]; /* Variabler String ohne Null-Terminierung: [fw_version][kernel_version] */
};
/* Payload für das Entfernen einer Datei */
struct __attribute__((packed)) buzz_rm_file_payload
{
uint8_t data_type; /* BUZZ_DATA_RM_FILE */
uint8_t path_length;
char path[]; /* Variabler String ohne Null-Terminierung */
};
/* Payload für das Umbenennen einer Datei */
struct __attribute__((packed)) buzz_rename_file_payload
{
uint8_t data_type; /* BUZZ_DATA_RENAME_FILE */
uint8_t old_path_length;
uint8_t new_path_length;
char paths[]; /* Variabler String ohne Null-Terminierung */
};
/* Payload für das Credit-System (ACK) */
struct __attribute__((packed)) buzz_ack_payload
{
@@ -142,4 +193,16 @@ void buzz_proto_buf_free(uint8_t **buf);
/* Übergabe eines empfangenen Frames an den Protokoll-Thread */
int buzz_proto_submit_frame(struct buzz_frame_msg *msg);
/* Gibt die Anzahl der freien Slabs zurück (abzüglich Reserve) */
uint16_t buzz_proto_get_free_rx_slabs(void);
/* Baut und sendet ein ACK Frame */
void buzz_proto_send_ack(buzz_transport_reply_fn reply_cb, uint16_t credits);
/* Sendet einen Success-Frame unter Wiederverwendung eines bestehenden Slabs (Zero-Copy) */
void buzz_proto_send_success_reusing_slab(buzz_transport_reply_fn reply_cb, uint8_t data_type, uint8_t *slab);
/* Sendet einen Error-Frame unter Wiederverwendung eines bestehenden Slabs (Zero-Copy) */
void buzz_proto_send_error_reusing_slab(buzz_transport_reply_fn reply_cb, uint16_t error_code, uint8_t *slab);
#endif /* BUZZ_PROTO_H */

View File

@@ -8,6 +8,7 @@
#include "buzz_proto.h"
#include "fs_mgmt.h"
#include "fw_mgmt.h"
LOG_MODULE_REGISTER(buzz_proto, CONFIG_BUZZ_PROTO_LOG_LEVEL);
K_MEM_SLAB_DEFINE(buzz_proto_slabs, CONFIG_BUZZ_PROTO_SLAB_SIZE, CONFIG_BUZZ_PROTO_SLAB_COUNT, 4);
@@ -63,7 +64,7 @@ enum stream_state_t
static enum stream_state_t current_stream = STREAM_IDLE;
static char src_path[FS_MGMT_MAX_PATH_LENGTH], dst_path[FS_MGMT_MAX_PATH_LENGTH];
static char src_path[CONFIG_FS_MGMT_MAX_PATH_LENGTH], dst_path[CONFIG_FS_MGMT_MAX_PATH_LENGTH];
int buzz_proto_buf_alloc(uint8_t **buf)
{
@@ -74,7 +75,7 @@ void buzz_proto_buf_free(uint8_t **buf)
{
if (buf && *buf)
{
k_mem_slab_free(&buzz_proto_slabs, (void **)*buf);
k_mem_slab_free(&buzz_proto_slabs, *buf);
*buf = NULL;
}
}
@@ -84,6 +85,8 @@ int buzz_proto_submit_frame(struct buzz_frame_msg *msg)
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)
{
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
@@ -99,10 +102,70 @@ 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);
}
uint16_t buzz_proto_get_free_rx_slabs(void)
{
uint32_t free_slabs = k_mem_slab_num_free_get(&buzz_proto_slabs);
return (free_slabs > 4) ? (uint16_t)(free_slabs - 4) : 0;
}
void buzz_proto_send_ack(buzz_transport_reply_fn reply_cb, uint16_t credits)
{
if (!reply_cb || credits == 0)
return;
uint8_t *buf;
if (buzz_proto_buf_alloc(&buf) == 0)
{
struct buzz_proto_header *hdr = (struct buzz_proto_header *)buf;
hdr->frame_type = BUZZ_FRAME_ACK;
hdr->payload_length = sys_cpu_to_le16(sizeof(struct buzz_ack_payload));
struct buzz_ack_payload *pl = (struct buzz_ack_payload *)(buf + sizeof(*hdr));
pl->credits = sys_cpu_to_le16(credits);
reply_cb(buf, sizeof(*hdr) + sizeof(*pl));
buzz_proto_buf_free(&buf);
}
}
void buzz_proto_send_success_reusing_slab(buzz_transport_reply_fn reply_cb, uint8_t data_type, uint8_t *slab)
{
if (!reply_cb || !slab)
return;
struct buzz_proto_header *hdr = (struct buzz_proto_header *)slab;
hdr->frame_type = BUZZ_FRAME_SUCCESS;
hdr->payload_length = sys_cpu_to_le16(sizeof(struct buzz_resp_success));
struct buzz_resp_success *succ = (struct buzz_resp_success *)(slab + sizeof(*hdr));
succ->data_type = data_type;
reply_cb(slab, sizeof(*hdr) + sizeof(*succ));
buzz_proto_buf_free(&slab);
}
void buzz_proto_send_error_reusing_slab(buzz_transport_reply_fn reply_cb, uint16_t error_code, uint8_t *slab)
{
if (!reply_cb || !slab)
return;
struct buzz_proto_header *hdr = (struct buzz_proto_header *)slab;
hdr->frame_type = BUZZ_FRAME_ERROR;
hdr->payload_length = sys_cpu_to_le16(sizeof(struct buzz_resp_error));
struct buzz_resp_error *err = (struct buzz_resp_error *)(slab + sizeof(*hdr));
err->error_code = sys_cpu_to_le16(error_code);
reply_cb(slab, sizeof(*hdr) + sizeof(*err));
buzz_proto_buf_free(&slab);
}
static void handle_proto_version_request(struct buzz_frame_msg *msg)
{
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
hdr->frame_type = BUZZ_FRAME_RESPONSE;
struct buzz_resp_proto_version *resp_data = (struct buzz_resp_proto_version *)(msg->data_ptr + sizeof(*hdr));
@@ -110,10 +173,18 @@ static void handle_proto_version_request(struct buzz_frame_msg *msg)
resp_data->data_type = BUZZ_DATA_PROTO_INFO;
resp_data->version = sys_cpu_to_le16(BUZZ_PROTO_VERSION);
resp_data->max_chunk_size = sys_cpu_to_le16(CONFIG_BUZZ_PROTO_SLAB_SIZE - sizeof(struct buzz_proto_header));
/* Dynamische Chunk-Grösse basierend auf der aktuellen Transport-MTU berechnen */
uint16_t slab_payload = CONFIG_BUZZ_PROTO_SLAB_SIZE - sizeof(struct buzz_proto_header);
uint16_t transport_payload = 0;
if (msg->max_payload > sizeof(struct buzz_proto_header)) {
transport_payload = msg->max_payload - sizeof(struct buzz_proto_header);
}
uint16_t safe_chunk = MIN(slab_payload, transport_payload);
resp_data->max_chunk_size = sys_cpu_to_le16(safe_chunk);
hdr->payload_length = sys_cpu_to_le16(sizeof(struct buzz_resp_proto_version));
uint16_t total_len = sizeof(struct buzz_proto_header) + sizeof(struct buzz_resp_proto_version);
if (msg->reply_cb)
@@ -122,6 +193,34 @@ static void handle_proto_version_request(struct buzz_frame_msg *msg)
}
}
void handle_device_info_request(struct buzz_frame_msg *msg) {
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
hdr->frame_type = BUZZ_FRAME_RESPONSE;
struct buzz_resp_device_info *resp_data = (struct buzz_resp_device_info *)(msg->data_ptr + sizeof(*hdr));
resp_data->data_type = BUZZ_DATA_DEVICE_INFO;
if (fw_mgmt_get_id(resp_data->device_id, sizeof(resp_data->device_id)) < 0) {
LOG_ERR("Failed to get device ID");
send_error_frame(msg, EIO);
return;
}
const char *board_name = fw_mgmt_get_board_name();
const char *board_rev = fw_mgmt_get_board_revision();
const char *soc_name = fw_mgmt_get_soc_name();
resp_data->board_name_length = MIN(strlen(board_name), 32); // Sicherheitsmassnahme gegen zu lange Namen
resp_data->board_revision_length = MIN(strlen(board_rev), 32); // Sicherheitsmassnahme gegen zu lange Namen
resp_data->soc_name_length = MIN(strlen(soc_name), 32); // Sicherheitsmassnahme gegen zu lange Namen
memcpy(resp_data->data, board_name, resp_data->board_name_length);
memcpy(resp_data->data + resp_data->board_name_length, board_rev, resp_data->board_revision_length);
memcpy(resp_data->data + resp_data->board_name_length + resp_data->board_revision_length, soc_name, resp_data->soc_name_length);
uint16_t payload_length = sizeof(struct buzz_resp_device_info) + resp_data->board_name_length + resp_data->board_revision_length + resp_data->soc_name_length;
hdr->payload_length = sys_cpu_to_le16(payload_length);
uint16_t total_len = sizeof(struct buzz_proto_header) + payload_length;
if (msg->reply_cb)
{
msg->reply_cb(msg->data_ptr, total_len);
}
}
void handle_fs_info_request(struct buzz_frame_msg *msg)
{
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
@@ -147,7 +246,7 @@ void handle_fs_info_request(struct buzz_frame_msg *msg)
resp_data->data_type = BUZZ_DATA_FS_INFO;
resp_data->total_size = sys_cpu_to_le32(total_size);
resp_data->free_size = sys_cpu_to_le32(free_size);
resp_data->max_path_length = FS_MGMT_MAX_PATH_LENGTH;
resp_data->max_path_length = CONFIG_FS_MGMT_MAX_PATH_LENGTH;
resp_data->sys_path_length = strlen(FS_SYSTEM_PATH);
resp_data->audio_path_length = strlen(FS_AUDIO_PATH);
memcpy(resp_data->data, FS_SYSTEM_PATH, resp_data->sys_path_length);
@@ -164,6 +263,36 @@ void handle_fs_info_request(struct buzz_frame_msg *msg)
}
}
static void handle_fw_info_request(struct buzz_frame_msg *msg)
{
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
hdr->frame_type = BUZZ_FRAME_RESPONSE;
struct buzz_resp_fw_info *resp_data = (struct buzz_resp_fw_info *)(msg->data_ptr + sizeof(*hdr));
resp_data->data_type = BUZZ_DATA_FW_INFO;
resp_data->fw_status = fw_mgmt_get_fw_state();
resp_data->slot1_size = sys_cpu_to_le32(fw_mgmt_get_slot1_size());
const char *fw_version = fw_mgmt_get_fw_version_string();
const char *kernel_version = fw_mgmt_get_kernel_version_string();
resp_data->fw_version_length = MIN(strlen(fw_version), 32); // Sicherheitsmassnahme gegen zu lange Strings
resp_data->kernel_version_length = MIN(strlen(kernel_version), 32); // Sicherheitsmassnahme gegen zu lange Strings
memcpy(resp_data->data, fw_version, resp_data->fw_version_length);
memcpy(resp_data->data + resp_data->fw_version_length, kernel_version, resp_data->kernel_version_length);
uint16_t payload_length = sizeof(struct buzz_resp_fw_info) + resp_data->fw_version_length + resp_data->kernel_version_length;
hdr->payload_length = sys_cpu_to_le16(payload_length);
uint16_t total_len = sizeof(struct buzz_proto_header) + payload_length;
if (msg->reply_cb)
{
msg->reply_cb(msg->data_ptr, total_len);
}
}
static void handle_ls_request(struct buzz_frame_msg *msg)
{
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
@@ -214,7 +343,7 @@ static void handle_ls_request(struct buzz_frame_msg *msg)
}
}
static void handle_file_get_request(struct buzz_frame_msg *msg)
static void handle_file_get_request(struct buzz_frame_msg *msg, bool only_tags)
{
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
uint16_t payload_len = sys_le16_to_cpu(hdr->payload_length);
@@ -237,7 +366,6 @@ static void handle_file_get_request(struct buzz_frame_msg *msg)
memcpy(src_path, msg->data_ptr + sizeof(*hdr) + 1, path_len);
src_path[path_len] = '\0';
// 1. Datei-Größe ermitteln
struct fs_dirent entry;
if (fs_mgmt_pm_stat(src_path, &entry) != 0)
{
@@ -246,7 +374,6 @@ static void handle_file_get_request(struct buzz_frame_msg *msg)
return;
}
// 2. Datei öffnen
fs_file_t_init(&get_file_state.file);
int rc = fs_mgmt_pm_open(&get_file_state.file, src_path, FS_O_READ);
if (rc != 0)
@@ -256,7 +383,31 @@ static void handle_file_get_request(struct buzz_frame_msg *msg)
return;
}
// 3. State initialisieren
uint32_t stream_size = entry.size;
if (only_tags)
{
ssize_t audio_len = fs_mgmt_get_audio_data_len(&get_file_state.file);
if (audio_len < 0)
{
LOG_ERR("Failed to get audio data len: %d", (int)audio_len);
fs_mgmt_pm_close(&get_file_state.file);
send_error_frame(msg, EIO);
return;
}
stream_size = entry.size - audio_len;
if (stream_size == 0)
{
fs_seek(&get_file_state.file, entry.size, FS_SEEK_SET);
}
else
{
fs_seek(&get_file_state.file, audio_len, FS_SEEK_SET);
}
}
current_stream = STREAM_FILE_GET;
get_file_state.active = true;
get_file_state.credits = 0;
@@ -268,12 +419,11 @@ static void handle_file_get_request(struct buzz_frame_msg *msg)
LOG_INF("Started FILE_GET stream for '%s' (%u bytes)", src_path, entry.size);
// 4. FILE_START Frame senden
hdr->frame_type = BUZZ_FRAME_FILE_START;
hdr->payload_length = sys_cpu_to_le16(sizeof(struct buzz_file_start_payload));
struct buzz_file_start_payload *start_pl = (struct buzz_file_start_payload *)(msg->data_ptr + sizeof(*hdr));
start_pl->total_size = sys_cpu_to_le32(entry.size);
start_pl->total_size = sys_cpu_to_le32(stream_size);
if (msg->reply_cb)
{
@@ -284,11 +434,120 @@ static void handle_file_get_request(struct buzz_frame_msg *msg)
fs_mgmt_pm_close(&get_file_state.file);
get_file_state.active = false;
current_stream = STREAM_IDLE;
k_sleep(K_MSEC(10));
send_error_frame(msg, EIO);
return;
}
}
}
static void process_rm_request(struct buzz_frame_msg *msg)
{
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
uint16_t payload_len = sys_le16_to_cpu(hdr->payload_length);
struct buzz_rm_file_payload *req = (struct buzz_rm_file_payload *)(msg->data_ptr + sizeof(*hdr));
if (payload_len < (sizeof(req->data_type) + sizeof(req->path_length)))
{
LOG_ERR("Invalid payload for RM_FILE request");
send_error_frame(msg, EINVAL);
return;
}
if ((sizeof(req->data_type) + sizeof(req->path_length) + req->path_length) != payload_len)
{
LOG_ERR("Path length in RM_FILE does not match payload length");
send_error_frame(msg, EINVAL);
return;
}
if (req->path_length >= sizeof(src_path))
{
LOG_ERR("Path too long for RM_FILE request");
send_error_frame(msg, ENAMETOOLONG);
return;
}
memcpy(src_path, req->path, req->path_length);
src_path[req->path_length] = '\0';
int rc = fs_mgmt_pm_unlink(src_path);
if (rc != 0)
{
LOG_ERR("Failed to remove file '%s': %d", src_path, rc);
send_error_frame(msg, abs(rc));
return;
}
LOG_INF("File '%s' removed successfully", src_path);
hdr->frame_type = BUZZ_FRAME_SUCCESS;
hdr->payload_length = sys_cpu_to_le16(sizeof(struct buzz_resp_success));
struct buzz_resp_success *resp_data = (struct buzz_resp_success *)(msg->data_ptr + sizeof(*hdr));
resp_data->data_type = BUZZ_DATA_RM_FILE;
if (msg->reply_cb)
{
msg->reply_cb(msg->data_ptr, sizeof(*hdr) + sizeof(*resp_data));
}
}
static void process_move_request(struct buzz_frame_msg *msg)
{
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
uint16_t payload_len = sys_le16_to_cpu(hdr->payload_length);
struct buzz_rename_file_payload *req = (struct buzz_rename_file_payload *)(msg->data_ptr + sizeof(*hdr));
if (payload_len < (sizeof(req->data_type) + sizeof(req->old_path_length) + sizeof(req->new_path_length)))
{
LOG_ERR("Invalid payload for RENAME_FILE request");
send_error_frame(msg, EINVAL);
return;
}
if ((sizeof(req->data_type) + sizeof(req->old_path_length) + sizeof(req->new_path_length) + req->old_path_length + req->new_path_length) != payload_len)
{
LOG_ERR("Path lengths in RENAME_FILE do not match payload length");
send_error_frame(msg, EINVAL);
return;
}
if (req->old_path_length >= sizeof(src_path) || req->new_path_length >= sizeof(dst_path))
{
LOG_ERR("Source or destination path too long for RENAME_FILE request");
send_error_frame(msg, ENAMETOOLONG);
return;
}
memcpy(src_path, req->paths, req->old_path_length);
src_path[req->old_path_length] = '\0';
memcpy(dst_path, req->paths + req->old_path_length, req->new_path_length);
dst_path[req->new_path_length] = '\0';
int rc = fs_mgmt_pm_rename(src_path, dst_path);
if (rc != 0)
{
LOG_ERR("Failed to rename file from '%s' to '%s': %d", src_path, dst_path, rc);
send_error_frame(msg, abs(rc));
return;
}
LOG_INF("File renamed from '%s' to '%s' successfully", src_path, dst_path);
hdr->frame_type = BUZZ_FRAME_SUCCESS;
hdr->payload_length = sys_cpu_to_le16(sizeof(struct buzz_resp_success));
struct buzz_resp_success *resp_data = (struct buzz_resp_success *)(msg->data_ptr + sizeof(*hdr));
resp_data->data_type = BUZZ_DATA_RENAME_FILE;
if (msg->reply_cb)
{
msg->reply_cb(msg->data_ptr, sizeof(*hdr) + sizeof(*resp_data));
}
}
static void process_file_get_stream(void)
{
uint8_t *buf = NULL;
@@ -316,7 +575,7 @@ static void process_file_get_stream(void)
return;
}
// Chunk Size berechnen
// Chunk Size berechnen
uint16_t max_chunk_size = MIN(
get_file_state.max_payload - sizeof(*hdr),
CONFIG_BUZZ_PROTO_SLAB_SIZE - sizeof(struct buzz_proto_header));
@@ -363,16 +622,20 @@ static void process_file_get_stream(void)
return;
}
// Daten gelesen -> CRC aktualisieren und Chunk senden
get_file_state.crc32 = crc32_ieee_update(get_file_state.crc32, payload_ptr, read_len);
get_file_state.offset += read_len;
// Chunk senden; CRC/Offset erst nach erfolgreichem Enqueue aktualisieren
hdr->frame_type = BUZZ_FRAME_FILE_CHUNK;
hdr->payload_length = sys_cpu_to_le16(read_len);
if (get_file_state.reply_cb)
{
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)
{
LOG_ERR("Failed to send FILE_CHUNK (err %d)", send_rc);
@@ -380,10 +643,15 @@ static void process_file_get_stream(void)
get_file_state.active = false;
current_stream = STREAM_IDLE;
buzz_proto_buf_free(&buf);
k_sleep(K_MSEC(10));
send_stream_error(get_file_state.reply_cb, EIO);
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.retry_counter = 0;
buzz_proto_buf_free(&buf);
@@ -409,18 +677,116 @@ static void handle_request(struct buzz_frame_msg *msg)
LOG_DBG("Received Proto Version Request");
handle_proto_version_request(msg);
break;
case BUZZ_DATA_DEVICE_INFO:
LOG_DBG("Received Device Info Request");
handle_device_info_request(msg);
break;
case BUZZ_DATA_FS_INFO:
LOG_DBG("Received FS Info Request");
handle_fs_info_request(msg);
break;
case BUZZ_DATA_LS:
LOG_DBG("Received LS Request");
handle_ls_request(msg);
break;
case BUZZ_DATA_FW_INFO:
LOG_DBG("Received FW Info Request");
handle_fw_info_request(msg);
break;
case BUZZ_DATA_FILE_GET:
LOG_DBG("Received FILE_GET Request");
handle_file_get_request(msg);
handle_file_get_request(msg, false);
break;
case BUZZ_DATA_FILE_PUT:
LOG_DBG("Received FILE_PUT Request");
if (payload_len < sizeof(struct buzz_request_payload) + sizeof(uint32_t) + 1)
{
send_error_frame(msg, EINVAL);
return;
}
if (current_stream != STREAM_IDLE)
{
LOG_WRN("Stream active, rejecting FILE_PUT request");
send_error_frame(msg, EBUSY);
return;
}
struct fs_write_msg write_req = {
.op = FS_WRITE_OP_FILE_START,
.slab_ptr = msg->data_ptr,
.data_offset = sizeof(*hdr) + sizeof(struct buzz_request_payload) + sizeof(uint32_t),
.data_len = payload_len - sizeof(struct buzz_request_payload) - sizeof(uint32_t),
.metadata = sys_get_le32(msg->data_ptr + sizeof(*hdr) + sizeof(struct buzz_request_payload)),
.reply_cb = msg->reply_cb};
if (fs_mgmt_submit_write(&write_req) == 0)
{
current_stream = STREAM_FILE_PUT; /* WICHTIG: Status blockieren */
msg->data_ptr = NULL; /* Ownership an FS-Thread übertragen */
}
else
{
send_error_frame(msg, EBUSY);
}
break;
case BUZZ_DATA_TAGS_PUT:
LOG_DBG("Received TAGS_PUT Request");
if (payload_len < sizeof(struct buzz_request_payload) + sizeof(uint32_t) + 1)
{
send_error_frame(msg, EINVAL);
return;
}
if (current_stream != STREAM_IDLE)
{
LOG_WRN("Stream active, rejecting TAGS_PUT request");
send_error_frame(msg, EBUSY);
return;
}
struct fs_write_msg tags_req = {
.op = FS_WRITE_OP_TAGS_START,
.slab_ptr = msg->data_ptr,
.data_offset = sizeof(*hdr) + sizeof(struct buzz_request_payload) + sizeof(uint32_t),
.data_len = payload_len - sizeof(struct buzz_request_payload) - sizeof(uint32_t),
.metadata = sys_get_le32(msg->data_ptr + sizeof(*hdr) + sizeof(struct buzz_request_payload)),
.reply_cb = msg->reply_cb
};
if (fs_mgmt_submit_write(&tags_req) == 0)
{
current_stream = STREAM_FILE_PUT; /* Blockiert den Stream für weitere Requests */
msg->data_ptr = NULL;
}
else
{
send_error_frame(msg, EBUSY);
}
break;
case BUZZ_DATA_TAGS_GET:
LOG_DBG("Received TAGS_GET Request");
handle_file_get_request(msg, true);
break;
case BUZZ_DATA_RM_FILE:
LOG_DBG("Received RM_FILE Request");
process_rm_request(msg);
break;
case BUZZ_DATA_RENAME_FILE:
LOG_DBG("Received RENAME_FILE Request");
process_move_request(msg);
break;
default:
LOG_WRN("Unknown request data_type: 0x%02x", req_data->data_type);
send_error_frame(msg, EINVAL);
@@ -555,7 +921,64 @@ static void buzz_proto_thread_fn(void *p1, void *p2, void *p3)
break;
case BUZZ_FRAME_FILE_CHUNK:
send_error_frame(&msg, ENOSYS);
if (current_stream != STREAM_FILE_PUT)
{
send_error_frame(&msg, EBADMSG);
buzz_proto_buf_free(&msg.data_ptr);
break;
}
struct fs_write_msg chunk_req = {
.op = FS_WRITE_OP_FILE_CHUNK,
.slab_ptr = msg.data_ptr,
.data_offset = sizeof(*hdr),
.data_len = sys_le16_to_cpu(hdr->payload_length),
.reply_cb = msg.reply_cb};
if (fs_mgmt_submit_write(&chunk_req) == 0)
{
msg.data_ptr = NULL;
}
else
{
send_error_frame(&msg, EBUSY);
}
buzz_proto_buf_free(&msg.data_ptr); /* Tut nichts, wenn msg.data_ptr == NULL */
break;
case BUZZ_FRAME_FILE_END:
if (current_stream != STREAM_FILE_PUT)
{
send_error_frame(&msg, EBADMSG);
buzz_proto_buf_free(&msg.data_ptr);
break;
}
if (msg.length >= sizeof(*hdr) + sizeof(struct buzz_file_end_payload))
{
struct buzz_file_end_payload *end_pl = (struct buzz_file_end_payload *)(msg.data_ptr + sizeof(*hdr));
struct fs_write_msg end_req = {
.op = FS_WRITE_OP_FILE_END,
.slab_ptr = msg.data_ptr,
.data_offset = 0,
.data_len = 0,
.metadata = sys_le32_to_cpu(end_pl->crc32),
.reply_cb = msg.reply_cb};
if (fs_mgmt_submit_write(&end_req) == 0)
{
msg.data_ptr = NULL;
current_stream = STREAM_IDLE; /* Stream wieder freigeben */
}
else
{
send_error_frame(&msg, EBUSY);
}
}
else
{
send_error_frame(&msg, EINVAL);
}
buzz_proto_buf_free(&msg.data_ptr);
break;
@@ -590,6 +1013,7 @@ static void buzz_proto_thread_fn(void *p1, void *p2, void *p3)
{
LOG_WRN("LS timeout waiting for ACK");
fs_mgmt_pm_closedir(&ls_state.dir);
send_stream_error(ls_state.reply_cb, ETIMEDOUT);
ls_state.active = false;
current_stream = STREAM_IDLE;
}
@@ -608,6 +1032,7 @@ static void buzz_proto_thread_fn(void *p1, void *p2, void *p3)
{
LOG_WRN("FILE_GET timeout waiting for ACK");
fs_close(&get_file_state.file);
send_stream_error(get_file_state.reply_cb, ETIMEDOUT);
get_file_state.active = false;
current_stream = STREAM_IDLE;
}

View File

@@ -0,0 +1,5 @@
if(CONFIG_EVENT_MGMT)
zephyr_library()
zephyr_library_sources(src/event_mgmt.c)
zephyr_include_directories(include)
endif()

View File

@@ -0,0 +1,10 @@
menuconfig EVENT_MGMT
bool "Event management"
default y
select EVENTS
if EVENT_MGMT
module = EVENT_MGMT
module-str = event_mgmt
source "subsys/logging/Kconfig.template.log_config"
endif # EVENT_MGMT

View File

@@ -0,0 +1,29 @@
#ifndef EVENT_MGMT_H
#define EVENT_MGMT_H
#include <zephyr/kernel.h>
#define EVENT_MGMT_FS_READY BIT(0)
#define EVENT_MGMT_AUDIO_READY BIT(1)
#define EVENT_MGMT_BLE_CONNECTED BIT(2)
#define EVENT_MGMT_BLE_DISCONNECTED BIT(3)
extern struct k_event event_mgmt_events;
static inline int event_mgmt_wait_for(uint32_t events, k_timeout_t timeout)
{
uint32_t got = k_event_wait(&event_mgmt_events, events, false, timeout);
return (got & events) == events ? 0 : -ETIMEDOUT;
}
static inline void event_mgmt_set_event(uint32_t event)
{
k_event_post(&event_mgmt_events, event);
}
static inline void event_mgmt_clear_event(uint32_t event)
{
k_event_clear(&event_mgmt_events, event);
}
#endif /* EVENT_MGMT_H */

View File

@@ -0,0 +1,3 @@
#include "event_mgmt.h"
K_EVENT_DEFINE(event_mgmt_events);

View File

@@ -1,5 +1,6 @@
menuconfig FS_MGMT
bool "File System Management"
default y
select FLASH
select FLASH_MAP
select FILE_SYSTEM
@@ -11,12 +12,41 @@ menuconfig FS_MGMT
Library for initializing and managing the file system.
if FS_MGMT
config FS_MGMT_MAX_PATH_LENGTH
int "Maximum File Path Length"
default 32
help
Set the maximum length for file paths in the file system. Default is 32 characters.
config FS_MGMT_MOUNT_POINT
string "Littlefs Mount Point"
default "/lfs"
help
Set the mount point for the Littlefs file system. Default is "/lfs".
config FS_MGMT_AUDIO_SUBDIR
string "Audio File Path"
default "/a"
help
Set the path for the audio file within the file system. Default is "/a".
config FS_MGMT_SYSTEM_SUBDIR
string "System File Path"
default "/sys"
help
Set the path for the system file within the file system. Default is "/sys".
config FS_MGMT_THREAD_STACK_SIZE
int "File System Management Thread Stack Size"
default 2048
help
Set the stack size for the file system management thread. Default is 2048 bytes.
config FS_MGMT_THREAD_PRIORITY
int "File System Management Thread Priority"
default 6
help
Set the priority for the file system management thread. Default is 6.
if SOC_SERIES_NRF52X
config PM_PARTITION_REGION_LITTLEFS_EXTERNAL
default y

View File

@@ -2,25 +2,52 @@
#define FS_MGMT_H
#include <zephyr/fs/fs.h>
#include "buzz_proto.h"
#define FS_MGMT_MAX_PATH_LENGTH 32
#define FS_AUDIO_PATH CONFIG_FS_MGMT_MOUNT_POINT "/a"
#define FS_SYSTEM_PATH CONFIG_FS_MGMT_MOUNT_POINT "/sys"
#define FS_AUDIO_PATH CONFIG_FS_MGMT_MOUNT_POINT CONFIG_FS_MGMT_AUDIO_SUBDIR
#define FS_SYSTEM_PATH CONFIG_FS_MGMT_MOUNT_POINT CONFIG_FS_MGMT_SYSTEM_SUBDIR
#define MAX_FILE_NAME_LEN(target, path) \
((int)(sizeof(target) - (sizeof(path)) - 1))
/** @brief Assemble a full path from a base path and a filename
* Ensures that the resulting path fits into the target buffer and is null-terminated. If the filename is too long, it will be truncated to fit.
* @param buffer Target buffer to hold the assembled path
* @param path Base path (e.g. "/sys" or "/audio")
* @param filename Name of the file to append to the base path
*/
#define FS_MGMT_ASSEMBLE_PATH(buffer, path, filename) \
snprintf(buffer, sizeof(buffer), \
"%s/%.*s", \
path, \
MAX_FILE_NAME_LEN(buffer, path), \
filename)
/**
* @brief Initializes the filesystem management module.
* @brief OP-Codes for the FS write thread
*/
int fs_mgmt_init(void);
enum fs_write_op
{
FS_WRITE_OP_FILE_START,
FS_WRITE_OP_FILE_CHUNK,
FS_WRITE_OP_FILE_END,
FS_WRITE_OP_TAGS_START, // Schon mal vorgesehen
FS_WRITE_OP_FW_START, // Schon mal vorgesehen
FS_WRITE_OP_ABORT
};
// /**
// * @brief Puts the QSPI flash into deep sleep mode to save power
// */
// int fs_pm_flash_suspend(void);
// /**
// * @brief Resumes the QSPI flash from deep sleep mode
// */
// int fs_pm_flash_resume(void);
/**
* @brief Structure representing a write message for the FS write thread
*/
struct fs_write_msg
{
enum fs_write_op op;
uint8_t *slab_ptr; /* Basis-Pointer des Memory-Slabs (für k_mem_slab_free) */
uint16_t data_offset; /* Offset ab dem slab_ptr, wo die Nutzdaten beginnen */
uint16_t data_len; /* Länge der Nutzdaten */
uint32_t metadata; /* Zusatzinfo (Start: erwartete Dateigröße, End: erwartete CRC32) */
buzz_transport_reply_fn reply_cb; /* Callback für ACKs / Success / Error */
};
/**
* @brief Wrapper around fs_open that handles power management for the flash
@@ -114,4 +141,18 @@ int fs_mgmt_pm_mkdir_recursive(char *path);
*/
int fs_mgmt_pm_rm_recursive(char *path, size_t max_len);
/**
* @brief Gets the length of the audio data in a file, accounting for any metadata tags, ensuring the flash is active during the operation
* @param fp Pointer to an open fs_file_t structure representing the file
* @return Length of the audio data on success, negative error code on failure
*/
ssize_t fs_mgmt_get_audio_data_len(struct fs_file_t *fp);
/**
* @brief Submits a write message to the FS write thread, which will handle writing data to the filestem asynchronously, ensuring the flash is active during the operation
* @param msg Pointer to the fs_write_msg structure containing the write operation details
* @return 0 on success, negative error code on failure
*/
int fs_mgmt_submit_write(struct fs_write_msg *msg);
#endif /* FS_MGMT_H */

View File

@@ -1,9 +1,13 @@
#include <zephyr/fs/littlefs.h>
#include <zephyr/fs/fs.h>
#include <zephyr/sys/byteorder.h>
#include <zephyr/logging/log.h>
#include <zephyr/pm/device.h>
#include <zephyr/sys/crc.h>
#include "fs_mgmt.h"
#include "buzz_proto.h"
#include "event_mgmt.h"
LOG_MODULE_REGISTER(fs_mgmt, CONFIG_FS_MGMT_LOG_LEVEL);
@@ -13,6 +17,9 @@ FS_LITTLEFS_DECLARE_DEFAULT_CONFIG(fs_storage_data);
#define QSPI_FLASH_NODE DT_ALIAS(qspi_flash)
static const struct device *flash_dev = DEVICE_DT_GET(QSPI_FLASH_NODE);
#define TAG_MAGIC "TAG!"
#define TAG_FORMAT_VERSION 1U
static struct fs_mount_t fs_storage_mnt = {
.type = FS_LITTLEFS,
.fs_data = &fs_storage_data,
@@ -23,6 +30,37 @@ static struct fs_mount_t fs_storage_mnt = {
static int open_count = 0;
static struct k_mutex flash_pm_lock;
// #define ACK_WATERMARK (CONFIG_BUZZ_PROTO_SLAB_COUNT / 4)
#define INITIAL_CREDITS CONFIG_BUZZ_PROTO_SLAB_COUNT
#define ACK_WATERMARK (MAX(2, MIN(8, CONFIG_BUZZ_PROTO_SLAB_COUNT / 4)))
typedef struct __attribute__((packed))
{
uint16_t total_size;
uint16_t version;
uint8_t magic[4];
} tag_footer_t;
K_MSGQ_DEFINE(fs_write_msgq, sizeof(struct fs_write_msg), CONFIG_BUZZ_PROTO_SLAB_COUNT, 4);
typedef enum
{
FS_STATE_IDLE,
FS_STATE_RECEIVING_FILE,
FS_STATE_RECEIVING_TAGS,
FS_STATE_RECEIVING_FIRMWARE
} fs_thread_state_t;
static struct
{
fs_thread_state_t state;
struct fs_file_t file;
char filename[CONFIG_FS_MGMT_MAX_PATH_LENGTH];
uint32_t crc32;
uint16_t unacked_chunks;
off_t audio_len; // Offeset für Tags
} write_ctx;
/**
* @brief Puts the QSPI flash into deep sleep mode to save power
* Decrements the open count and suspends the flash if no more users are active
@@ -349,17 +387,18 @@ int fs_mgmt_pm_mkdir_recursive(char *path)
return rc;
}
int fs_mgmt_init(void)
static int fs_mgmt_init(void)
{
k_mutex_init(&flash_pm_lock);
if (!device_is_ready(flash_dev)) {
if (!device_is_ready(flash_dev))
{
LOG_ERR("Flash device not ready!");
return -ENODEV;
}
fs_mgmt_pm_flash_resume();
int rc = fs_mount(&fs_storage_mnt);
if (rc < 0)
@@ -367,7 +406,349 @@ int fs_mgmt_init(void)
LOG_ERR("Error mounting filesystem: %d", rc);
return rc;
}
fs_mgmt_pm_flash_suspend();
LOG_DBG("Filesystem mounted successfully");
event_mgmt_set_event(EVENT_MGMT_FS_READY);
return 0;
}
}
/* * APPLICATION Level sorgt dafür, dass die Treiber (Flash/QSPI)
* bereits bereit sind.
* CONFIG_APPLICATION_INIT_PRIORITY ist ein guter Standardwert (meist 90).
*/
SYS_INIT(fs_mgmt_init, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY);
static int fs_get_tag_bounds(struct fs_file_t *fp, off_t file_size,
size_t *audio_limit, size_t *payload_len, bool *has_tag)
{
tag_footer_t footer;
if (audio_limit == NULL || payload_len == NULL || has_tag == NULL)
{
return -EINVAL;
}
*has_tag = false;
*audio_limit = (size_t)file_size;
*payload_len = 0U;
if (file_size < (off_t)sizeof(tag_footer_t))
{
return 0;
}
fs_seek(fp, -(off_t)sizeof(tag_footer_t), FS_SEEK_END);
if (fs_read(fp, &footer, sizeof(tag_footer_t)) != sizeof(tag_footer_t))
{
fs_seek(fp, 0, FS_SEEK_SET);
return -EIO;
}
if (memcmp(footer.magic, TAG_MAGIC, 4) != 0)
{
fs_seek(fp, 0, FS_SEEK_SET);
return 0;
}
uint16_t tag_version = sys_le16_to_cpu(footer.version);
uint16_t tag_len = sys_le16_to_cpu(footer.total_size);
if (tag_version != TAG_FORMAT_VERSION)
{
fs_seek(fp, 0, FS_SEEK_SET);
return -ENOTSUP;
}
if (tag_len > (uint16_t)file_size || tag_len < sizeof(tag_footer_t))
{
fs_seek(fp, 0, FS_SEEK_SET);
return -EBADMSG;
}
*has_tag = true;
*audio_limit = (size_t)file_size - tag_len;
*payload_len = tag_len - sizeof(tag_footer_t);
fs_seek(fp, 0, FS_SEEK_SET);
return 0;
}
ssize_t fs_mgmt_get_audio_data_len(struct fs_file_t *fp)
{
off_t file_size;
size_t audio_limit = 0U;
size_t payload_len = 0U;
bool has_tag = false;
fs_seek(fp, 0, FS_SEEK_END);
file_size = fs_tell(fp);
if (file_size < 0)
{
fs_seek(fp, 0, FS_SEEK_SET);
return -EIO;
}
if (fs_get_tag_bounds(fp, file_size, &audio_limit, &payload_len, &has_tag) < 0)
{
fs_seek(fp, 0, FS_SEEK_SET);
return -EIO;
}
fs_seek(fp, 0, FS_SEEK_SET);
return has_tag ? (ssize_t)audio_limit : file_size;
}
int fs_mgmt_submit_write(struct fs_write_msg *msg)
{
return k_msgq_put(&fs_write_msgq, msg, K_NO_WAIT);
}
static void fs_thread_entry(void *p1, void *p2, void *p3)
{
LOG_INF("FS Write Thread started");
write_ctx.state = FS_STATE_IDLE;
fs_file_t_init(&write_ctx.file);
struct fs_write_msg msg;
while (1)
{
/* Watchdog nur bei aktiven Transfers */
k_timeout_t wait_time = (write_ctx.state == FS_STATE_IDLE) ? K_FOREVER : K_SECONDS(2);
int rc = k_msgq_get(&fs_write_msgq, &msg, wait_time);
if (rc == -EAGAIN)
{
LOG_WRN("Write timeout! Aborting transfer.");
if (write_ctx.state == FS_STATE_RECEIVING_FILE)
{
fs_mgmt_pm_close(&write_ctx.file);
fs_mgmt_pm_unlink(write_ctx.filename);
}
write_ctx.state = FS_STATE_IDLE;
continue;
}
switch (write_ctx.state)
{
case FS_STATE_IDLE:
if (msg.op == FS_WRITE_OP_FILE_START)
{
if (msg.data_len >= sizeof(write_ctx.filename))
{
LOG_ERR("Filename too long");
buzz_proto_send_error_reusing_slab(msg.reply_cb, ENAMETOOLONG, msg.slab_ptr);
break;
}
memcpy(write_ctx.filename, msg.slab_ptr + msg.data_offset, msg.data_len);
write_ctx.filename[msg.data_len] = '\0';
fs_mgmt_pm_unlink(write_ctx.filename);
rc = fs_mgmt_pm_open(&write_ctx.file, write_ctx.filename, FS_O_CREATE | FS_O_WRITE);
if (rc == 0)
{
write_ctx.state = FS_STATE_RECEIVING_FILE;
write_ctx.crc32 = 0;
write_ctx.unacked_chunks = 0;
LOG_INF("File transfer started: %s (Expected: %u bytes)", write_ctx.filename, msg.metadata);
uint16_t credits = MIN(INITIAL_CREDITS, buzz_proto_get_free_rx_slabs());
buzz_proto_buf_free(&msg.slab_ptr);
buzz_proto_send_ack(msg.reply_cb, credits);
}
else
{
LOG_ERR("Failed to open %s: %d", write_ctx.filename, rc);
buzz_proto_send_error_reusing_slab(msg.reply_cb, abs(rc), msg.slab_ptr);
}
} /* Innerhalb von case FS_STATE_IDLE: */
else if (msg.op == FS_WRITE_OP_TAGS_START)
{
if (msg.data_len >= sizeof(write_ctx.filename))
{
buzz_proto_send_error_reusing_slab(msg.reply_cb, ENAMETOOLONG, msg.slab_ptr);
break;
}
memcpy(write_ctx.filename, msg.slab_ptr + msg.data_offset, msg.data_len);
write_ctx.filename[msg.data_len] = '\0';
/* Datei öffnen: Nur Lese- und Schreibrechte, Datei muss bereits existieren */
int rc = fs_mgmt_pm_open(&write_ctx.file, write_ctx.filename, FS_O_READ | FS_O_WRITE);
if (rc == 0)
{
ssize_t audio_len = fs_mgmt_get_audio_data_len(&write_ctx.file);
if (audio_len < 0)
{
LOG_ERR("Failed to get audio length: %d", (int)audio_len);
fs_mgmt_pm_close(&write_ctx.file);
buzz_proto_send_error_reusing_slab(msg.reply_cb, EIO, msg.slab_ptr);
break;
}
/* Datei ab dem Ende der Audiodaten abschneiden (alte Tags entfernen) */
rc = fs_truncate(&write_ctx.file, audio_len);
if (rc != 0)
{
LOG_ERR("Failed to truncate file: %d", rc);
fs_mgmt_pm_close(&write_ctx.file);
buzz_proto_send_error_reusing_slab(msg.reply_cb, abs(rc), msg.slab_ptr);
break;
}
/* File-Pointer exakt an das neue Ende (audio_len) setzen */
fs_seek(&write_ctx.file, audio_len, FS_SEEK_SET);
write_ctx.state = FS_STATE_RECEIVING_TAGS;
write_ctx.crc32 = 0;
write_ctx.unacked_chunks = 0;
write_ctx.audio_len = audio_len;
LOG_INF("Tags transfer started: %s (Expected tags: %u bytes)", write_ctx.filename, msg.metadata);
uint16_t credits = buzz_proto_get_free_rx_slabs();
buzz_proto_buf_free(&msg.slab_ptr);
buzz_proto_send_ack(msg.reply_cb, credits);
}
else
{
LOG_ERR("Failed to open %s for tags: %d", write_ctx.filename, rc);
buzz_proto_send_error_reusing_slab(msg.reply_cb, abs(rc), msg.slab_ptr);
}
}
else if (msg.op == FS_WRITE_OP_FW_START)
{
LOG_WRN("Operation not yet fully implemented in FS state machine");
buzz_proto_send_error_reusing_slab(msg.reply_cb, ENOSYS, msg.slab_ptr);
}
break;
case FS_STATE_RECEIVING_FILE:
if (msg.op == FS_WRITE_OP_FILE_CHUNK && msg.slab_ptr)
{
ssize_t written = fs_write(&write_ctx.file, msg.slab_ptr + msg.data_offset, msg.data_len);
if (written == msg.data_len)
{
write_ctx.crc32 = crc32_ieee_update(write_ctx.crc32, msg.slab_ptr + msg.data_offset, msg.data_len);
buzz_proto_buf_free(&msg.slab_ptr);
write_ctx.unacked_chunks++;
if (write_ctx.unacked_chunks >= ACK_WATERMARK)
{
uint16_t free_slabs = buzz_proto_get_free_rx_slabs();
uint16_t credits_to_send = MIN(free_slabs, write_ctx.unacked_chunks);
if (credits_to_send > 0)
{
buzz_proto_send_ack(msg.reply_cb, credits_to_send);
write_ctx.unacked_chunks -= credits_to_send;
}
}
}
else
{
LOG_ERR("Flash write failed!");
write_ctx.state = FS_STATE_IDLE;
buzz_proto_send_error_reusing_slab(msg.reply_cb, EIO, msg.slab_ptr);
}
}
else if (msg.op == FS_WRITE_OP_FILE_END)
{
fs_mgmt_pm_close(&write_ctx.file);
write_ctx.state = FS_STATE_IDLE;
if (write_ctx.crc32 == msg.metadata)
{
LOG_INF("File transfer finished. CRC valid: 0x%08X", write_ctx.crc32);
buzz_proto_send_success_reusing_slab(msg.reply_cb, BUZZ_DATA_FILE_PUT, msg.slab_ptr);
}
else
{
LOG_ERR("CRC Mismatch! Expected: 0x%08X, Got: 0x%08X", msg.metadata, write_ctx.crc32);
fs_mgmt_pm_unlink(write_ctx.filename);
buzz_proto_send_error_reusing_slab(msg.reply_cb, EBADMSG, msg.slab_ptr);
}
}
else if (msg.op == FS_WRITE_OP_ABORT)
{
fs_mgmt_pm_close(&write_ctx.file);
fs_mgmt_pm_unlink(write_ctx.filename);
write_ctx.state = FS_STATE_IDLE;
if (msg.slab_ptr)
buzz_proto_buf_free(&msg.slab_ptr);
}
break;
case FS_STATE_RECEIVING_TAGS:
if (msg.op == FS_WRITE_OP_FILE_CHUNK && msg.slab_ptr)
{
ssize_t written = fs_write(&write_ctx.file, msg.slab_ptr + msg.data_offset, msg.data_len);
if (written == msg.data_len)
{
write_ctx.crc32 = crc32_ieee_update(write_ctx.crc32, msg.slab_ptr + msg.data_offset, msg.data_len);
buzz_proto_buf_free(&msg.slab_ptr);
write_ctx.unacked_chunks++;
if (write_ctx.unacked_chunks >= ACK_WATERMARK)
{
uint16_t free_slabs = buzz_proto_get_free_rx_slabs();
uint16_t credits_to_send = MIN(free_slabs, write_ctx.unacked_chunks);
if (credits_to_send > 0)
{
buzz_proto_send_ack(msg.reply_cb, credits_to_send);
write_ctx.unacked_chunks -= credits_to_send;
}
}
}
else
{
LOG_ERR("Flash write failed during tags transfer!");
fs_truncate(&write_ctx.file, write_ctx.audio_len); /* Rollback */
fs_mgmt_pm_close(&write_ctx.file);
write_ctx.state = FS_STATE_IDLE;
buzz_proto_send_error_reusing_slab(msg.reply_cb, EIO, msg.slab_ptr);
}
}
else if (msg.op == FS_WRITE_OP_FILE_END)
{
if (write_ctx.crc32 == msg.metadata)
{
LOG_INF("Tags transfer finished. CRC valid: 0x%08X", write_ctx.crc32);
fs_mgmt_pm_close(&write_ctx.file);
buzz_proto_send_success_reusing_slab(msg.reply_cb, BUZZ_DATA_TAGS_PUT, msg.slab_ptr);
}
else
{
LOG_ERR("Tags CRC Mismatch! Expected: 0x%08X, Got: 0x%08X", msg.metadata, write_ctx.crc32);
fs_truncate(&write_ctx.file, write_ctx.audio_len); /* Rollback */
fs_mgmt_pm_close(&write_ctx.file);
buzz_proto_send_error_reusing_slab(msg.reply_cb, EBADMSG, msg.slab_ptr);
}
write_ctx.state = FS_STATE_IDLE;
}
else if (msg.op == FS_WRITE_OP_ABORT)
{
fs_truncate(&write_ctx.file, write_ctx.audio_len); /* Rollback */
fs_mgmt_pm_close(&write_ctx.file);
write_ctx.state = FS_STATE_IDLE;
if (msg.slab_ptr)
buzz_proto_buf_free(&msg.slab_ptr);
}
break;
case FS_STATE_RECEIVING_FIRMWARE:
break;
}
/* Garbage Collection: Ungültige Operationen im falschen State abfangen */
if (write_ctx.state == FS_STATE_IDLE && msg.op == FS_WRITE_OP_FILE_CHUNK && msg.slab_ptr)
{
buzz_proto_buf_free(&msg.slab_ptr);
}
}
}
K_THREAD_DEFINE(fs_thread, CONFIG_FS_MGMT_THREAD_STACK_SIZE, fs_thread_entry,
NULL, NULL, NULL, CONFIG_FS_MGMT_THREAD_PRIORITY, 0, 0);

View File

@@ -0,0 +1,16 @@
if(CONFIG_FW_MGMT)
zephyr_library()
zephyr_library_sources(src/fw_mgmt.c)
zephyr_include_directories(include)
if(CONFIG_MCUBOOT_IMG_MANAGER)
# img_mgmt.h pulls in <bootutil/image.h> and <zcbor_common.h>
if(DEFINED ZEPHYR_MCUBOOT_MODULE_DIR)
zephyr_include_directories(${ZEPHYR_MCUBOOT_MODULE_DIR}/boot/bootutil/include)
zephyr_include_directories(${ZEPHYR_MCUBOOT_MODULE_DIR}/boot/zephyr/include)
endif()
if(DEFINED ZEPHYR_ZCBOR_MODULE_DIR)
zephyr_include_directories(${ZEPHYR_ZCBOR_MODULE_DIR}/include)
endif()
endif()
endif()

View File

@@ -0,0 +1,23 @@
menuconfig FW_MGMT
bool "Firmware Management"
default y
select FLASH
select FLASH_MAP
select STREAM_FLASH
select FLASH_PAGE_LAYOUT
select BOOTLOADER_MCUBOOT
select IMG_MANAGER
select MCUBOOT_IMG_MGR
select HWINFO
help
Library for firmware operations.
if FW_MGMT
config MCUBOOT_UTIL_LOG_LEVEL_ERR
# config CONFIG_MCUMGR_GRP_IMG
# default y
module = FW_MGMT
module-str = fw_mgmt
source "subsys/logging/Kconfig.template.log_config"
endif # FW_MGMT

View File

@@ -0,0 +1,23 @@
#ifndef FW_MGMT_H
#define FW_MGMT_H
#include <zephyr/kernel.h>
typedef enum
{
FW_STATE_CONFIRMED = 0x00,
FW_STATE_PENDING = 0x01,
FW_STATE_TESTING = 0x02,
FW_STATE_UNKNOWN = 0xFF
} fw_state_t;
const char *fw_mgmt_get_fw_version_string(void);
const char *fw_mgmt_get_kernel_version_string(void);
const char *fw_mgmt_get_board_name();
const char *fw_mgmt_get_board_revision();
const char *fw_mgmt_get_soc_name();
int fw_mgmt_get_id(uint8_t *buffer, size_t length);
ssize_t fw_mgmt_get_slot1_size(void);
fw_state_t fw_mgmt_get_fw_state(void);
#endif /* FW_MGMT_H */

View File

@@ -0,0 +1,79 @@
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <zephyr/dfu/mcuboot.h>
#include <zephyr/drivers/hwinfo.h>
#include <zephyr/mgmt/mcumgr/grp/img_mgmt/img_mgmt.h>
#include <version.h>
#include <app_version.h>
#include "fw_mgmt.h"
#define SLOT1_PARTITION_SIZE DT_REG_SIZE(DT_NODELABEL(slot1_partition))
static char fw_version_string[] = APP_VERSION_STRING;
static char kernel_version_string[] = KERNEL_VERSION_STRING;
static char board_name[] = CONFIG_BOARD;
static char board_revision[] = CONFIG_BOARD_REVISION;
static char soc_name[] = CONFIG_SOC;
static uint8_t hwid[8];
LOG_MODULE_REGISTER(fw_mgmt, CONFIG_FW_MGMT_LOG_LEVEL);
const char* fw_mgmt_get_fw_version_string(void)
{
return (const char*)fw_version_string;
}
const char* fw_mgmt_get_kernel_version_string(void)
{
return (const char*)kernel_version_string;
}
const char* fw_mgmt_get_board_name(void)
{
return (const char*)board_name;
}
const char* fw_mgmt_get_board_revision(void)
{
return (const char*)board_revision;
}
const char* fw_mgmt_get_soc_name(void)
{
return (const char*)soc_name;
}
int fw_mgmt_get_id(uint8_t *buffer, size_t length)
{
int ret = hwinfo_get_device_id(hwid, length);
if (ret < 0) {
LOG_ERR("Failed to get device EUI64: %d", ret);
return ret;
}
memcpy(buffer, hwid, sizeof(hwid));
return ret;
}
fw_state_t fw_mgmt_get_fw_state(void)
{
if (!boot_is_img_confirmed()) {
return FW_STATE_TESTING;
}
int swap_type = mcuboot_swap_type();
if (swap_type == BOOT_SWAP_TYPE_NONE) {
return FW_STATE_CONFIRMED;
} else if (swap_type == BOOT_SWAP_TYPE_TEST) {
return FW_STATE_PENDING;
} else {
LOG_ERR("Unexpected swap type: %d", swap_type);
return FW_STATE_UNKNOWN; // Fallback auf bestätigten Zustand bei unerwartetem Swap-Typ
}
return FW_STATE_UNKNOWN; // Fallback, sollte nie erreicht werden
}
ssize_t fw_mgmt_get_slot1_size(void)
{
return SLOT1_PARTITION_SIZE;
}

View File

@@ -1,35 +1,21 @@
### Logging
CONFIG_LOG=y
### File System
CONFIG_FS_MGMT=y
CONFIG_FS_MGMT_LOG_LEVEL_DBG=y
CONFIG_FS_LOG_LEVEL_WRN=y
CONFIG_AUDIO_LOG_LEVEL_DBG=y
### Bluetooth
CONFIG_BLE_MGMT=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
### Error handling
CONFIG_HW_STACK_PROTECTION=y
CONFIG_RESET_ON_FATAL_ERROR=y
# Advertising 500ms - 1s
CONFIG_BLE_MGMT_ADV_INT_MIN=160
CONFIG_BLE_MGMT_ADV_INT_MAX=320
## Buzzer protocol
CONFIG_BUZZ_PROTO=y
CONFIG_BUZZ_PROTO_LOG_LEVEL_DBG=y
## Power management
### Power management
CONFIG_PM_DEVICE=y
## Shell
# CONFIG_SHELL=y
# CONFIG_FILE_SYSTEM_SHELL=y
### Stack
CONFIG_MAIN_STACK_SIZE=2048
CONFIG_INIT_STACKS=y
CONFIG_THREAD_STACK_INFO=y
CONFIG_STACK_SENTINEL=y
# CONFIG_LOG_MODE_IMMEDIATE=y

View File

@@ -3,18 +3,21 @@
#include <string.h>
#include "fs_mgmt.h"
#include "ble_mgmt.h"
#include "buzz_proto.h"
#include "fw_mgmt.h"
#include "audio.h"
LOG_MODULE_REGISTER(main);
#if IS_ENABLED(CONFIG_BLE_MGMT)
#include "ble_mgmt.h"
void ble_rx_cb(const uint8_t *data, uint16_t len)
{
uint8_t *buf;
/* 1. Länge prüfen (darf SLAB_BLOCK_SIZE = 256 nicht überschreiten) */
if (len > 256) {
LOG_ERR("Received data too large for proto buf (%u bytes)", len);
if (len > CONFIG_BUZZ_PROTO_SLAB_SIZE) {
LOG_ERR("Received data too large for proto buf (%u > %u)", len, CONFIG_BUZZ_PROTO_SLAB_SIZE);
return;
}
@@ -41,26 +44,27 @@ void ble_rx_cb(const uint8_t *data, uint16_t len)
buzz_proto_buf_free(&buf); /* Speicher bei Fehler sofort wieder freigeben */
}
}
#endif
int main(void)
{
LOG_INF("Starting app on %s (SOC: %s)", CONFIG_BOARD, CONFIG_SOC);
int rc;
rc = fs_mgmt_init();
if (rc < 0) {
LOG_ERR("Failed to initialize file system management: %d", rc);
return rc;
}
#if IS_ENABLED(CONFIG_BLE_MGMT)
/* BLE-Subsystem initialisieren und RX-Callback registrieren */
rc = ble_mgmt_init(ble_rx_cb, CONFIG_BLE_MGMT_DEFAULT_DEVICE_NAME);
int rc = ble_mgmt_init(ble_rx_cb, CONFIG_BLE_MGMT_DEFAULT_DEVICE_NAME);
if (rc < 0) {
LOG_ERR("Failed to initialize BLE management: %d", rc);
return rc;
}
#endif
LOG_INF("Init complete");
LOG_INF("Init complete. Starting audio playback test...");
k_sleep(K_SECONDS(1));
audio_queue_play("/lfs/sys/update", false);
k_sleep(K_SECONDS(1));
audio_start_random_playback(); // Starte die Wiedergabe eines zufälligen Sounds
k_sleep(K_SECONDS(1));
audio_queue_play("/lfs/sys/404", true);
k_sleep(K_FOREVER);
}

1
firmware/sysbuild.conf Normal file
View File

@@ -0,0 +1 @@
SB_CONFIG_BOOTLOADER_MCUBOOT=y

View File

@@ -0,0 +1,7 @@
CONFIG_LOG=y
# CONFIG_MCUBOOT_SERIAL=y
CONFIG_UART_CONSOLE=y
# CONFIG_SINGLE_APPLICATION_SLOT=n
# CONFIG_MCUBOOT_INDICATION_LED=y
# CONFIG_BOOT_SERIAL_CDC_ACM=y
# CONFIG_PM_PARTITION_SIZE_MCUBOOT=0x11000

View File

@@ -0,0 +1,13 @@
/ {
aliases {
mcuboot-button0 = &button0;
mcuboot-led0 = &led0;
};
};
/* Step 2.1 - Configure CDC ACM */
&zephyr_udc0 {
cdc_acm_uart0: cdc_acm_uart0 {
compatible = "zephyr,cdc-acm-uart";
};
};

View File

@@ -1,313 +1,506 @@
# Buzzer Protocol (Wire Specification)
## 1. Zweck und Geltungsbereich
Das Buzzer Protocol definiert ein transportunabhaengiges, binaeres Frame-Format fuer die Kommunikation zwischen Host und Device.
Unterstuetzte Transporte sind aktuell BLE und USB CDC ACM/UART.
Stand: 2026-03-18
Quelle: aktueller Implementierungsstand aus Firmware (`buzz_proto`, `fs_mgmt`, `ble_mgmt`) und Web-Client (`transport`, `parser`).
Das Protokoll spezifiziert:
- Frame-Struktur (Header + Payload)
- Frametypen
- Datentypen fuer Request/Response
- Semantik fuer Stream-Transfers (Verzeichnisliste, Datei, Firmware)
## Ziel und Scope
## 2. Transport- und Codierungsregeln
- Alle ganzzahligen Felder werden in Little Endian uebertragen.
- Die im Header angegebene `payload_length` bezieht sich ausschliesslich auf die Nutzdaten ohne Header.
- Bei UART kann optional eine Synchronisationssequenz `BUZZ` (`0x42 0x55 0x5A 0x5A`) vor einem Frame verwendet werden, um Framing nach Leitungsstoerungen zu resynchronisieren.
Das Buzzer Protocol ist ein binäres Frame-Protokoll für Host <-> Device Kommunikation.
## 3. Frame-Format
Abgedeckte Funktionen:
- Capability-Abfrage (`PROTO_INFO`)
- Dateisystem-Info (`FS_INFO`)
- Verzeichnisliste als Stream (`LS`)
- Datei-/Tag-Download (`FILE_GET`, `TAGS_GET`)
- Datei-/Tag-Upload (`FILE_PUT`, `TAGS_PUT`)
- Datei löschen / umbenennen (`RM_FILE`, `RENAME_FILE`)
Nicht produktiv implementiert:
- `DEVICE_INFO` (`0x02`)
- Firmware-Update (`FW_*`, `FW_UPDATE`)
## Transport und Grundregeln
- Alle Integer-Felder sind Little Endian.
- Jedes Frame hat einen 3-Byte Header.
- `payload_length` enthält nur die Payload-Länge (ohne Header).
- Aktiver Produktiv-Transport ist BLE GATT (RX Write Without Response, TX Notify).
- Es darf genau ein Stream gleichzeitig aktiv sein (`LS`, `FILE_GET`, `FILE_PUT`, `TAGS_*`).
BLE Service UUIDs:
- Service: `e517d988-bab5-4574-8479-97c6cb115ca0`
- RX: `e517d988-bab5-4574-8479-97c6cb115ca1`
- TX: `e517d988-bab5-4574-8479-97c6cb115ca2`
## Frame-Format
### Header
### 3.1 Header (3 Byte)
```c
uint8_t frame_type
uint16_t payload_length // Little Endian
uint8_t frame_type;
uint16_t payload_length; // LE
```
### 3.2 Gesamtframe
```
+------------------+-------------------------+
| Header (3 Byte) | Payload (optional) |
| frame_type (1B) | payload_length Byte |
| payload_len (2B) | |
+------------------+-------------------------+
### Paketstruktur
```mermaid
---
title: "Basic Packet Structure"
---
packet
+8: "Frame type"
+16: "Payload length (LE)"
+40: "Payload (variable length)"
```
## 4. Frametypen
### Maximalgröße
### 4.1 Steuer- und Anfrageframes
| Wert | Name | Richtung | Beschreibung |
|--------|------------|----------------|---------------------------------------|
| `0x00` | `REQUEST` | Host → Device | Abfrage eines Datentyps |
| `0x10` | `RESPONSE` | Device → Host | Antwort auf `REQUEST` |
| `0x11` | `ACK` | Host → Device | Flusskontrolle bei Stream-Transfers |
| `0x12` | `ERROR` | Device → Host | Fehlerantwort mit Fehlercode |
Firmware-Buffer ist slab-basiert (`CONFIG_BUZZ_PROTO_SLAB_SIZE`).
Der effektive Chunk für Transfers wird zusätzlich durch den Transport limitiert. Bei Bluetooth sind das zum Beispiel 3 Bytes:
### 4.2 Datei-Transfer (reserviert, noch nicht implementiert)
| Wert | Name |
|--------|--------------|
| `0x20` | `FILE_START` |
| `0x21` | `FILE_CHUNK` |
| `0x22` | `FILE_END` |
`PROTO_INFO.max_chunk_size` wird dynamisch berechnet als:
### 4.3 Firmware-Transfer (reserviert, noch nicht implementiert)
| Wert | Name |
|--------|------------|
| `0x30` | `FW_START` |
| `0x31` | `FW_CHUNK` |
| `0x32` | `FW_END` |
`min(slab_size - 3, transport_max_payload - 3)`
### 4.4 Verzeichnisliste
| Wert | Name | Richtung | Beschreibung |
|--------|------------|----------------|---------------------------------|
| `0x40` | `LS_START` | Device → Host | Beginn des Listing-Streams |
| `0x41` | `LS_ENTRY` | Device → Host | Ein Verzeichniseintrag |
| `0x42` | `LS_END` | Device → Host | Ende des Listing-Streams |
Das Protokoll ist so ausgelegt, dass es mit mindestens 100 Bytes auskommen sollte.
## 5. Request/Response-Schema
## Frame-Typen
| Wert | Name | Richtung | Bedeutung |
|---|---|---|---|
| `0x00` | `REQUEST` | Host -> Device | API-Aufruf per `data_type` |
| `0x10` | `RESPONSE` | Device -> Host | Antwort auf `REQUEST` |
| `0x11` | `ACK` | Host <-> Device | Credit-basierte Flusskontrolle |
| `0x12` | `ERROR` | Device -> Host | Fehler mit errno-Code |
| `0x13` | `SUCCESS` | Device -> Host | Erfolgreicher Abschluss |
| `0x20` | `FILE_START` | Device -> Host | Start Download-Stream |
| `0x21` | `FILE_CHUNK` | Host <-> Device | Datenblock (Upload/Download) |
| `0x22` | `FILE_END` | Host <-> Device | Streamende mit CRC32 |
| `0x30` | `FW_START` | reserviert | nicht aktiv |
| `0x31` | `FW_CHUNK` | reserviert | aktuell `ENOSYS` |
| `0x32` | `FW_END` | reserviert | nicht aktiv |
| `0x40` | `LS_START` | Device -> Host | Start Verzeichnis-Stream |
| `0x41` | `LS_ENTRY` | Device -> Host | Verzeichniseintrag |
| `0x42` | `LS_END` | Device -> Host | Ende Verzeichnis-Stream |
## Data-Typen (`REQUEST`)
| Wert | Name | Status | Beschreibung |
|---|---|---|---|
| `0x01` | `PROTO_INFO` | aktiv | Protokollversion + max Chunkgröße |
| `0x02` | `DEVICE_INFO` | aktiv | Device-Infos (Board, Revision, SOC, ID) |
| `0x03` | `FS_INFO` | aktiv | Dateisystem- und Pfadinfos |
| `0x04` | `FW_INFO` | aktiv | Info über Firmware-Status und -Version sowie Kernelversion |
| `0x20` | `FILE_GET` | aktiv | Datei vom Device streamen |
| `0x21` | `FILE_PUT` | aktiv | Datei zum Device hochladen |
| `0x22` | `TAGS_GET` | aktiv | nur Tag-Bereich streamen |
| `0x23` | `TAGS_PUT` | aktiv | nur Tag-Bereich schreiben |
| `0x24` | `RM_FILE` | aktiv | Datei löschen |
| `0x25` | `RENAME_FILE` | aktiv | Datei umbenennen |
| `0x30` | `FW_UPDATE` | reserviert | aktuell nicht bedient |
| `0x40` | `LS` | aktiv | Verzeichnisliste starten |
## Request/Response-Formate
### Generischer Request
### 5.1 Request (`frame_type = 0x00`)
Payload-Mindestformat:
```c
uint8_t data_type // Nutzt enum buzz_data_type
// optional: datentypspezifische Parameter
uint8_t data_type;
// optional daten_typspezifische Parameter
```
Wire-Format:
```
[0x00][payload_length LE][data_type][optional parameters]
Wire:
```text
[0x00][payload_len LE][data_type][optional...]
```
### 5.2 Response (`frame_type = 0x10`)
Payload-Mindestformat:
```c
uint8_t data_type // Echo des angefragten data_type
// danach: datentypspezifische Response-Daten
```mermaid
---
title: "Generic Requst Structure"
---
packet
+8: "Frame type REQUEST: 0x00"
+16: "Payload length (LE)"
+8: "Data type"
+32: "Optional payload (variable length)"
```
Wire-Format:
```
[0x10][payload_length LE][data_type][response payload]
```
### `PROTO_INFO` (`0x01`)
## 6. Datentypen (Request/Response)
Definierte `data_type`-Werte:
| Wert | Name | Beschreibung |
|--------|---------------|--------------------------------------|
| `0x01` | `PROTO_INFO` | Protokollversion und Chunk-Groesse |
| `0x02` | `DEVICE_INFO` | Geraeteinformationen (TBD) |
| `0x03` | `FS_INFO` | Dateisystem-Statistik und Pfadnamen |
| `0x40` | `LS` | Verzeichnisliste starten |
### 6.1 `PROTO_INFO` (`0x01`)
Request-Parameter: keine
Request: keine Zusatzdaten.
Response-Payload:
```c
uint8_t data_type; // 0x01
uint16_t version; // Protokollversion (LE)
uint16_t max_chunk_size; // max. Nutzdaten pro Frame ohne Header (LE)
uint16_t version; // LE
uint16_t max_chunk_size; // LE
```
Hinweis: `max_chunk_size` ergibt sich aus der internen Slab-Konfiguration (`CONFIG_BUZZ_PROTO_SLAB_SIZE - 3`).
```mermaid
---
title: "PROTO_INFO response structure"
---
packet
+8: "Frame type RESPONSE: 0x10"
+16: "Payload length (LE): 5"
+8: "Data type PROTO_INFO: 0x01"
+16: "Protocol Version (LE)"
+16: "Max Chunk Size (LE)"
```
### 6.2 `DEVICE_INFO` (`0x02`)
TBD
### `DEVICE_INFO` (`0x02`)
### 6.3 `FS_INFO` (`0x03`)
Request-Parameter: keine
Request: keine Zusatzdaten.
Respone-Payload:
```c
uint8_t data_type; // 0x02
uint8_t[8] device_id;
uint8_t board_len;
uint8_t rev_len;
uint8_t soc_len;
uint8_t data[]; // board, rev und soc, ohne Nullterminierung
```
***Hinweis:*** In der aktuellen implementierung werden die Strings auf eine maximale Länge von 32 Zeichen beschränkt. Dies sollte für alle Fälle genügen.
### `FS_INFO` (`0x03`)
Request: keine Zusatzdaten.
Response-Payload:
```c
uint8_t data_type; // 0x03
uint32_t total_size; // Gesamtgroesse Flash in Bytes (LE)
uint32_t free_size; // Freier Speicher in Bytes (LE)
uint8_t max_path_length; // Maximal erlaubte Pfadlaenge
uint8_t sys_path_length; // Laenge des System-Pfades (ohne 0-Terminator)
uint8_t audio_path_length; // Laenge des Audio-Pfades (ohne 0-Terminator)
uint8_t data[]; // sys_path gefolgt von audio_path, nicht nullterminiert
uint8_t data_type; // 0x03
uint32_t total_size; // LE
uint32_t free_size; // LE
uint8_t max_path_length;
uint8_t sys_path_length;
uint8_t audio_path_length;
uint8_t data[]; // sys_path + audio_path ohne Nullterminierung
```
### 6.4 `LS` (`0x40`) — Verzeichnisliste anfordern
Startet einen LS-Stream fuer den angegebenen Pfad.
Im `data` folgen sich System- und Audiopfad ohne Abstand, und ohne 0-Terminierung (`\0`). Beispiel für Systempfad `/lfs/sys`und Audiopfad `/lfs/a`:
`/lfs/sys/lfs/a`
`sys_path_len` ist in diesem Beispiel 8 und `audio_path_len` ist 6.
```mermaid
---
title: "FS_INFO response structure"
---
packet
+8: "Frame type RESPONSE: 0x10"
+16: "Payload length (LE): variable, 12 + sys path length + audio path length"
+8: "Data type FS_INFO: 0x03"
+32: "Total size (LE)"
+32: "Free size (LE)"
+8: "Max path length"
+8: "Sys path length"
+8: "Audio path length"
+40: "Sys path + Audio Path (variable)"
```
Beispielpaket mit **8 MiB Flash**, wovon **7 MiB frei** sind und den Pfaden `/lfs/sys` und `/lfs/a`. Die **maximale Pfadlänge** sind **32** Zeichen:
```mermaid
---
title: "FS_INFO response example"
---
packet
+8: "Frame type RESPONSE: 0x10"
+16: "Payload length (LE): 26 (0x1A 0x00)"
+8: "Data type FS_INFO: 0x03"
+32: "Total size (LE): 8388608 (0x00 0x00 0x80 0x00)"
+32: "Free size (LE): 7340032 (0x00 0x00 0x70 0x00)"
+8: "Max path length: 32"
+8: "Sys path length: 8"
+8: "Audio path length: 6"
+112: "data: '/lfs/sys/lfs/a'"
```
Das Beispiel schaut in HEX so aus:
```text
0x10 0x1A 0x00 0x03 0x00 0x00 0x80 0x00 0x00 0x00 0x70 0x00 0x20 0x08 0x06
0x2F 0x6C 0x66 0x73 0x2F 0x73 0x79 0x73 0x2F 0x6C 0x66 0x73 0x2F 0x61
```
### `FW_INFO` (`0x04`)
Request: keine Zusatzdaten
Response:
```c
uint8_t fw_status; /* 0x00: Confirmed, 0x01: Pending, 0x02: Testing, 0xFF: Unbekannt */
uint32_t slot1_size; /* (LE) Grösse des Firmware Update Slots */
uint8_t fw_version_len; /* Länge des Firmware-Versionsstring */
uint8_t kernel_version_len; /* Länge des Kernel-Versionsstrings */
uint8_t data[]; /* FW-Version und Kernelversion, ohne Nullterminierung */
```
***Hinweis:*** in der Aktuellen implementierung werden die Versionen auf 32 Zeichen limitiert.
### `LS` (`0x40`)
Request-Payload:
```c
uint8_t data_type; // 0x40
char path[]; // Pfad ohne 0-Terminator, Laenge ergibt sich aus payload_length - 1
uint8_t data_type; // 0x40
char path[]; // ohne Nullterminierung
```
Wire-Format (Beispiel fuer Pfad `/a`):
```
[0x00][0x03 0x00][0x40][0x2F 0x61]
```
### `FILE_GET` (`0x20`) und `TAGS_GET` (`0x22`)
Das Device antwortet mit dem LS-Stream (siehe Abschnitt 8).
Request-Payload:
## 7. ACK- und ERROR-Frames
### 7.1 ACK (`frame_type = 0x11`) — Host → Device
Wird waehrend eines laufenden LS-Streams gesendet, um dem Device Credits (Sendeerlaubnisse) zu erteilen.
Format:
```c
// Header:
uint8_t frame_type; // 0x11
uint16_t payload_length; // 0x0002
// Payload:
uint16_t credits; // Anzahl der Entries, die das Device senden darf (LE)
uint8_t data_type; // 0x20 oder 0x22
char path[]; // ohne Nullterminierung
```
Wire-Format (Beispiel: 64 Credits):
```
[0x11][0x02 0x00][0x40 0x00]
```
Antwort ist ein Stream aus `FILE_START` -> `FILE_CHUNK`* -> `FILE_END`.
Semantik:
- Der Host sendet nach Empfang von `LS_START` initial Credits (typisch 64).
- Das Device dekrementiert seinen internen Credit-Zaehler mit jeder gesendeten `LS_ENTRY`.
- Bei 0 Credits wartet das Device auf ein weiteres ACK (Timeout: 5 × 500 ms, danach Abbruch).
- Der Host soll bei Bedarf weitere Credits nachsenden, bevor die bisherigen aufgebraucht sind.
### `FILE_PUT` (`0x21`) und `TAGS_PUT` (`0x23`)
Request-Payload:
### 7.2 ERROR (`frame_type = 0x12`) — Device → Host
Format:
```c
// Header:
uint8_t frame_type; // 0x12
uint16_t payload_length; // 0x0002
// Payload:
uint16_t error_code; // Positiver Zephyr-errno-Wert (LE)
uint8_t data_type; // 0x21 oder 0x23
uint32_t total_size; // LE
char path[]; // ohne Nullterminierung
```
Wire-Format (Beispiel: ENOENT = 2):
```
[0x12][0x02 0x00][0x02 0x00]
Danach sendet der Host:
- `FILE_CHUNK` Frames
- abschließend `FILE_END` mit CRC32
### `RM_FILE` (`0x24`)
Request-Payload:
```c
uint8_t data_type; // 0x24
uint8_t path_length;
char path[]; // ohne Nullterminierung
```
ERROR kann jederzeit als Antwort auf einen REQUEST oder waehrend eines Streams gesendet werden.
Ein ERROR-Frame waehrend eines aktiven LS-Streams beendet diesen implizit.
### `RENAME_FILE` (`0x25`)
Fehlercode-Tabelle (Zephyr errno, positiver Wert):
| Code | Zephyr-Name | Bedeutung |
|------|----------------|---------------------------------------------|
| 1 | `EPERM` | Fehlende Berechtigung |
| 2 | `ENOENT` | Datei oder Verzeichnis nicht gefunden |
| 5 | `EIO` | Ein-/Ausgabefehler auf dem Flash |
| 12 | `ENOMEM` | Nicht genuegend Speicher frei |
| 16 | `EBUSY` | Geraet oder Ressource belegt |
| 22 | `EINVAL` | Ungültiges Argument oder Parameter |
| 24 | `EMFILE` | Zu viele offene Dateien |
| 28 | `ENOSPC` | Kein freier Speicherplatz mehr |
| 36 | `ENAMETOOLONG` | Dateiname oder Pfad zu lang |
| 88 | `ENOSYS` | Funktion nicht implementiert |
| 134 | `ENOTSUP` | Operation nicht unterstuetzt |
Request-Payload:
## 8. LS-Stream (Verzeichnisliste)
Der LS-Stream wird durch einen `REQUEST` mit `data_type = 0x40` ausgeloest und laeuft wie folgt ab:
```
Host Device
| |
|-- REQUEST (data_type=LS, path) -->|
| | (oeffnet Verzeichnis)
|<--------- LS_START (leer) --------|
| |
|------ ACK (credits=64) ---------->|
| |
|<-- LS_ENTRY (entry 1) ------------|
|<-- LS_ENTRY (entry 2) ------------|
| ... |
|<-- LS_ENTRY (entry 64) -----------| (credits = 0, Device wartet)
| |
|------ ACK (credits=64) ---------->|
| |
|<-- LS_ENTRY (entry 65) -----------|
| ... |
|<--------- LS_END -----------------|
```c
uint8_t data_type; // 0x25
uint8_t old_path_length;
uint8_t new_path_length;
char paths[]; // old_path + new_path (jeweils ohne Nullterminierung)
```
### 8.1 `LS_START` (`0x40`) — Device → Host
Signalisiert den Beginn des Streams. Keine Payload.
## ACK / ERROR / SUCCESS
```
[0x40][0x00 0x00]
```
### 8.2 `LS_ENTRY` (`0x41`) — Device → Host
Ein Eintrag pro Verzeichniselement.
### ACK (`0x11`)
Payload:
```c
uint8_t type; // 0x00 = Datei, 0x01 = Verzeichnis (buzz_fs_entry_type)
uint32_t size; // Dateigroesse in Bytes (LE); bei Verzeichnissen 0
uint8_t name_length; // Laenge des Namens (ohne 0-Terminator)
char name[]; // Datei-/Verzeichnisname, nicht nullterminiert
uint16_t credits; // LE
```
`type`-Werte:
| Wert | Bedeutung |
|--------|---------------|
| `0x00` | Datei (FILE) |
| `0x01` | Verzeichnis (DIR) |
Wichtig: Es gibt zwei Semantiken je nach Richtung.
### 8.3 `LS_END` (`0x42`) — Device → Host
Signalisiert das Ende des Streams.
- Download (`LS`, `FILE_GET`, `TAGS_GET`):
Host -> Device, Credits werden im Device als absoluter neuer Stand gesetzt.
- Upload (`FILE_PUT`, `TAGS_PUT`):
Device -> Host, Credits sind zusätzlich gewährte Tokens (Host addiert sie).
### ERROR (`0x12`)
Payload:
```c
uint32_t total_entries; // Gesamtzahl gesendeter Eintraege (LE)
uint16_t error_code; // positiver errno-Wert, LE
```
Der Host kann `total_entries` mit der empfangenen Anzahl von `LS_ENTRY`-Frames vergleichen, um Vollstaendigkeit zu pruefen.
Häufige Codes:
### 8.4 Fehler- und Timeoutbehandlung
- Tritt ein Fehler beim Lesen auf, sendet das Device einen `ERROR`-Frame und beendet den Stream.
- Empfaengt das Device 5 Mal in Folge keine Credits innerhalb von je 500 ms (2,5 s gesamt), bricht es den Stream intern ab (kein ERROR-Frame, Stream wird still verworfen).
- Der Host sollte einen eigenen Watchdog implementieren; empfohlener Timeout: 3 s ohne empfangenen Frame.
| Code | Name | Bedeutung |
|---|---|---|
| `1` | `EPERM` | fehlende Rechte |
| `2` | `ENOENT` | Datei/Ordner nicht gefunden |
| `5` | `EIO` | Flash-I/O-Fehler |
| `16` | `EBUSY` | Stream/Ressource belegt |
| `22` | `EINVAL` | ungültige Nutzdaten |
| `36` | `ENAMETOOLONG` | Pfad zu lang |
| `71` | `EPROTO` | unzulässiger Frame-Typ |
| `74` | `EBADMSG` | ungültiger Stream-Frame/CRC Fehler |
| `88` | `ENOSYS` | nicht implementiert |
| `90` | `EMSGSIZE` | max Payload ungültig |
| `116` | `ETIMEDOUT` | Credit-/Stream-Timeout |
| `134` | `ENOTSUP` | nicht unterstützt |
## 9. Beispiele
### SUCCESS (`0x13`)
### 9.1 PROTO_INFO abfragen
Payload:
```c
uint8_t data_type; // erfolgreich abgeschlossener Befehl
```
Wird derzeit u.a. für `FILE_PUT`, `TAGS_PUT`, `RM_FILE`, `RENAME_FILE` genutzt.
## Verzeichnisliste (`LS`)
```mermaid
sequenceDiagram
participant Host
participant Device
Host->>Device: REQUEST(LS, path)
Device-->>Host: LS_START
Host->>Device: ACK(credits=64)
loop solange credits > 0
Device-->>Host: LS_ENTRY
end
alt Verzeichnis vollständig
Device-->>Host: LS_END(total_entries)
else keine Credits/Timeout
Device-->>Host: ERROR(ETIMEDOUT)
end
```
Hinweis zum aktuellen Web-Client (März 2026): Für `LS` wird initial `ACK(64)` gesendet, ein dynamisches Nachfüllen ist noch nicht implementiert. Große Verzeichnisse können deshalb in `ETIMEDOUT` laufen.
## Datei-/Tag-Download (`FILE_GET`, `TAGS_GET`)
```mermaid
sequenceDiagram
participant Host
participant Device
Host->>Device: REQUEST(FILE_GET|TAGS_GET, path)
Device-->>Host: FILE_START(total_size)
Host->>Device: ACK(credits=128)
loop chunks
Device-->>Host: FILE_CHUNK(data)
Note over Host: Credits dekrementieren
alt Credits <= 64
Host->>Device: ACK(credits=128)
end
end
Device-->>Host: FILE_END(crc32)
Note over Host: CRC prüfen
```
## Datei-/Tag-Upload (`FILE_PUT`, `TAGS_PUT`)
```mermaid
sequenceDiagram
participant Host
participant Device
Host->>Device: REQUEST(FILE_PUT|TAGS_PUT, total_size, path)
Device-->>Host: ACK(initial credits)
loop solange Host-Credits > 0
Host->>Device: FILE_CHUNK(data)
Note over Device: schreibt Flash, zählt unacked_chunks
alt ACK_WATERMARK erreicht
Device-->>Host: ACK(additional credits)
end
end
Host->>Device: FILE_END(crc32)
alt CRC korrekt
Device-->>Host: SUCCESS(FILE_PUT|TAGS_PUT)
else CRC/Write Fehler
Device-->>Host: ERROR(EBADMSG/EIO/...)
end
```
## Payload-Frames im Detail
### `LS_ENTRY` (`0x41`)
```c
uint8_t type; // 0x00 file, 0x01 dir
uint32_t size; // LE
uint8_t name_length;
char name[]; // ohne Nullterminierung
```
### `LS_END` (`0x42`)
```c
uint32_t total_entries; // LE
```
### `FILE_START` (`0x20`)
```c
uint32_t total_size; // LE
```
`FILE_GET`: komplette Dateigröße.
`TAGS_GET`: nur Tag-Teil der Datei.
### `FILE_CHUNK` (`0x21`)
Payload sind rohe Nutzdatenbytes.
### `FILE_END` (`0x22`)
```c
uint32_t crc32; // LE, IEEE CRC32
```
## Beispiel-Frames (Hex)
### `PROTO_INFO` Request/Response
Request:
```
```text
00 01 00 01
```
- `00`: `REQUEST`
- `01 00`: `payload_length = 1`
- `01`: `data_type = PROTO_INFO`
Response (Beispielwerte):
```
Beispiel-Response:
```text
10 05 00 01 01 00 FD 00
```
- `10`: `RESPONSE`
- `05 00`: `payload_length = 5`
- `01`: `data_type = PROTO_INFO`
- `01 00`: `version = 1`
- `FD 00`: `max_chunk_size = 253`
### 9.2 Verzeichnisliste `/a` anfordern
Interpretation:
- `10`: RESPONSE
- `05 00`: Payload 5 Byte
- `01`: PROTO_INFO
- `01 00`: Version 1
- `FD 00`: max_chunk_size = 253
Request:
```
### `LS` Request für `/a`
```text
00 03 00 40 2F 61
```
- `00`: `REQUEST`
- `03 00`: `payload_length = 3`
- `40`: `data_type = LS`
- `2F 61`: Pfad `/a`
Antwort (Sequenz):
```
40 00 00 // LS_START, keine Payload
// Host sendet ACK mit Credits
11 02 00 40 00 // ACK, 64 Credits
// Device sendet Eintraege
41 0A 00 00 00 00 00 00 06 73 6F 75 6E 64 31 // LS_ENTRY: FILE, size=0, name="sound1" (gekuerzt)
// ... weitere Eintraege ...
42 04 00 01 00 00 00 // LS_END, total_entries = 1
```
Interpretation:
- `00`: REQUEST
- `03 00`: Payload 3 Byte
- `40`: data_type LS
- `2F 61`: `/a`
### Implementierungsnotizen
- Unknown `REQUEST.data_type` wird aktuell mit `ERROR(EINVAL)` beantwortet.
- Unbekannte/unerwartete `frame_type` im aktiven Protokollthread führen zu `ERROR(EPROTO)`.
- Stream-Timeout in Firmware erzeugt aktiv `ERROR(ETIMEDOUT)`.
- Upload-Timeout im FS-Thread (2 s Inaktivität) bricht intern ab; die Host-Seite sollte eigene Watchdogs haben.

View File

@@ -4,7 +4,7 @@
"singleAttributePerLine": false,
"overrides": [
{
"files": ["*.svelte", "*.astro"],
"files": ["*.svelte", "*.astro", "*.ts", "*.js", "*.tsx", "*.jsx"],
"options": {
"printWidth": 100
}

View File

@@ -1,12 +1,15 @@
// @ts-check
import { defineConfig } from 'astro/config';
import svelte from '@astrojs/svelte';
import tailwindcss from '@tailwindcss/vite';
const isProd = process.env.NODE_ENV === 'production';
// https://astro.build/config
export default defineConfig({
base: isProd ? '/buzzer/' : '/',
site: 'https://home.iten.pro',
integrations: [svelte()],
vite: {

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,9 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><style>
#light-icon {
display: inline;
}
#dark-icon {
display: none;
}
@media (prefers-color-scheme: dark) {
#light-icon {
display: none;
}
#dark-icon {
display: inline;
}
}
</style><g id="light-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><g><g transform="matrix(31.25,0,0,31.25,0,0)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="32" height="32" fill="#000000" viewBox="0 0 256 256" id="svg1" sodipodi:docname="siren.svg" inkscape:version="1.4 (86a8ad7, 2024-10-11)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg"><defs id="defs1"></defs><sodipodi:namedview id="namedview1" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="36.46875" inkscape:cx="16" inkscape:cy="16" inkscape:window-width="3440" inkscape:window-height="1369" inkscape:window-x="1672" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:current-layer="svg1"></sodipodi:namedview><path d="m 232,176 v 24 a 16,16 0 0 1 -16,16 H 40 A 16,16 0 0 1 24,200 V 176 A 16,16 0 0 1 40,160 V 128 A 88,88 0 0 1 128.67,40 C 176.82,40.36 216,80.29 216,129 v 31 a 16,16 0 0 1 16,16 z" id="path5"></path><path d="M 216,200 V 176 H 40 v 24 z" id="path7" style="fill:#b3b3b3"></path><path d="M 56,160 H 200 V 129 C 200,89 167.95,56.29 128.55,56 H 128 a 72,72 0 0 0 -72,72 z" id="path6" style="fill:#ff0000"></path><path d="M 137.34,72.11 A 8,8 0 1 0 134.7,87.89 C 153.67,91.08 168,108.32 168,128 a 8,8 0 0 0 16,0 c 0,-27.4 -20.07,-51.43 -46.68,-55.89 z" id="path4"></path><path d="M 50.34,45.66 A 8.0044488,8.0044488 0 0 0 61.66,34.34 l -8,-8 A 8.0044488,8.0044488 0 0 0 42.34,37.66 Z" id="path3"></path><path d="m 200,48 a 8,8 0 0 0 5.66,-2.34 l 8,-8 A 8.0044488,8.0044488 0 0 0 202.34,26.34 l -8,8 A 8,8 0 0 0 200,48 Z" id="path2"></path><path d="M 120,16 V 8 a 8,8 0 0 1 16,0 v 8 a 8,8 0 0 1 -16,0 z" id="path1"></path></svg></g></g></svg></g><g id="dark-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><g><g transform="matrix(31.25,0,0,31.25,0,0)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="32" height="32" fill="#000000" viewBox="0 0 256 256" id="svg1" sodipodi:docname="siren_dark.svg" inkscape:version="1.4 (86a8ad7, 2024-10-11)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg"><defs id="defs1"></defs><sodipodi:namedview id="namedview1" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="36.46875" inkscape:cx="16" inkscape:cy="16" inkscape:window-width="3440" inkscape:window-height="1369" inkscape:window-x="1672" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:current-layer="svg1"></sodipodi:namedview><path d="m 232,176 v 24 a 16,16 0 0 1 -16,16 H 40 A 16,16 0 0 1 24,200 V 176 A 16,16 0 0 1 40,160 V 128 A 88,88 0 0 1 128.67,40 C 176.82,40.36 216,80.29 216,129 v 31 a 16,16 0 0 1 16,16 z" id="path5" style="fill:#ffffff"></path><path d="M 216,200 V 176 H 40 v 24 z" id="path7" style="fill:#b3b3b3"></path><path d="M 56,160 H 200 V 129 C 200,89 167.95,56.29 128.55,56 H 128 a 72,72 0 0 0 -72,72 z" id="path6" style="fill:#ff0000"></path><path d="M 137.34,72.11 A 8,8 0 1 0 134.7,87.89 C 153.67,91.08 168,108.32 168,128 a 8,8 0 0 0 16,0 c 0,-27.4 -20.07,-51.43 -46.68,-55.89 z" id="path4" style="fill:#ffffff"></path><path d="M 50.34,45.66 A 8.0044488,8.0044488 0 0 0 61.66,34.34 l -8,-8 A 8.0044488,8.0044488 0 0 0 42.34,37.66 Z" id="path3" style="fill:#ffffff"></path><path d="m 200,48 a 8,8 0 0 0 5.66,-2.34 l 8,-8 A 8.0044488,8.0044488 0 0 0 202.34,26.34 l -8,8 A 8,8 0 0 0 200,48 Z" id="path2" style="fill:#ffffff"></path><path d="M 120,16 V 8 a 8,8 0 0 1 16,0 v 8 a 8,8 0 0 1 -16,0 z" id="path1" style="fill:#ffffff"></path></svg></g></g></svg></g></svg>

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "Edis Buzzer",
"short_name": "Buzzer",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

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,52 @@
<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 shadow-red-500/30 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

@@ -0,0 +1,456 @@
<script lang="ts">
import { GearIcon, CloudArrowUpIcon, InfoIcon, CaretDownIcon } from "phosphor-svelte";
import { slide, fade } from "svelte/transition";
import { addToast } from "../lib/toast";
import { processAudioFile } from "../lib/audioProcessor";
import { saveLocalFile } from "../lib/db";
import { refreshLocal } from "../lib/sync";
import { updateLocalAudioFormat, updateLocalAudioCrc } from "../lib/tagHandler";
import { audioOptions } from "../lib/store";
import { AUDIO_PRESETS, type AudioPresetName } from "../lib/settings";
import { tooltip } from "../lib/actions/tooltip";
import { clickOutside } from "../lib/actions/clickOutside"; // Angenommen, diese Action existiert bereits (analog zu Ihren anderen Menüs).
let isFileDragOver = false;
let showSettings = false;
let showPresetDropdown = false; // Neuer Zustand für das Custom Dropdown
// Definitionen der Preset-Infos für Tooltips im Dropdown
const presetInfos: Record<AudioPresetName, { name: string; desc: string }> = {
normal: { name: "Normal", desc: "Für natürliche Sprache. Moderate Verdichtung." },
broadcast: { name: "Broadcast", desc: "Aggressiv, sehr laut (Radio-Style). Maximale Präsenz." },
custom: { name: "Benutzerdefiniert", desc: "Manuelle Anpassung aller Kompressor-Parameter." },
};
// Reagiert auf Preset-Wechsel
function handlePresetChange(preset: AudioPresetName) {
$audioOptions.preset = preset;
showPresetDropdown = false; // Dropdown nach Auswahl schließen
if (preset !== "custom") {
const p = AUDIO_PRESETS[preset];
$audioOptions = {
...$audioOptions,
compressorThreshold: p.compressorThreshold!,
compressorRatio: p.compressorRatio!,
compressorKnee: p.compressorKnee!,
compressorAttack: p.compressorAttack!,
compressorRelease: p.compressorRelease!,
};
}
}
// Setzt das Preset automatisch auf Custom, sobald ein Slider bewegt wird
function setCustomPreset() {
$audioOptions.preset = "custom";
}
async function handleDrop(event: DragEvent) {
isFileDragOver = false;
showSettings = false;
showPresetDropdown = false; // Dropdown sicherheitshalber schließen
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
const fileArray = Array.from(files);
const audioFiles = fileArray.filter((file) => file.type.startsWith("audio/"));
const rawFiles = fileArray.filter((file) => file.type === ""); // Ohne MIME-Type
if (audioFiles.length > 0 || rawFiles.length > 0) {
// 1. Reguläre Dateien umwandeln & taggen
for (const file of audioFiles) {
try {
const { buffer, name } = await processAudioFile(file, $audioOptions);
await saveLocalFile(name, new Blob([buffer]), buffer.byteLength);
await updateLocalAudioFormat(name, { codec: 0, bitDepth: 16, sampleRate: 16000 });
await updateLocalAudioCrc(name);
addToast(`Gespeichert & Getaggt: ${name}`, "success");
} catch (err) {
addToast(`Fehler bei ${file.name}: ${err}`, "error");
}
}
// 2. Rohdaten bypassen (direkt speichern & taggen)
for (const file of rawFiles) {
try {
const name = file.name;
await saveLocalFile(name, file, file.size);
// TagHandler erkennt alte Tags automatisch und fügt System-Tags sicher hinzu
await updateLocalAudioFormat(name, { codec: 0, bitDepth: 16, sampleRate: 16000 });
await updateLocalAudioCrc(name);
addToast(`Roh-Datei importiert: ${name}`, "success");
} catch (err) {
addToast(`Fehler bei Roh-Import ${file.name}: ${err}`, "error");
}
}
refreshLocal();
} else {
addToast("Keine gültigen Dateien gefunden.", "error");
}
}
</script>
<svelte:window
on:keydown={(e) => {
if (e.key === "Escape") showSettings = false;
}}
/>
<section class="buzzer-card flex flex-col h-full transition-all duration-300 min-w-[320px]">
<div class="card-header" class:blur={isFileDragOver}>
<h3 class="card-title">Dateiverarbeitung</h3>
<button class="btn" aria-label="Einstellungen" on:click={() => (showSettings = !showSettings)}>
<GearIcon class="icon" />
</button>
</div>
<div
class="card-body flex flex-col flex-1"
role="region"
aria-label="Audiofile dropzone"
on:dragenter={() => (isFileDragOver = true)}
on:dragleave={() => (isFileDragOver = false)}
on:dragover|preventDefault
on:drop|preventDefault={handleDrop}
>
{#if !showSettings}
<div
class="flex justify-center items-center pointer-events-none p-4 pb-0"
in:fade={{ duration: 200 }}
>
<div
class="text-center text-2xs tracking-tight font-mono font-semibold transition-all"
class:blur={isFileDragOver}
>
<span class={$audioOptions.lowCut ? "text-emerald-500" : "text-text-muted"}>LOW CUT</span>
|
<span class={$audioOptions.compress ? "text-emerald-500" : "text-text-muted"}>
COMPRESSOR
</span>
|
<span class={$audioOptions.normalize ? "text-emerald-500" : "text-text-muted"}>
NORMALIZER
</span>
</div>
</div>
<div class="flex-1 p-4 pt-2">
<div
in:fade={{ duration: 200 }}
class="flex h-full items-center justify-center border-2 border-dashed rounded-lg pointer-events-none transition-all duration-300 min-h-[8rem]"
class:border-slate-300={!isFileDragOver}
class:border-indigo-500={isFileDragOver}
class:bg-indigo-50={isFileDragOver}
>
<div class="relative flex size-24 transition-transform" class:scale-110={isFileDragOver}>
<span
class="absolute inline-flex h-full w-full opacity-50"
class:animate-ping={isFileDragOver}
class:text-indigo-500={isFileDragOver}
class:text-transparent={!isFileDragOver}
>
<CloudArrowUpIcon class="w-full h-full" />
</span>
<span
class="relative inline-flex size-24 transition-colors"
class:text-slate-500={!isFileDragOver}
class:text-indigo-600={isFileDragOver}
>
<CloudArrowUpIcon class="w-full h-full transition-colors" />
</span>
</div>
</div>
</div>
{:else}
<div class="flex-1 flex flex-col bg-white h-full" in:slide={{ duration: 300 }}>
<div
class="flex items-center justify-between px-4 pt-4 pb-2 border-b border-slate-100 mb-4"
>
<h4 class="font-semibold text-sm text-slate-800">Audio-Optionen</h4>
</div>
<div class="space-y-5 flex-1 overflow-y-auto px-4 pb-4">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
use:tooltip={{
text: "Entfernt tieffrequentes Rumpeln (Wind, Trittschall). Schont kleine Lautsprecher und verhindert Verzerrungen.",
pos: "right",
}}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<label class="flex items-center space-x-2 text-sm font-medium cursor-pointer flex-1">
<input
type="checkbox"
bind:checked={$audioOptions.lowCut}
class="accent-indigo-600"
/>
<span>Low-Cut Filter</span>
</label>
</div>
{#if $audioOptions.lowCut}
<div class="pl-6 flex items-center gap-3">
<span class="text-xs text-text-muted w-10 text-right">
{$audioOptions.lowCutFreq} Hz
</span>
<input
type="range"
min="50"
max="300"
step="10"
bind:value={$audioOptions.lowCutFreq}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
use:tooltip={{
text: "Gleicht Lautstärkeunterschiede an. Macht leise Passagen lauter und fängt laute Spitzen ab.",
pos: "right",
}}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<label class="flex items-center space-x-2 text-sm font-medium cursor-pointer flex-1">
<input
type="checkbox"
bind:checked={$audioOptions.compress}
class="accent-indigo-600"
/>
<span>Dynamik-Kompressor</span>
</label>
</div>
{#if $audioOptions.compress}
<div class="pl-6 flex flex-col gap-3">
<div class="relative" use:clickOutside={() => (showPresetDropdown = false)}>
<button
type="button"
class="flex items-center justify-between w-full text-sm border border-slate-300 rounded p-1.5 bg-slate-50 hover:bg-slate-100 transition"
on:click={() => (showPresetDropdown = !showPresetDropdown)}
>
<div class="flex items-center gap-2">
<div
use:tooltip={{ text: presetInfos[$audioOptions.preset].desc, pos: "right" }}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<span>Preset: {presetInfos[$audioOptions.preset].name}</span>
</div>
<span class:rotate-180={showPresetDropdown}>
<CaretDownIcon class="size-4 text-slate-500 transition-transform" />
</span>
</button>
{#if showPresetDropdown}
<div
class="absolute top-full left-0 right-0 mt-1 bg-white border border-slate-200 rounded-md shadow-lg z-20 overflow-hidden"
transition:fade={{ duration: 150 }}
>
{#each Object.entries(presetInfos) as [key, info]}
<button
type="button"
class="flex items-center gap-2 w-full text-left text-sm px-3 py-2 hover:bg-indigo-50 transition"
class:bg-indigo-100={$audioOptions.preset === key}
class:font-medium={$audioOptions.preset === key}
on:click={() => handlePresetChange(key as AudioPresetName)}
>
<div use:tooltip={{ text: info.desc, pos: "right" }}>
<InfoIcon class="text-text-muted size-4" />
</div>
<span>{info.name}</span>
</button>
{/each}
</div>
{/if}
</div>
<div class="grid grid-cols-1 gap-2 text-xs">
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Pegel, ab dem der Kompressor zu arbeiten beginnt.",
pos: "right",
}}
>
Threshold
</span>
<span class="text-text-muted w-16 text-right">
{$audioOptions.compressorThreshold} dB
</span>
<input
type="range"
min="-60"
max="0"
step="1"
bind:value={$audioOptions.compressorThreshold}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Stärke der Pegelreduzierung oberhalb des Thresholds.",
pos: "right",
}}
>
Ratio
</span>
<span class="text-text-muted w-16 text-right">
{$audioOptions.compressorRatio}:1
</span>
<input
type="range"
min="1"
max="20"
step="1"
bind:value={$audioOptions.compressorRatio}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Reaktionszeit des Kompressors auf Pegelüberschreitungen.",
pos: "right",
}}
>
Attack
</span>
<span class="text-text-muted w-16 text-right">
{($audioOptions.compressorAttack * 1000).toFixed(0)} ms
</span>
<input
type="range"
min="0.001"
max="0.1"
step="0.001"
bind:value={$audioOptions.compressorAttack}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Zeit, bis der Kompressor nach Unterschreitung des Thresholds aufhört zu arbeiten.",
pos: "right",
}}
>
Release
</span>
<span class="text-text-muted w-16 text-right">
{($audioOptions.compressorRelease * 1000).toFixed(0)} ms
</span>
<input
type="range"
min="0.01"
max="1"
step="0.01"
bind:value={$audioOptions.compressorRelease}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
</div>
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
use:tooltip={{
text: "Hebt das verarbeitete Signal linear an, bis der lauteste Peak exakt den Zielwert erreicht.",
pos: "right",
}}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<label class="flex items-center space-x-2 text-sm font-medium cursor-pointer flex-1">
<input
type="checkbox"
bind:checked={$audioOptions.normalize}
class="accent-indigo-600"
/>
<span>Lautstärke normalisieren</span>
</label>
</div>
{#if $audioOptions.normalize}
<div class="pl-6 flex items-center gap-3">
<span class="text-xs text-text-muted w-10 text-right">
{$audioOptions.normalizeTargetDb} dB
</span>
<input
type="range"
min="-10"
max="0"
step="0.1"
bind:value={$audioOptions.normalizeTargetDb}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
{/if}
</div>
<div class="text-xs text-text-muted bg-slate-100 p-3 rounded mt-4">
Hinweis: Die Konvertierung zu 16kHz Mono PCM erfolgt immer automatisch beim Speichern.
</div>
</div>
</div>
{/if}
</div>
</section>
<style>
@reference "../styles/app.css";
.btn {
@apply rounded-full transition-colors border-0 p-2;
&:not(:disabled) {
@apply hover:bg-slate-200 hover:shadow-sm;
}
}
/* Styling für die Range-Slider */
input[type="range"] {
@apply cursor-pointer;
}
input[type="range"]:disabled {
@apply cursor-not-allowed;
}
</style>

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,163 @@
<script lang="ts">
import FlashUsage from "./FlashUsage.svelte";
import { deviceInfo, fwInfo } from "../lib/store";
import { FW_STATUS } from "../lib/protocol/constants";
import { tooltip } from "../lib/actions/tooltip";
import {
CheckCircleIcon,
WarningIcon,
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">
{#if $deviceInfo}
{$deviceInfo.boardName}
{#if $deviceInfo.boardRevision}
<span class="text-muted">>Rev. {$deviceInfo.boardRevision}</span>
{/if}
{:else}
unbekannt
{/if}
</td>
</tr>
<tr>
<td class="key">Version</td>
<td class="value flex items-center gap-1">
{#if $fwInfo}
<span
use:tooltip={{
text: `Die Firmware-Slotgrösse beträgt <span class="font-medium">${$fwInfo.slot1Size / 1024} kB</span>. Wieso musst Du das wissen? Edi sorgt schon dafür, dass die Updates nicht zu gross sind.<br/> <div class="text-3xl align-center text-center pb-2">😈</div>`,
pos: "bottom",
}}
>
{$fwInfo.fwVersion}
</span>
{#if $fwInfo.fwStatus === FW_STATUS.CONFIRMED}
<span
use:tooltip={{
text: "Firmware ist bestätigt und damit dauerhaft nutzbar",
pos: "bottom",
}}
class="relative flex size-5"
>
<CheckCircleIcon
weight="fill"
class="text-emerald-500 relative inline-flex h-full w-full"
/>
</span>
{:else if $fwInfo.fwStatus === FW_STATUS.PENDING}
<span
use:tooltip={{
text: "Firmware ist nur hochgeladen und noch nicht bestätigt. Du kannst sie nach einem <b>reboot</b> testen. Wenn sie funktioniert, bestätige sie, damit sie dauerhaft nutzbar ist.<br/><b><i>Achtung:</i></b> der Reboot dauert einen Moment, da die Firmware erst umkopiert werden muss.",
pos: "bottom",
}}
class="relative flex size-5"
>
<WarningIcon
weight="fill"
class="text-amber-500 absolute inline-flex h-full w-full animate-ping"
/>
<WarningIcon
weight="fill"
class="text-amber-500 relative inline-flex h-full w-full"
/>
</span>
{:else if $fwInfo.fwStatus === FW_STATUS.TESTING}
<span
use:tooltip={{
text: "Die Firmware ist im Testmodus. Wenn sie gut funktioniert, dann <b>bestätige</b> sie, damit sie dauerhaft nutzbar ist. Wenn sie nicht richtig funktioniert, dann kannst Du den Buzzer <b>rebooten</b>, er kehrt dann zur vorhergehenden Version zurück.<br/><b><i>Achtung:</i></b> der Reboot dauert einen Moment, da die Firmware erst umkopiert werden muss.",
pos: "bottom",
}}
class="relative flex size-5"
>
<WarningIcon
weight="fill"
class="text-amber-600 absolute inline-flex h-full w-full animate-ping"
/>
<WarningIcon
weight="fill"
class="text-amber-600 relative inline-flex h-full w-full"
/>
</span>
{:else if $fwInfo.fwStatus === FW_STATUS.UNKNOWN}
<span
use:tooltip={{
text: `Die Firmware hat den unbekannten Status <span class="bold font-mono">0x${$fwInfo.fwStatus.toString(16).padStart(2, "0").toUpperCase()}.</span> Weiss der Teufel, was da wieder passiert ist... Vielleicht hilft ein Reboot? Oder Firmware neu flashen? Oder... hast Du ne Idee???`,
pos: "bottom",
}}
class="relative flex size-5"
>
<WarningIcon
weight="fill"
class="text-red-500 absolute inline-flex h-full w-full animate-ping"
/>
<WarningIcon
weight="fill"
class="text-red-500 relative inline-flex h-full w-full"
/>
</span>
{/if}
<span class="text-text-muted ml-1">(Zephyr {$fwInfo.kernelVersion})</span>
{:else}
unbekannt
{/if}
</td>
</tr>
<tr>
<td class="key">HW-ID</td>
<td class="value">
{#if $deviceInfo}
<span class="font-mono">{$deviceInfo.deviceId}</span>
{:else}
unbekannt
{/if}
</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;
}
</style>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import FileListItem from "./FileListItem.svelte";
import { localAudioFiles, buzzerAudioFiles } from "../lib/store";
import { slide } from "svelte/transition";
import { flip } from "svelte/animate";
export let type: "local" | "buzzer" = "buzzer";
// Reaktive Zuweisung: Wechselt automatisch, falls sich 'type' zur Laufzeit ändert
$: activeStore = type === "local" ? localAudioFiles : buzzerAudioFiles;
</script>
<div class="flex flex-col w-full">
{#each $activeStore as file (file.name)}
<div transition:slide|local={{ duration: 500 }} animate:flip={{ duration: 500 }}>
<FileListItem {file} {type} />
</div>
{/each}
{#if $activeStore.length === 0}
<div class="p-4 text-center text-slate-400 text-sm italic tracking-widest uppercase">
Keine Dateien vorhanden.
</div>
{/if}
</div>

View File

@@ -0,0 +1,346 @@
<script lang="ts">
import { type BuzzerFile, SyncState } from "../lib/types";
import {
MusicNotesIcon,
DotsThreeVerticalIcon,
PlayIcon,
TrashIcon,
PencilIcon,
CircleIcon,
QuestionIcon,
TagIcon,
UserIcon,
CheckCircleIcon,
WarningIcon,
WarningCircleIcon,
CloudArrowDownIcon,
} from "phosphor-svelte";
import {
isTransferingRemote,
transferStats,
transferDetails,
buzzerAudioFiles,
localAudioFiles,
syncStateMap,
fsInfo,
} from "../lib/store";
import { SETTINGS } from "../lib/settings";
import { tagEditorState } from "../lib/store";
import { tooltip } from "../lib/actions/tooltip";
import { deleteRemoteFile } from "../lib/transport";
import { deleteLocalFile, playLocalFile, downloadLocalFile } from "../lib/db";
import { refreshRemote, refreshLocal } from "../lib/sync";
import { addToast } from "../lib/toast";
export let file: BuzzerFile;
export let type: "local" | "buzzer" = "buzzer";
let menuOpen = false;
$: activeStore = type === "local" ? localAudioFiles : buzzerAudioFiles;
$: selectedFiles = $activeStore.filter((f) => f.selected);
$: currentIndex = selectedFiles.findIndex((f) => f.name === $transferStats.currentFileName);
$: myIndex = selectedFiles.findIndex((f) => f.name === file.name);
$: state = (() => {
if (!file.selected || !$isTransferingRemote) return "default";
if (file.name === $transferStats.currentFileName) return "active";
if (myIndex < currentIndex) return "done";
if (myIndex > currentIndex) return "pending";
return "default";
})();
$: syncStatus = $syncStateMap[type][file.name] || { state: SyncState.UNKNOWN, linkedFiles: [] };
$: statusConfig = (() => {
switch (syncStatus.state) {
case SyncState.UNKNOWN:
return {
icon: QuestionIcon,
weight: "fill",
color: "text-amber-500",
variant: "warning",
text: "Prüfsumme fehlt. Bitte Metadaten aktualisieren.",
};
case SyncState.SINGLE_SIDED:
return {
icon: CircleIcon,
weight: "bold",
color: "text-emerald-600",
variant: "info",
text: `Datei existiert nur ${type === "buzzer" ? "auf dem Buzzer" : "lokal"}.`,
};
case SyncState.SYNCED:
return {
icon: CheckCircleIcon,
weight: "fill",
color: "text-emerald-500",
variant: "info",
text: "Datei ist synchronisiert.",
};
case SyncState.CONFLICT:
return {
icon: WarningIcon,
ping: true,
weight: "fill",
color: "text-amber-600",
variant: "warning",
text: `Konflikt: Name/Tags weichen ab. Vergleiche mit <span class="text-medium text-on-surface bg-white rounded px-1">${syncStatus.linkedFiles[0]}</span> ${type === "buzzer" ? "lokal" : "auf dem Buzzer"}`,
};
case SyncState.DUPLICATE:
const duplicateText =
syncStatus.linkedFiles.length === 0
? `Diese Datei ist hier ok, aber ${type === "buzzer" ? "lokal" : "auf dem Buzzer"} existieren mehrere identische Versionen.`
: `Mehrfach vorhanden: Gleicher Inhalt auch in <span class="text-medium text-on-surface bg-white rounded px-1">${syncStatus.linkedFiles.join("</span> <span class='text-medium text-on-surface bg-white rounded px-1'>")}</span>`;
return {
icon: WarningCircleIcon,
ping: true,
weight: "fill",
color: "text-red-600",
variant: "danger",
text: duplicateText,
};
default:
return null;
}
})();
function toggleSelection() {
if ($isTransferingRemote) return;
if (type === "buzzer") {
buzzerAudioFiles.update((files) =>
files.map((entry) =>
entry.name === file.name ? { ...entry, selected: !entry.selected } : entry,
),
);
return;
}
localAudioFiles.update((files) =>
files.map((entry) =>
entry.name === file.name ? { ...entry, selected: !entry.selected } : entry,
),
);
}
async function handleDeleteClick() {
if (!confirm(`Möchten Sie die Datei "${file.name}" wirklich löschen?`)) {
menuOpen = false;
return;
}
if (type === "buzzer") {
try {
const basePath = $fsInfo?.audioPath || "/lfs/a";
const fullPath = `${basePath}/${file.name}`;
await deleteRemoteFile(fullPath);
addToast(`Datei ${file.name} erfolgreich vom Buzzer gelöscht.`, "success");
await refreshRemote();
} catch (error) {
console.error("Fehler beim Löschen:", error);
addToast("Fehler beim Löschen der Datei auf dem Buzzer.", "error");
}
} else {
try {
await deleteLocalFile(file.name);
addToast(`Lokale Datei ${file.name} gelöscht.`, "success");
await refreshLocal();
} catch (error) {
console.error("Fehler beim Löschen:", error);
}
}
menuOpen = false;
}
</script>
<svelte:window on:click={() => (menuOpen = false)} />
<div class="relative overflow-hidden group/item">
{#if state === "active"}
<div
class="absolute top-0 left-0 h-full bg-indigo-100 z-0"
style="width: {$transferDetails.filePercent}%; transition: {$transferDetails.filePercent === 0
? 'none'
: `width ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
></div>
{/if}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<button
class="relative z-10 w-full text-left flex-1 px-3 py-1 pr-16 flex items-center border-l-4 transition-colors border-b border-b-border-card
{file.selected ? 'border-l-blue-600' : 'border-l-transparent'}
{file.selected && state !== 'active' ? 'bg-blue-50' : ''}
{!$isTransferingRemote && file.selected ? 'hover:bg-blue-100 cursor-pointer' : ''}
{!$isTransferingRemote && !file.selected
? 'hover:bg-slate-100 hover:border-l-blue-200 cursor-pointer'
: ''}
{$isTransferingRemote ? 'cursor-default' : ''}
{state === 'pending' ? 'grayscale opacity-80' : ''}"
on:click={toggleSelection}
on:mouseenter|stopPropagation={() => (menuOpen = true)}
on:mouseleave|stopPropagation={() => (menuOpen = false)}
on:blur|stopPropagation={() => (menuOpen = false)}
on:focus|stopPropagation={() => (menuOpen = true)}
disabled={$isTransferingRemote}
>
<MusicNotesIcon weight="fill" class="mr-2 w-5 h-5 shrink-0" />
<div class="flex flex-col flex-1 min-w-0 overflow-hidden pl-1">
<div class="flex items-center min-w-0">
<span class="font-light truncate min-w-0 text-sm">
{file.name || "Unbekannte Datei"}
{#if file.metaTags?.t}
&thinsp;-&thinsp;
<span class="font-normal">{file.metaTags.t}</span>
{/if}
</span>
</div>
<div class="flex items-center gap-0 text-xs text-text-muted mt-0.5 min-w-0">
{#if statusConfig}
<span
use:tooltip={{
text: statusConfig.text,
pos: "right",
variant: statusConfig.variant,
}}
>
{#if statusConfig.ping}
<span class="mr-1 relative inline-flex size-3.5">
<svelte:component
this={statusConfig.icon}
weight={statusConfig.weight ? statusConfig.weight : "fill"}
class="opacity-57 text-amber-600 absolute inline-flex h-full w-full animate-ping"
/>
<svelte:component
this={statusConfig.icon}
weight={statusConfig.weight ? statusConfig.weight : "fill"}
class="shrink-0 w-3.5 h-3.5 {statusConfig.color}"
/>
</span>
{:else}
<svelte:component
this={statusConfig.icon}
weight={statusConfig.weight ? statusConfig.weight : "fill"}
class="mr-1 shrink-0 w-3.5 h-3.5 {statusConfig.color}"
/>
{/if}
</span>
{/if}
<span class="font-light shrink-0">
{parseFloat((file.size / 1024).toFixed(1))}&thinsp;kB
</span>
{#if file.metaTags?.a}
<UserIcon
weight="fill"
class="ml-1 pl-1 mr-0.5 shrink-0 text-slate-500 w-4.5 h-3.5 border-l border-l-text-muted"
/>
<span class="truncate min-w-0">{file.metaTags.a}</span>
{/if}
{#if file.metaTags?.c}
<TagIcon
weight="fill"
class="ml-1 pl-1 mr-0.5 shrink-0 text-slate-500 w-4.5 h-3.5 border-l border-l-text-muted"
/>
<span class="truncate min-w-0">{file.metaTags.c}</span>
{/if}
</div>
</div>
</button>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<div
class="menu-btn-grp group/menu"
class:is-open={menuOpen}
on:mouseout|stopPropagation={() => (menuOpen = false)}
>
<div
class="flex items-center overflow-hidden transition-all duration-300 ease-in-out
{menuOpen
? 'max-w-[120px] opacity-100'
: 'max-w-0 opacity-0 group-hover/menu:max-w-[120px] group-hover/menu:opacity-100'}"
>
<button class="menu-btn danger" title="Löschen" on:click|stopPropagation={handleDeleteClick}>
<TrashIcon class="list-menu-icon" />
</button>
<button
class="menu-btn"
title="Abspielen"
on:click|stopPropagation={() => {
downloadLocalFile(file.name);
menuOpen = false;
}}
>
<CloudArrowDownIcon class="list-menu-icon" />
</button>
<button
class="menu-btn"
title="Abspielen"
on:click|stopPropagation={() => {
if (type === "buzzer") {
addToast;
} else {
playLocalFile(file.name);
menuOpen = false;
}
}}
>
<PlayIcon class="list-menu-icon" />
</button>
<button
class="menu-btn"
title="Datei-Info"
on:click|stopPropagation={() => {
tagEditorState.set({ show: true, type, fileName: file.name });
menuOpen = false;
}}
>
<PencilIcon class="list-menu-icon" />
</button>
</div>
<button
class="menu-btn !border-r-transparent"
on:click|stopPropagation={() => (menuOpen = !menuOpen)}
>
<DotsThreeVerticalIcon class="list-menu-icon" />
</button>
</div>
</div>
<style>
@reference "../styles/app.css";
.menu-btn-grp {
@apply absolute right-2 top-1/2 -translate-y-1/2 z-20 overflow-hidden
p-0 flex items-center backdrop-blur-sm
rounded-full transition-all;
}
/* Kombiniert Hover von Mausnutzern und aktiven Touch-Zustand */
.menu-btn-grp:hover,
.menu-btn-grp.is-open {
@apply bg-white shadow-sm;
}
.menu-btn {
@apply border-r border-r-border-card
flex items-center justify-center shrink-0 transition-colors;
&:not(:disabled):not(.danger) {
@apply hover:bg-surface-hover;
}
&.danger:not(:disabled) {
@apply text-red-700 hover:bg-red-100;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed grayscale;
}
}
</style>

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { localAudioFiles, buzzerAudioFiles } from "../lib/store";
import { isConnected, fsInfo, tagEditorState } from "../lib/store";
import { deleteSelectedLocalFiles } from "../lib/sync";
import { addToast } from "../lib/toast";
import { tooltip } from "../lib/actions/tooltip";
import { updateLocalAudioCrc } from "../lib/tagHandler";
import {
refreshLocal,
refreshRemote,
downloadSelectedFiles,
uploadSelectedFiles,
} from "../lib/sync";
import {
GearIcon,
CloudArrowUpIcon,
ArrowClockwiseIcon,
DotsThreeVerticalIcon,
CheckSquareOffsetIcon,
SquareIcon,
DownloadIcon,
TrashIcon,
FingerprintIcon,
UploadIcon,
} from "phosphor-svelte";
export let type: "local" | "buzzer" = "buzzer";
export let onToggleMenu: () => void; // Callback-Prop definieren
let showMenu = false;
$: activeStore = type === "local" ? localAudioFiles : buzzerAudioFiles;
$: fileCount = $activeStore.length;
$: totalSize = $activeStore.reduce((sum, f) => sum + f.size, 0);
$: selectedFileCount = $activeStore.filter((f) => f.selected).length;
$: selectedFileSize = $activeStore.filter((f) => f.selected).reduce((sum, f) => sum + f.size, 0);
</script>
<div class="btn-connect-group {$isConnected || type === 'local' ? 'active group' : ''}">
{#if type === "buzzer"}
<button
class="btn-main"
aria-label="Alle ausgewählten Dateien herunterladen"
on:click={() => {
downloadSelectedFiles();
}}
disabled={!$isConnected || selectedFileCount === 0}
title="Alle ausgewählten Dateien herunterladen"
>
<DownloadIcon class="list-menu-icon" />
</button>
{:else}
<button
class="btn-main"
aria-label="Alle ausgewählten Dateien herunterladen"
on:click={() => {
uploadSelectedFiles();
}}
disabled={!$isConnected || selectedFileCount === 0}
title="Alle ausgewählten Dateien hochladen"
>
<UploadIcon class="list-menu-icon" />
</button>
{/if}
<button
class="btn-main"
aria-label="Alles Auswählen"
on:click={() => {
activeStore.update((files) => files.map((f) => ({ ...f, selected: true })));
}}
disabled={(!$isConnected && type === "buzzer") || selectedFileCount === fileCount}
title="Alle auswählen"
>
<CheckSquareOffsetIcon class="list-menu-icon" />
</button>
<button
class="btn-main"
on:click={() => {
activeStore.update((files) => files.map((f) => ({ ...f, selected: false })));
}}
aria-label="Auswahl löschen"
disabled={(!$isConnected && type === "buzzer") || selectedFileCount === 0}
title="Auswahl aufheben"
>
<SquareIcon class="list-menu-icon" />
</button>
<button
class="btn-main"
on:click={() => {
if (type === "buzzer") {
refreshRemote();
} else {
refreshLocal();
}
}}
aria-label="Reload"
disabled={!$isConnected && type === "buzzer"}
title="Dateiliste neu laden"
>
<ArrowClockwiseIcon class="list-menu-icon" />
</button>
<button
class="btn-main"
aria-label="Menu"
disabled={!$isConnected && type === "buzzer"}
title="Menu"
on:click|stopPropagation={onToggleMenu}
>
<DotsThreeVerticalIcon class="list-menu-icon" />
</button>
</div>
<style>
@reference "../styles/app.css";
.btn-connect-group {
@apply flex items-stretch rounded-full overflow-hidden transition-all;
}
.btn-connect-group.active {
@apply hover:border-border-card hover:shadow-sm hover:bg-white;
}
.btn-main {
@apply border-r-1 border-transparent last:border-r-0 focus:outline-none;
&:not(:disabled) {
@apply 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 border-r-transparent;
}
}
</style>

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import { fade, slide } from "svelte/transition";
import { localAudioFiles, buzzerAudioFiles } from "../lib/store";
import { deleteSelectedLocalFiles, deleteSelectedRemoteFiles } from "../lib/sync";
import { addToast } from "../lib/toast";
import { tooltip } from "../lib/actions/tooltip";
import { updateLocalAudioCrc } from "../lib/tagHandler";
import { refreshLocal } from "../lib/sync";
import {
CheckSquareOffsetIcon,
SquareIcon,
TrashIcon,
FingerprintIcon,
} from "phosphor-svelte";
export let show = false;
export let type: "local" | "buzzer" = "buzzer";
export let onClose: () => void;
// Interne reaktive Logik statt Props [cite: 15, 16]
$: activeStore = type === "local" ? localAudioFiles : buzzerAudioFiles;
$: fileCount = $activeStore.length;
$: totalSize = $activeStore.reduce((sum, f) => sum + f.size, 0);
$: selectedFileCount = $activeStore.filter((f) => f.selected).length;
$: selectedFileSize = $activeStore.filter((f) => f.selected).reduce((sum, f) => sum + f.size, 0);
function closeMenu() {
if (onClose) onClose();
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}\u202FB`;
else if (bytes < 10 * 1024) return `${(bytes / 1024).toFixed(2)}\u202F\KiB`;
else if (bytes < 100 * 1024) return `${(bytes / 1024).toFixed(1)}\u202F\KiB`;
else if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}\u202F\KiB`;
else return `${(bytes / (1024 * 1024)).toFixed(2)}\u202F\MiB`;
}
</script>
<svelte:window
on:click={() => {
if (show) closeMenu();
}}
on:keydown={(e) => {
if (show && e.key === "Escape") closeMenu();
}}
/>
{#if show}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full h-full z-20 bg-white flex flex-col items-center justify-start rounded-b-lg shadow-sm"
transition:fade={{ duration: 150 }}
on:click|stopPropagation={closeMenu}
>
<div class="w-full flex flex-col" on:click|stopPropagation>
<div
class="list_entry text-center bg-gradient-to-b from-white to-slate-100"
class:text-text-muted={fileCount === 0}
>
{#if fileCount === 0}
Keine Dateien vorhanden.
{:else if selectedFileCount === 0}
keine Datei ausgewählt
{:else if selectedFileCount === fileCount}
Alle {fileCount} Dateien ausgewählt ({formatSize(selectedFileSize)})
{:else}
{selectedFileCount} von {fileCount} Dateien ausgewählt ({formatSize(
selectedFileSize,
)}/{formatSize(totalSize)})
{/if}
</div>
<button
class="menu-btn"
on:click={() => {
$activeStore.forEach((f) => (f.selected = true));
$activeStore = $activeStore;
}}
disabled={fileCount === 0 || selectedFileCount === fileCount}
>
<CheckSquareOffsetIcon class="btn-icon" />
Alle Dateien auswählen
</button>
<button
class="menu-btn"
on:click={() => {
$activeStore.forEach((f) => (f.selected = false));
$activeStore = $activeStore;
}}
disabled={fileCount === 0 || selectedFileCount === 0}
>
<SquareIcon class="btn-icon" />
Alle Dateien abwählen
</button>
{#if selectedFileCount > 0}
<div
class="list_entry border-t mt-2 text-medium bg-gradient-to-b from-white to-slate-100 flex items-justify items-center"
class:text-text-muted={selectedFileCount === 0}
transition:slide={{ duration: 150 }}
>
<div>Augesählte Dateien:</div>
<div
class="ml-auto text-2xs text-white bg-gradient-to-b from-blue-400 to-blue-600 rounded-full px-2 py-0.5"
class:hidden={selectedFileCount === 0}
>
{#if selectedFileCount > 0}
{selectedFileCount}
{/if}
</div>
</div>
<button
class="menu-btn"
disabled={selectedFileCount === 0}
on:click={async () => {
if (type === "buzzer") {
addToast("CRC32 Update auf Buzzer wird derzeit nicht unterstützt.", "error");
} else {
const selectedFiles = $activeStore.filter((f) => f.selected);
for (const file of selectedFiles) {
await updateLocalAudioCrc(file.name);
}
await refreshLocal();
addToast(`CRC32-Prüfsumme${selectedFiles.length > 1 ? "n" : ""} von ${selectedFiles.length > 1 ? selectedFiles.length + " lokalen Dateien": "einer lokalen Datei"} aktualisiert.`, "success");
}
closeMenu();
}}
use:tooltip={{
text: "Berechnet die CRC32 des Audioanteils einer Datei und speichert sie in den Tags.",
position: "right",
}}
>
<FingerprintIcon class="btn-icon" />
CRC32 neu berechnen und speichern
</button>
<button
class="menu-btn danger"
disabled={selectedFileCount === 0}
on:click={() => {
if (type === "buzzer") {
deleteSelectedRemoteFiles();
} else {
deleteSelectedLocalFiles();
}
closeMenu();
}}
>
<TrashIcon class="btn-icon" />
Löschen
</button>
{/if}
</div>
</div>
{/if}
<style>
@reference "../styles/app.css";
.list_entry {
@apply py-2 px-4 border-border-card border-b-1 text-sm;
}
.menu-btn {
@apply w-full px-4 border-b border-b-border-card text-left text-sm flex items-center min-h-10;
&:not(:disabled):not(.danger) {
@apply hover:bg-surface-hover;
}
/* Spezifisches Styling für rote Aktions-Buttons (z.B. Löschen) */
&.danger:not(:disabled) {
@apply text-red-700 hover:bg-red-100;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed grayscale;
}
}
:global(.btn-icon) {
@apply w-5 h-5 mr-2;
}
</style>

View File

@@ -0,0 +1,481 @@
<script lang="ts">
import { updateFile } from "../lib/tagHandler";
import { refreshLocal, refreshRemote } from "../lib/sync";
import { fade, slide } from "svelte/transition";
import {
localAudioFiles,
buzzerAudioFiles,
fsInfo,
syncStateMap,
} from "../lib/store";
import { type MetadataTags, SyncState } from "../lib/types";
import {
XIcon,
CaretLeftIcon,
CaretRightIcon,
FloppyDiskIcon,
ArrowUUpLeftIcon,
CheckSquareIcon,
SquareIcon,
PencilIcon,
} from "phosphor-svelte";
import { addToast } from "../lib/toast";
import { tooltip } from "../lib/actions/tooltip";
export let show = false;
export let type: "local" | "buzzer" = "buzzer";
export let initialFileName: string | null = null;
export let onClose: () => void;
let drafts: Record<string, { newName: string; tags: MetadataTags }> = {};
let currentFileName = "";
let applyToBoth = false;
let isDropdownOpen = false;
let lastOpenedName: string | null = null;
$: autoApplyIcon = applyToBoth ? CheckSquareIcon : SquareIcon;
$: activeStore = type === "local" ? localAudioFiles : buzzerAudioFiles;
$: fileList = $activeStore || [];
$: if (show && initialFileName !== lastOpenedName) {
if (initialFileName) {
currentFileName = initialFileName;
} else if (fileList.length > 0) {
currentFileName = fileList[0].name;
}
lastOpenedName = initialFileName;
}
$: if (!show) {
lastOpenedName = null;
}
$: currentIndex = (fileList || []).findIndex((f) => f.name === currentFileName);
$: currentFile = fileList[currentIndex];
$: hasDraft = currentFile ? drafts[currentFile.name] !== undefined : false;
$: hasAnyDrafts = Object.keys(drafts).length > 0;
$: activeDraft = currentFile ? drafts[currentFile.name] : null;
$: activeTags = activeDraft ? activeDraft.tags : currentFile?.metaTags || {};
$: activeName = activeDraft ? activeDraft.newName : currentFile?.name || "";
$: syncStatus = (currentFileName && $syncStateMap[type]?.[currentFileName]) || { state: SyncState.UNKNOWN, linkedFiles: [] };
$: isDuplicate = syncStatus.state === SyncState.DUPLICATE;
$: if (isDuplicate) {
applyToBoth = false;
}
$: maxFilenameLength = $fsInfo ? $fsInfo.maxPathLength - $fsInfo.audioPath.length - 2 : 30;
function closeEditor() {
if (onClose) onClose();
}
function nextFile() {
if (currentIndex < fileList.length - 1) {
currentFileName = fileList[currentIndex + 1].name;
isDropdownOpen = false;
}
}
function prevFile() {
if (currentIndex > 0) {
currentFileName = fileList[currentIndex - 1].name;
isDropdownOpen = false;
}
}
function initDraftIfNeeded() {
if (!currentFile) return;
if (!drafts[currentFile.name]) {
drafts[currentFile.name] = {
newName: currentFile.name,
tags: { ...(currentFile.metaTags || {}) },
};
}
}
function updateTag(field: keyof MetadataTags, event: Event) {
initDraftIfNeeded();
if (currentFile) {
drafts[currentFile.name].tags[field] = (event.target as HTMLInputElement).value;
drafts = drafts;
}
}
function updateName(event: Event) {
initDraftIfNeeded();
if (currentFile) {
const inputEl = event.target as HTMLInputElement;
let val = inputEl.value.replace(/[^a-zA-Z0-9_\-\.]/g, "");
if (val.length > maxFilenameLength) val = val.substring(0, maxFilenameLength);
inputEl.value = val;
drafts[currentFile.name].newName = val;
drafts = drafts;
}
}
function resetCurrent() {
if (!currentFile) return;
delete drafts[currentFile.name];
drafts = drafts;
}
function resetAll() {
drafts = {};
}
async function saveCurrent() {
if (!currentFile || !drafts[currentFile.name]) return;
const draft = drafts[currentFile.name];
const oldName = currentFile.name;
const newName = draft.newName;
try {
await updateFile(oldName, newName, currentFile.sysTags, draft.tags, type, applyToBoth);
addToast(`Datei ${newName} gespeichert.`, "success");
delete drafts[oldName];
drafts = drafts;
if (oldName !== newName) currentFileName = newName;
if (applyToBoth) {
// Wenn auf beide angewendet, beide Seiten neu laden
await refreshLocal();
await refreshRemote();
} else {
// Ansonsten nur die aktive Seite
if (type === "local") await refreshLocal();
if (type === "buzzer") await refreshRemote();
}
} catch (error) {
addToast("Fehler beim Speichern.", "error");
}
}
async function saveAll() {
try {
let savedCount = 0;
for (const [oldName, draft] of Object.entries(drafts)) {
const file = fileList.find((f) => f.name === oldName);
if (file) {
await updateFile(oldName, draft.newName, file.sysTags, draft.tags, type, applyToBoth);
savedCount++;
}
}
drafts = {};
addToast(`${savedCount} Dateien gespeichert.`, "success");
if (applyToBoth) {
await refreshLocal();
await refreshRemote();
} else {
if (type === "local") await refreshLocal();
if (type === "buzzer") await refreshRemote();
}
} catch (error) {
addToast("Fehler beim Speichern.", "error");
}
}
function formatSize(bytes: number) {
return (bytes / 1024).toFixed(1) + "\u202FkB";
}
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);
},
};
}
function getDropdownItemClass(fileName: string, current: string, currentDrafts: any): string {
const isChanged = !!currentDrafts[fileName];
const isSelected = fileName === current;
if (isChanged)
return isSelected
? "bg-amber-50 hover:bg-amber-100 font-semibold"
: "bg-amber-50 hover:bg-amber-100";
return isSelected ? "font-semibold hover:bg-slate-100" : "bg-white hover:bg-slate-100";
}
function isTagChanged(
field: keyof MetadataTags,
currentDraftTags: MetadataTags,
originalFile: any,
draftExists: boolean,
): boolean {
if (!draftExists || !originalFile) return false;
const orig = originalFile.metaTags?.[field];
const draft = currentDraftTags[field];
const origStr = Array.isArray(orig) ? orig.join(", ") : orig || "";
const draftStr = Array.isArray(draft) ? draft.join(", ") : draft || "";
return origStr !== draftStr;
}
function getInputClass(isChanged: boolean): string {
return isChanged
? "border-amber-400 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 bg-amber-50/50 text-amber-900"
: "border-border-card focus:border-on-surface focus:ring-1 focus:ring-on-surface";
}
function handleKeydown(event: KeyboardEvent) {
if (show && event.key === "Escape" && !isDropdownOpen) {
closeEditor();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if show && currentFile}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full h-full bg-white flex flex-col rounded-b-lg shadow-sm overflow-hidden"
transition:fade={{ duration: 150 }}
on:click|stopPropagation
>
<div
class="flex items-center justify-between p-2 border-b border-border-card bg-gradient-to-b from-white {hasDraft
? 'to-amber-100'
: 'to-slate-100'}"
>
<button class="nav-btn" on:click={prevFile} disabled={currentIndex === 0}>
<CaretLeftIcon class="w-5 h-5" />
</button>
<div
class="relative flex-1 px-2"
use:clickOutside
on:outclick={() => (isDropdownOpen = false)}
>
<button
class="w-full flex items-center justify-center gap-1.5 text-sm font-semibold cursor-pointer outline-none"
on:click={() => (isDropdownOpen = !isDropdownOpen)}
>
{#if hasDraft}<PencilIcon class="w-4 h-4 shrink-0 text-amber-700" />{/if}
<span class="truncate {hasDraft ? 'text-amber-800' : 'text-slate-800'}">
{activeName}
</span>
</button>
{#if isDropdownOpen}
<div
transition:slide={{ duration: 200 }}
class="absolute left-0 right-0 top-full mt-2 bg-white shadow-lg rounded-md border border-border-card max-h-64 overflow-y-auto z-50"
>
{#each fileList as file}
<button
class="w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 border-b border-border-card last:border-0 {getDropdownItemClass(
file.name,
currentFileName,
drafts,
)}"
on:click={() => {
currentFileName = file.name;
isDropdownOpen = false;
}}
>
{#if drafts[file.name]}<PencilIcon class="w-4 h-4 shrink-0 text-amber-600" />
<span class="truncate text-amber-800">{drafts[file.name].newName}</span>
{:else}<div class="w-4 h-4 shrink-0"></div>
<span class="truncate text-slate-700">{file.name}</span>{/if}
</button>
{/each}
</div>
{/if}
</div>
<button class="nav-btn" on:click={nextFile} disabled={currentIndex === fileList.length - 1}>
<CaretRightIcon class="w-5 h-5" />
</button>
<button
class="ml-2 p-1.5 hover:bg-slate-200 rounded-full transition-colors"
on:click={closeEditor}
>
<XIcon class="w-5 h-5" />
</button>
</div>
<div class="flex-1 overflow-y-auto flex flex-col">
<div
class="px-4 py-3 bg-slate-50 border-b border-border-card flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500 font-mono"
>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-slate-400">Grösse</span>
<span>{formatSize(currentFile.size)}</span>
</div>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-slate-400">CRC32</span>
<span>
{currentFile.sysTags?.crc32
? `0x${currentFile.sysTags.crc32.toString(16).toUpperCase()}`
: "N/A"}
</span>
</div>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-slate-400">Audio Codec</span>
<span>
{currentFile.sysTags?.format?.codec || "PCM"} / {currentFile.sysTags?.format
?.sampleRate || "16000"}Hz
</span>
</div>
</div>
<div class="p-4 flex flex-col gap-3 {hasDraft ? 'bg-amber-50/50' : ''}">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-slate-600">Dateiname</span>
<input
type="text"
value={activeName}
on:input={updateName}
maxlength={maxFilenameLength}
class="editor-input {getInputClass(
hasDraft && activeName !== currentFile.name,
)}"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-slate-600">Titel</span>
<input
type="text"
value={activeTags.t || ""}
on:input={(e) => updateTag("t", e)}
class="editor-input {getInputClass(
isTagChanged('t', activeTags, currentFile, hasDraft),
)}"
placeholder="Titel..."
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-slate-600">Autor</span>
<input
type="text"
value={activeTags.a || ""}
on:input={(e) => updateTag("a", e)}
class="editor-input {getInputClass(
isTagChanged('a', activeTags, currentFile, hasDraft),
)}"
placeholder="Autor..."
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-slate-600">Tags/Kategorien</span>
<input
type="text"
value={Array.isArray(activeTags.c) ? activeTags.c.join(", ") : activeTags.c || ""}
on:input={(e) => updateTag("c", e)}
class="editor-input {getInputClass(
isTagChanged('c', activeTags, currentFile, hasDraft),
)}"
placeholder="Tags..."
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-slate-600">Bemerkungen</span>
<textarea
value={activeTags.r || ""}
on:input={(e) => updateTag("r", e)}
class="editor-input resize-none h-20 {getInputClass(
isTagChanged('r', activeTags, currentFile, hasDraft),
)}"
placeholder="Infos..."
></textarea>
</label>
</div>
</div>
<div class="flex flex-col border-t border-border-card bg-white mt-auto">
{#if hasAnyDrafts}
<div class="flex bg-amber-50">
<button
class="menu-btn !border-b-0 flex-1 text-amber-700 hover:bg-amber-100"
on:click={resetAll}
>
<ArrowUUpLeftIcon class="btn-icon" /> Alle zurück
</button>
<button
class="menu-btn !border-b-0 flex-1 border-l border-amber-200 text-amber-700 hover:bg-amber-100 font-semibold"
on:click={saveAll}
>
<FloppyDiskIcon class="btn-icon" /> Alle speichern
</button>
</div>
<div class="border-t border-amber-200"></div>
{/if}
<div class="flex">
<button class="menu-btn flex-1" disabled={!hasDraft} on:click={resetCurrent}>
<ArrowUUpLeftIcon class="btn-icon" /> Zurück
</button>
<button
class="menu-btn flex-1 border-l border-border-card font-semibold {hasDraft
? 'text-blue-700'
: ''}"
disabled={!hasDraft}
on:click={saveCurrent}
>
<FloppyDiskIcon class="btn-icon" /> Speichern
</button>
</div>
<div
use:tooltip={{
text: "Diese Option ist deaktiviert, da ein Duplikat-Konflikt vorliegt. Lösen Sie den Konflikt, um Änderungen auf beiden Seiten anwenden zu können.",
pos: "top",
variant: "danger",
disabled: !isDuplicate,
}}
class:grayscale={isDuplicate}
class:cursor-not-allowed={isDuplicate}
>
<button
class="menu-btn bg-slate-50 hover:bg-slate-100 !justify-start text-slate-700 w-full"
on:click={() => (applyToBoth = !applyToBoth)}
disabled={isDuplicate}
>
<svelte:component
this={autoApplyIcon}
class="btn-icon {applyToBoth ? 'text-blue-600' : 'text-slate-400'}"
/> Auch {type === "buzzer" ? "lokal" : "auf dem Buzzer"} anwenden
</button>
</div>
</div>
</div>
{/if}
<style>
@reference "../styles/app.css";
.nav-btn {
@apply p-1.5 rounded-full transition-colors;
&:not(:disabled) {
@apply hover:bg-slate-200 cursor-pointer;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed grayscale;
}
}
.editor-input {
@apply w-full px-3 py-2 text-sm bg-white border rounded shadow-sm outline-none transition-colors;
}
.menu-btn {
@apply w-full px-4 border-b border-border-card text-left text-xs sm:text-sm flex justify-center items-center min-h-10 transition-colors;
&:not(:disabled) {
@apply hover:bg-surface-hover cursor-pointer;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed grayscale;
}
}
:global(.btn-icon) {
@apply w-4 h-4 mr-2 shrink-0;
}
</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,245 @@
<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-2xl 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)}
onConnect={(device) => {
connectBuzzer(device);
showDropdown = false;
}}
onForget={(device) => forgetDevice(device)}
/>
{/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 bg-gradient-to-b from-white to-slate-200 hover:from-slate-100 hover:to-slate-300;
}
.last-available,
.last-unavailable {
@apply border-transparent;
}
.last-available .btn-main,
.last-available .btn-dropdown {
@apply bg-gradient-to-b from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white;
}
.last-unavailable .btn-main,
.last-unavailable .btn-dropdown {
@apply bg-gradient-to-b from-indigo-400 to-indigo-600 hover:from-indigo-400 hover:to-indigo-700 text-white;
}
.btn-connect-group {
@apply flex items-stretch rounded-full overflow-hidden shadow-sm;
}
.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,68 @@
<!-- HeaderDeviceListItem.svelte -->
<script lang="ts">
import { BluetoothIcon, BluetoothSlashIcon, LinkBreakIcon } from "phosphor-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";
export let onConnect: (device: any) => void;
export let onForget: (device: any) => void;
let isHovered = false;
</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={() => onConnect(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)}
on:click|stopPropagation={() => onForget(device)}
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,170 @@
<script lang="ts">
import { isConnected, fsInfo, tagEditorState } from "../lib/store";
import { GearIcon, CloudArrowUpIcon, DotsThreeVerticalIcon } from "phosphor-svelte";
import FileList from "./FileList.svelte";
import DeviceInfo from "./DeviceInfo.svelte";
import { refreshRemote } from "../lib/sync";
import { transferStats, isTransferingRemote, pairedDevices, activeDeviceId } from "../lib/store";
import { SETTINGS } from "../lib/settings";
import TransferProgress from "./TransferProgress.svelte";
import FileMenuOverlay from "./FileMenuOverlay.svelte";
import FileTagEditor from "./FileTagEditor.svelte";
import FileListMenu from "./FileListMenu.svelte";
import AudioDropzone from "./AudioDropzone.svelte";
let showOverlay = false;
let isTransferFinished = false;
let overlayTimeout: ReturnType<typeof setTimeout>;
let showLocalMenu = false;
let showBuzzerMenu = false;
let editModeType: "local" | "buzzer" | null = null;
let fileToEdit: string | null = null;
let isFileDragOver = false;
$: currentDevice = $pairedDevices.find((d) => d.id === $activeDeviceId);
$: if ($isConnected) {
refreshRemote();
}
$: if ($isTransferingRemote && $transferStats.overallTotal > 0) {
// Transfer startet oder läuft
showOverlay = true;
isTransferFinished = false;
clearTimeout(overlayTimeout);
} else if (showOverlay && !$isTransferingRemote && $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
}
export function openTagEditor(type: "local" | "buzzer", fileName: string) {
editModeType = type;
fileToEdit = fileName;
}
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">
<AudioDropzone />
<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>
<TransferProgress />
</div>
</section>
<section class="buzzer-card relative self-start">
<div class="card-header">
<h3 class="card-title">Lokale Bibliothek</h3>
<FileListMenu type="local" onToggleMenu={() => (showLocalMenu = !showLocalMenu)} />
</div>
<div class="grid">
<div class="col-start-1 row-start-1">
<div class="card-body">
<FileList type="local" />
</div>
</div>
<div
class="col-start-1 row-start-1 z-30"
class:hidden={!$tagEditorState.show || $tagEditorState.type !== "local"}
>
<FileTagEditor
type="local"
show={$tagEditorState.show && $tagEditorState.type === "local"}
initialFileName={$tagEditorState.fileName}
onClose={() => tagEditorState.set({ show: false, type: "local", fileName: "" })}
/>
</div>
<div class="col-start-1 row-start-1 z-20" class:pointer-events-none={!showLocalMenu}>
<FileMenuOverlay
type="local"
show={showLocalMenu}
onClose={() => (showLocalMenu = false)}
/>
</div>
</div>
</section>
<section class="buzzer-card relative self-start">
<div class="card-header">
<h3 class="card-title">
Gerätebibliothek <span class="text-text-muted text-xs font-mono">
{$fsInfo?.audioPath}
</span>
</h3>
<FileListMenu type="buzzer" onToggleMenu={() => (showBuzzerMenu = !showBuzzerMenu)} />
</div>
<div class="grid">
<div class="col-start-1 row-start-1">
<div class="card-body" class:disconnected={!$isConnected}>
<FileList type="buzzer" />
</div>
</div>
<div
class="col-start-1 row-start-1 z-30"
class:hidden={!$tagEditorState.show || $tagEditorState.type !== "buzzer"}
>
<FileTagEditor
type="buzzer"
show={$tagEditorState.show && $tagEditorState.type === "buzzer"}
initialFileName={$tagEditorState.fileName}
onClose={() => tagEditorState.set({ show: false, type: "buzzer", fileName: "" })}
/>
</div>
<div class="col-start-1 row-start-1 z-20" class:pointer-events-none={!showBuzzerMenu}>
<FileMenuOverlay
type="buzzer"
show={showBuzzerMenu}
onClose={() => (showBuzzerMenu = false)}
/>
</div>
</div>
</section>
</div>
<style>
@reference "../styles/app.css";
.btn {
@apply rounded-full transition-colors border-0;
&:not(:disabled) {
@apply hover:bg-slate-200 hover:shadow-sm;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed;
}
}
</style>

View File

@@ -7,12 +7,12 @@
$: console.debug("Aktuelle Toasts im Store:", $toasts);
</script>
<div class="fixed bottom-20 right-6 z-[9999] flex flex-col gap-3 pointer-events-none">
<div class="fixed bottom-6 right-6 z-[9999] flex flex-col gap-3 pointer-events-none">
{#each $toasts as toast (toast.id)}
<div
in:fly={{ y: 20, duration: 300 }}
out:fly={{ y: -20, duration: 300 }}
class="pointer-events-auto flex items-center justify-between px-5 py-3 rounded-lg border-l-4 shadow-xl min-w-[280px]
class="pointer-events-auto flex items-center justify-between px-5 py-3 rounded-lg border-l-4 shadow-xl min-w-[280px] backdrop-blur-sm
{toast.type === 'success' ? 'bg-green-100/50 border-green-500 text-green-800' : ''}
{toast.type === 'info' ? 'bg-blue-100/50 border-blue-500 text-blue-800' : ''}
{toast.type === 'warning' ? 'bg-amber-100/50 border-amber-500 text-amber-800' : ''}

View File

@@ -0,0 +1,51 @@
<script lang="ts">
let {
text = "",
position = 'top',
variant = 'default'
} = $props();
let x = $state(-999);
let y = $state(-999);
let visible = $state(false);
export function setPosition(newX: number, newY: number) {
x = newX;
y = newY;
visible = true;
}
export function hideTooltip() {
visible = false;
}
// Svelte 5 Reaktivität für dynamische Klassen basierend auf dem Typ
let containerClass = $derived(
variant === 'warning' ? 'bg-amber-700' :
variant === 'danger' ? 'bg-red-700' :
'bg-slate-700' // default
);
let arrowClass = $derived(
variant === 'warning' ? 'bg-amber-700' :
variant === 'danger' ? 'bg-red-700' :
'bg-slate-700' // default
);
</script>
<div
class="fixed z-[9999] pointer-events-none transition-opacity duration-200 {visible ? 'opacity-100' : 'opacity-0'}"
style="left: {x}px; top: {y}px;"
>
<div class="relative text-xs text-white px-2 py-1 rounded shadow-xl max-w-xs {containerClass}">
{@html text}
<div class="absolute w-2 h-2 rotate-45 {arrowClass}
{position === 'top' ? 'bottom-[-4px] left-1/2 -translate-x-1/2' : ''}
{position === 'bottom' ? 'top-[-4px] left-1/2 -translate-x-1/2' : ''}
{position === 'left' ? 'right-[-4px] top-1/2 -translate-y-1/2' : ''}
{position === 'right' ? 'left-[-4px] top-1/2 -translate-y-1/2' : ''}"
></div>
</div>
</div>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { XIcon } from "phosphor-svelte";
import { isTransferingRemote, transferStats, transferDetails } from '../lib/store';
import { SETTINGS } from '../lib/settings';
let showOverlay = false;
let isTransferFinished = false;
let overlayTimeout: ReturnType<typeof setTimeout>;
$: if ($isTransferingRemote && $transferStats.overallTotal > 0) {
showOverlay = true;
isTransferFinished = false;
clearTimeout(overlayTimeout);
} else if (showOverlay && !$isTransferingRemote && $transferStats.overallDone > 0) {
isTransferFinished = true;
overlayTimeout = setTimeout(closeOverlay, SETTINGS.ui.transferOverlayPersistMs);
}
function closeOverlay() {
clearTimeout(overlayTimeout);
showOverlay = false;
isTransferFinished = false;
}
function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return "Berechne...";
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>
{#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}

View File

@@ -1,29 +1,30 @@
---
import "../styles/global.css";
const year = new Date().getFullYear();
import "../styles/app.css";
---
<!-- MainLayout.astro -->
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="icon"
type="image/png"
href={`${import.meta.env.BASE_URL}favicon-96x96.png`}
sizes="96x96"
/>
<link rel="icon" type="image/svg+xml" href={`${import.meta.env.BASE_URL}favicon.svg`} />
<link rel="shortcut icon" href={`${import.meta.env.BASE_URL}favicon.ico`} />
<link
rel="apple-touch-icon"
sizes="180x180"
href={`${import.meta.env.BASE_URL}apple-touch-icon.png`}
/>
<meta name="apple-mobile-web-app-title" content="Edis Buzzer" />
<link rel="manifest" href={`${import.meta.env.BASE_URL}site.webmanifest`} />
<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>

View File

@@ -0,0 +1,23 @@
export function clickOutside(node: HTMLElement, callback: () => void) {
const handleClick = (event: MouseEvent | TouchEvent) => {
// Prüfen, ob der Klick auf ein Element außerhalb des Containers erfolgte
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
callback();
}
};
// Event-Listener in der Capture-Phase hinzufügen (true)
document.addEventListener('click', handleClick, true);
document.addEventListener('touchstart', handleClick, { passive: true, capture: true });
return {
update(newCallback: () => void) {
callback = newCallback;
},
destroy() {
// Event-Listener beim Zerstören der Komponente entfernen
document.removeEventListener('click', handleClick, true);
document.removeEventListener('touchstart', handleClick, true);
}
};
}

View File

@@ -0,0 +1,99 @@
import Tooltip from "../../components/Tooltip.svelte";
import { mount, unmount } from "svelte";
// Typisierung um 'variant' erweitert
export interface TooltipOptions {
text: string;
pos?: "top" | "bottom" | "left" | "right";
variant?: "default" | "warning" | "danger";
}
export function tooltip(node: HTMLElement, options: TooltipOptions) {
let tooltipInstance: any = null;
let container: HTMLElement | null = null;
let isHovered = false;
const show = () => {
isHovered = true;
if (!options.text) return;
container = document.createElement('div');
document.body.appendChild(container);
tooltipInstance = mount(Tooltip, {
target: container,
props: {
text: options.text,
position: options.pos || "top",
variant: options.variant || "default" // Übergabe an die Komponente
}
});
requestAnimationFrame(() => {
if (!isHovered || !container || !tooltipInstance) return;
const tooltipEl = container.firstElementChild as HTMLElement;
if (!tooltipEl) return;
const nodeRect = node.getBoundingClientRect();
const tooltipRect = tooltipEl.getBoundingClientRect();
const gap = 8;
let x = 0, y = 0;
if (options.pos === "right") {
x = nodeRect.right + gap;
y = nodeRect.top + nodeRect.height / 2 - tooltipRect.height / 2;
} else if (options.pos === "left") {
x = nodeRect.left - tooltipRect.width - gap;
y = nodeRect.top + nodeRect.height / 2 - tooltipRect.height / 2;
} else if (options.pos === "bottom") {
x = nodeRect.left + nodeRect.width / 2 - tooltipRect.width / 2;
y = nodeRect.bottom + gap;
} else {
x = nodeRect.left + nodeRect.width / 2 - tooltipRect.width / 2;
y = nodeRect.top - tooltipRect.height - gap;
}
tooltipInstance.setPosition(x, y);
});
};
const hide = () => {
isHovered = false;
if (tooltipInstance) {
tooltipInstance.hideTooltip();
const instanceToRemove = tooltipInstance;
const containerToRemove = container;
tooltipInstance = null;
container = null;
setTimeout(() => {
try {
unmount(instanceToRemove);
if (containerToRemove) containerToRemove.remove();
} catch (e) {
console.warn("[Tooltip Action] Cleanup Fehler", e);
}
}, 200);
}
};
node.addEventListener("mouseenter", show);
node.addEventListener("mouseleave", hide);
node.addEventListener("mousedown", hide);
return {
update(newOptions: TooltipOptions) {
options = newOptions;
},
destroy() {
hide();
node.removeEventListener("mouseenter", show);
node.removeEventListener("mouseleave", hide);
node.removeEventListener("mousedown", hide);
}
};
}

View File

@@ -0,0 +1,131 @@
export interface AudioProcessingOptions {
lowCut: boolean;
lowCutFreq: number;
compress: boolean;
compressorThreshold: number;
compressorRatio: number;
compressorKnee: number;
compressorAttack: number;
compressorRelease: number;
normalize: boolean;
normalizeTargetDb: number;
}
export const DEFAULT_AUDIO_OPTIONS: AudioProcessingOptions = {
lowCut: true,
lowCutFreq: 150,
compress: true,
compressorThreshold: -45, // Drastisch gesenkt: Erfasst auch sehr leises Flüstern
compressorRatio: 8, // Starke Kompression (8:1)
compressorKnee: 10, // Härterer Übergang (Hard Knee), damit der Kompressor direkter zupackt
compressorAttack: 0.005, // Etwas langsamer (5ms), lässt die initialen Konsonanten für die Sprachverständlichkeit durch
compressorRelease: 0.15, // Schnelleres Release (150ms), zieht leise Passagen zwischen Wörtern schneller hoch
normalize: true,
normalizeTargetDb: -0.5,
};
/**
* Konvertiert Float32 Samples (-1.0 bis 1.0) in Int16 PCM (-32768 bis 32767)
*/
function floatToInt16(float32Data: Float32Array): Int16Array {
const int16Data = new Int16Array(float32Data.length);
for (let i = 0; i < float32Data.length; i++) {
// Hard-Clipping verhindern, falls Signale leicht übersteuern
const s = Math.max(-1, Math.min(1, float32Data[i]));
int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
return int16Data;
}
/**
* Führt eine Peak-Normalisierung basierend auf einem dBFS-Zielwert durch.
*/
function applyNormalization(buffer: AudioBuffer, targetDb: number) {
const data = buffer.getChannelData(0);
let maxPeak = 0;
// 1. Höchsten absoluten Peak finden
for (let i = 0; i < data.length; i++) {
const absValue = Math.abs(data[i]);
if (absValue > maxPeak) maxPeak = absValue;
}
// 2. Multiplikator berechnen und anwenden
if (maxPeak > 0) {
const targetLinear = Math.pow(10, targetDb / 20);
const factor = targetLinear / maxPeak;
for (let i = 0; i < data.length; i++) {
data[i] *= factor;
}
}
}
export async function processAudioFile(
file: File,
// Partial bedeutet: Es müssen nicht alle Felder übergeben werden
userOptions: Partial<AudioProcessingOptions> = {}
): Promise<{ buffer: ArrayBuffer; name: string }> {
// Fügt die Standardwerte mit den User-Werten zusammen.
// Was der User nicht mitschickt, wird durch Defaults aufgefüllt.
const options = { ...DEFAULT_AUDIO_OPTIONS, ...userOptions };
// Cast auf ArrayBuffer, um TypeScript bzgl. SharedArrayBuffer zu beruhigen
const rawArrayBuffer = await file.arrayBuffer() as ArrayBuffer;
const audioCtx = new window.AudioContext();
const sourceBuffer = await audioCtx.decodeAudioData(rawArrayBuffer);
// Ziel-Kontext: Exakt 16kHz, Mono (1 Kanal), Länge berechnet aus Originaldauer
const offlineCtx = new window.OfflineAudioContext(1, sourceBuffer.duration * 16000, 16000);
const source = offlineCtx.createBufferSource();
source.buffer = sourceBuffer;
let lastNode: AudioNode = source;
// 1. Low-Cut Filter (Highpass)
if (options.lowCut) {
const filter = offlineCtx.createBiquadFilter();
filter.type = "highpass";
// Jetzt ist options.lowCutFreq garantiert eine Zahl
filter.frequency.setValueAtTime(options.lowCutFreq, offlineCtx.currentTime);
lastNode.connect(filter);
lastNode = filter;
}
// 2. Dynamics Compressor
if (options.compress) {
const compressor = offlineCtx.createDynamicsCompressor();
// Hier knallt es nicht mehr, weil options.compressorThreshold existiert
compressor.threshold.setValueAtTime(options.compressorThreshold, offlineCtx.currentTime);
compressor.knee.setValueAtTime(options.compressorKnee, offlineCtx.currentTime);
compressor.ratio.setValueAtTime(options.compressorRatio, offlineCtx.currentTime);
compressor.attack.setValueAtTime(options.compressorAttack, offlineCtx.currentTime);
compressor.release.setValueAtTime(options.compressorRelease, offlineCtx.currentTime);
lastNode.connect(compressor);
lastNode = compressor;
}
console.log("AudioProcessor: Audioeffekte angewendet", { options });
// Kette abschließen und Rendering starten
lastNode.connect(offlineCtx.destination);
source.start(0);
const renderedBuffer = await offlineCtx.startRendering();
// 3. Normalisierung
if (options.normalize) {
applyNormalization(renderedBuffer, options.normalizeTargetDb);
}
// 4. In Zielformat (16-bit PCM) wandeln
const pcm16 = floatToInt16(renderedBuffer.getChannelData(0));
return {
buffer: pcm16.buffer as ArrayBuffer,
// Original-Dateiendung entfernen
name: file.name.replace(/\.[^/.]+$/, "")
};
}

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

147
webpage/src/lib/db.ts Normal file
View File

@@ -0,0 +1,147 @@
// src/lib/db.ts
const DB_NAME = 'BuzzerDB';
const STORE_NAME = 'localAudio';
function initDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
// Der Dateiname fungiert als eindeutiger Schlüssel
db.createObjectStore(STORE_NAME, { keyPath: 'name' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function saveLocalFile(name: string, blob: Blob, size: number): Promise<void> {
const db = await initDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
// Speichert das Blob zusammen mit Metadaten
store.put({ name, blob, size, timestamp: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getLocalFiles(): Promise<any[]> {
const db = await initDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function deleteLocalFile(name: string): Promise<void> {
const db = await initDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.delete(name);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
export async function getLocalFile(name: string): Promise<{ name: string, blob: Blob, size: number } | undefined> {
const db = await initDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(name);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
let currentSource: AudioBufferSourceNode | null = null;
export async function playLocalFile(name: string): Promise<void> {
// Falls bereits etwas spielt: Stoppen und aufräumen
if (currentSource) {
try {
currentSource.stop();
} catch (e) {
// Ignorieren, falls die Quelle bereits natürlich gestoppt war
}
currentSource = null;
}
const fileEntry = await getLocalFile(name);
if (!fileEntry) throw new Error('Datei nicht gefunden');
const fullBuffer = await fileEntry.blob.arrayBuffer();
let audioByteLength = fullBuffer.byteLength;
// Metadaten-Limit berechnen (Footer an EOF - 8)
if (fullBuffer.byteLength >= 8) {
const view = new DataView(fullBuffer);
const footerOffset = fullBuffer.byteLength - 8;
const magic = new TextDecoder().decode(new Uint8Array(fullBuffer, footerOffset + 4, 4));
if (magic === "TAG!") {
const totalTagSize = view.getUint16(footerOffset, true); //
if (totalTagSize <= fullBuffer.byteLength) {
audioByteLength = fullBuffer.byteLength - totalTagSize;
}
}
}
const numSamples = Math.floor(audioByteLength / 2);
const int16Data = new Int16Array(fullBuffer, 0, numSamples);
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = audioCtx.createBuffer(1, numSamples, 16000);
const float32Data = audioBuffer.getChannelData(0);
for (let i = 0; i < numSamples; i++) {
float32Data[i] = int16Data[i] / 32768.0;
}
const source = audioCtx.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioCtx.destination);
// Referenz speichern, bevor wir starten
currentSource = source;
// Wenn die Datei natürlich endet, Referenz löschen
source.onended = () => {
if (currentSource === source) {
currentSource = null;
}
};
source.start();
}
export async function downloadLocalFile(name: string): Promise<void> {
const fileEntry = await getLocalFile(name);
if (!fileEntry) throw new Error('Datei nicht gefunden');
const url = URL.createObjectURL(fileEntry.blob);
const a = document.createElement('a');
a.href = url;
a.download = name + ".buzz";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@@ -1,5 +1,6 @@
// src/lib/init.ts
import { isBluetoothSupported, isSerialSupported, isInitializing } from './store';
import { refreshLocal } from './sync';
export function getBrowserName(): string {
const ua = navigator.userAgent;
@@ -20,5 +21,8 @@ export function performHardwareCheck() {
isBluetoothSupported.set(hasBT);
isSerialSupported.set(hasSerial);
isInitializing.set(false);
refreshLocal().then(() => {
isInitializing.set(false);
});
}

View File

@@ -10,11 +10,13 @@ export const FRAME = {
RESPONSE: 0x10,
ACK: 0x11,
ERROR: 0x12,
SUCCESS: 0x13,
FILE_START: 0x20,
FILE_CHUNK: 0x21,
FILE_END: 0x22,
LS_START: 0x40,
LS_ENTRY: 0x41,
LS_END: 0x42,
@@ -22,11 +24,14 @@ export const FRAME = {
export const DATA = {
PROTO_INFO: 0x01,
DEVICE_INFO: 0x02,
FS_INFO: 0x03,
FW_INFO: 0x04,
FILE_GET: 0x20,
FILE_PUT: 0x21,
TAGS_GET: 0x22,
TAGS_PUT: 0x23,
LS: 0x40
};
@@ -53,4 +58,11 @@ export const ZEPHYR_ERRORS: Record<number, ZephyrError> = {
36: { text: "Dateiname oder Pfad zu lang", zephyr: "ENAMETOOLONG" },
88: { text: "Funktion im Buzzer nicht implementiert", zephyr: "ENOSYS" },
134: { text: "Operation nicht unterstützt", zephyr: "ENOTSUP" }
};
};
export const FW_STATUS = {
CONFIRMED: 0x00,
PENDING: 0x01,
TESTING: 0x02,
UNKNOWN: 0xFF,
}

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,14 +1,24 @@
import { FRAME, DATA, ZEPHYR_ERRORS } from './constants';
import { protocolInfo, fsInfo } from '../store';
import { protocolInfo, deviceInfo, fsInfo, transferStats, fwInfo, resetTransferStats, transferDetails } from '../store';
import { addToast } from '../toast';
import { SETTINGS } from '../settings';
import { crc32 } from './crc32';
import { get } from 'svelte/store';
import { saveLocalFile } from '../db';
import { refreshLocal } from '../sync';
import { file } from 'astro:schema';
let lastUiUpdate = 0;
let currentFileCrc32 = 0;
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
let lsBuffer: any[] = [];
let fileChunks: Uint8Array[] = [];
let lsTimeout: ReturnType<typeof setTimeout> | null = null;
let lsResolve: ((data: any[]) => void) | null = null;
let lsReject: ((error: Error) => void) | null = null;
let fileGetResolve: ((success: boolean) => void) | null = null;
let fileGetResolve: ((result: { success: boolean, blob?: Blob }) => void) | null = null;
let fileGetReject: ((error: Error) => void) | null = null;
export function showErrorToast(errorCode: number) {
@@ -38,20 +48,42 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
switch (frameType) {
case FRAME.RESPONSE:
const dataType = view.getUint8(3);
if (dataType === DATA.PROTO_INFO && payloadLength >= 5) {
const version = view.getUint16(4, true);
const maxChunkSize = view.getUint16(6, true);
protocolInfo.set({ version, maxChunkSize });
} else if (dataType === DATA.FS_INFO && payloadLength >= 14) {
const totalSizeBytes = view.getUint32(4, true);
const freeSizeBytes = view.getUint32(8, true);
const maxPathLength = view.getUint8(12);
const sysPathLength = view.getUint8(13);
const audioPathLength = view.getUint8(14);
const sysPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15, sysPathLength));
const audioPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + sysPathLength, audioPathLength));
fsInfo.set({ totalSize: totalSizeBytes / 1024 / 1024, freeSize: freeSizeBytes / 1024 / 1024, maxPathLength, sysPath, audioPath });
switch (dataType) {
case DATA.PROTO_INFO:
const version = view.getUint16(4, true);
const maxChunkSize = view.getUint16(6, true);
protocolInfo.set({ version, maxChunkSize });
break;
case DATA.FS_INFO:
const totalSizeBytes = view.getUint32(4, true);
const freeSizeBytes = view.getUint32(8, true);
const maxPathLength = view.getUint8(12);
const sysPathLength = view.getUint8(13);
const audioPathLength = view.getUint8(14);
const sysPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15, sysPathLength));
const audioPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + sysPathLength, audioPathLength));
fsInfo.set({ totalSize: totalSizeBytes / 1024 / 1024, freeSize: freeSizeBytes / 1024 / 1024, maxPathLength, sysPath, audioPath });
break;
case DATA.DEVICE_INFO:
const deviceId = [0, 2, 4, 6]
.map(offset => view.getUint16(4 + offset, false).toString(16).padStart(4, '0').toUpperCase())
.join('-');
const boardNameLength = view.getUint8(12);
const boardRevisionLength = view.getUint8(13);
const socNameLength = view.getUint8(14);
const boardName = new TextDecoder().decode(new Uint8Array(view.buffer, 15, boardNameLength));
const boardRevision = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + boardNameLength, boardRevisionLength));
const socName = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + boardNameLength + boardRevisionLength, socNameLength));
deviceInfo.set({ deviceId, boardName, boardRevision, socName });
break;
case DATA.FW_INFO:
const fwStatus = view.getUint8(4);
const slot1Size = view.getUint32(5, true);
const fw_version_length = view.getUint8(9);
const kernel_version_length = view.getUint8(10);
const fwVersion = new TextDecoder().decode(new Uint8Array(view.buffer, 11, fw_version_length));
const kernelVersion = new TextDecoder().decode(new Uint8Array(view.buffer, 11 + fw_version_length, kernel_version_length));
fwInfo.set({ fwStatus, slot1Size, fwVersion, kernelVersion });
}
break;
@@ -78,63 +110,69 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
case FRAME.LS_END:
if (lsTimeout) clearTimeout(lsTimeout);
const total = view.getUint32(3, true);
console.debug(`LS Stream beendet. Erwartete Einträge: ${total}, empfangen: ${lsBuffer.length}`, lsBuffer);
if (total !== lsBuffer.length) {
console.warn(`LS Stream: Erwartete Anzahl Einträge laut Header: ${total}, tatsächlich empfangen: ${lsBuffer.length}`);
addToast(`Warnung: LS Stream erwartete ${total} Einträge, aber nur ${lsBuffer.length} empfangen.`, 'warning');
} else if (lsResolve) {
lsResolve([...lsBuffer]);
const currentResolve = lsResolve;
lsResolve = null;
lsReject = null;
currentResolve([...lsBuffer]);
}
break;
case FRAME.FILE_START:
fileTransfer.totalBytes = view.getUint32(3, true);
case FRAME.FILE_START:
currentFileCrc32 = 0;
const totalBytes = view.getUint32(3, true);
const nowStart = performance.now();
fileChunks = [];
if (fileTransfer.mode === 'file') {
transferStats.update(s => ({
...s,
bytesTotal: totalBytes,
bytesDone: 0,
currentFileName: s.pendingFileName || s.currentFileName,
fileStartTime: nowStart,
bulkStartTime: s.bulkStartTime === 0 ? nowStart : s.bulkStartTime
}));
}
// Parser-interne Metriken (Watchdog etc.)
fileTransfer.totalBytes = totalBytes;
fileTransfer.receivedBytes = 0;
fileTransfer.lastReceivedBytes = 0;
fileTransfer.stalledSeconds = 0;
fileTransfer.active = true;
fileTransfer.startTime = performance.now();
console.log(`[FILE_GET] Stream gestartet. Erwartete Größe: ${fileTransfer.totalBytes} Bytes.`);
fileTransfer.startTime = nowStart;
lastUiUpdate = 0;
fileTransfer.metricsTimer = setInterval(() => {
if (!fileTransfer.active) return;
// Watchdog-Logik: Prüfen ob seit der letzten Sekunde Daten kamen
if (fileTransfer.receivedBytes === fileTransfer.lastReceivedBytes) {
fileTransfer.stalledSeconds++;
if (fileTransfer.stalledSeconds >= 5) { // 5 Sekunden Timeout
console.warn("[FILE_GET] Übertragung abgebrochen: Timeout (Keine Daten empfangen).");
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
fileTransfer.active = false;
// Hier optional einen Toast anzeigen lassen, falls importiert:
// addToast("Dateitransfer abgebrochen (Timeout)", "error");
if (fileGetReject) {
fileGetReject(new Error("Timeout beim Dateitransfer"));
fileGetResolve = null;
fileGetReject = null;
addToast("Dateitransfer abgebrochen (Timeout)", "error");
const currentReject = fileGetReject;
fileGetResolve = null;
fileGetReject = null;
if (currentReject) {
currentReject(new Error("Timeout beim Dateitransfer"));
}
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,37 +183,105 @@ case FRAME.FILE_START:
case FRAME.FILE_CHUNK:
if (!fileTransfer.active) break;
const chunkData = new Uint8Array(view.buffer, 3, payloadLength);
currentFileCrc32 = crc32(chunkData, currentFileCrc32);
fileChunks.push(new Uint8Array(chunkData));
const previousReceived = fileTransfer.receivedBytes;
fileTransfer.receivedBytes += payloadLength;
fileTransfer.credits--;
// Nachladen, sobald die Credits auf 32 fallen (Dein Vorschlag)
if (fileTransfer.mode === 'file') {
const nowChunk = performance.now();
if (nowChunk - lastUiUpdate > SETTINGS.ui.transferUpdateIntervalMs) {
const delta = fileTransfer.receivedBytes - previousReceived;
transferStats.update(s => ({
...s,
bytesDone: fileTransfer.receivedBytes,
overallDone: s.overallDone + (fileTransfer.receivedBytes - s.bytesDone)
}));
lastUiUpdate = nowChunk;
}
}
if (fileTransfer.credits <= 64) {
fileTransfer.credits = 128;
sendCredits(fileTransfer.credits, sender);
}
break;
case FRAME.FILE_END:
if (fileTransfer.metricsTimer) {
clearInterval(fileTransfer.metricsTimer);
fileTransfer.metricsTimer = null;
if (fileTransfer.mode === 'file') {
transferStats.update(s => ({
...s,
bytesDone: s.bytesTotal,
}));
}
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
fileTransfer.active = false;
const crc32 = view.getUint32(3, true);
const buzzerCrc32 = view.getUint32(3, true);
const totalElapsed = (performance.now() - fileTransfer.startTime) / 1000;
const avgSpeed = (fileTransfer.receivedBytes / 1024) / totalElapsed;
console.log(`[FILE_GET] Stream beendet.`);
console.log(`[FILE_GET] Empfangen: ${fileTransfer.receivedBytes} Bytes in ${totalElapsed.toFixed(2)}s.`);
console.log(`[FILE_GET] Durchschnitt: ${avgSpeed.toFixed(2)} KB/s`);
console.log(`[FILE_GET] Zephyr CRC32: 0x${crc32.toString(16).toUpperCase().padStart(8, '0')}`);
if (currentFileCrc32 === buzzerCrc32) {
const fileBlob = new Blob(fileChunks, { type: 'application/octet-stream' });
if (fileGetResolve) {
fileGetResolve(true);
if (fileTransfer.mode === 'file') {
const fileName = get(transferStats).currentFileName;
const currentResolve = fileGetResolve;
const currentReject = fileGetReject;
// Direkt hier aufräumen, um Race Conditions bei schnellen Folge-Transfers zu vermeiden
fileGetResolve = null;
fileGetReject = null;
saveLocalFile(fileName, fileBlob, fileTransfer.totalBytes)
.then(() => {
refreshLocal();
if (currentResolve) {
currentResolve({ success: true });
}
})
.catch(err => {
console.error("Datenbankfehler:", err);
addToast(`Speichern von ${fileName} fehlgeschlagen.`, 'error');
if (currentReject) currentReject(err);
});
} else {
// TAGS Modus: Blob direkt zurückgeben, nichts speichern
const currentResolve = fileGetResolve;
fileGetResolve = null;
fileGetReject = null;
if (currentResolve) currentResolve({ success: true, blob: fileBlob });
}
} else {
console.error("[CRC] Mismatch! Datei beschädigt.");
addToast("CRC Fehler: Datei wurde fehlerhaft übertragen.", "error");
const currentReject = fileGetReject;
fileGetResolve = null;
fileGetReject = null;
if (currentReject) currentReject(new Error("CRC Mismatch"));
}
break;
case FRAME.ACK:
if (uploadState.active && payloadLength >= 2) {
const creditsAdded = view.getUint16(3, true);
uploadState.credits += creditsAdded;
if (uploadState.onCreditsAdded) {
uploadState.onCreditsAdded();
}
}
case FRAME.SUCCESS:
if (payloadLength >= 1) {
const successDataType = view.getUint8(3);
if (uploadState.active && successDataType === DATA.FILE_PUT || successDataType === DATA.TAGS_PUT) {
if (uploadState.onSuccess) uploadState.onSuccess();
}
}
break;
@@ -184,16 +290,21 @@ case FRAME.FILE_START:
console.error(`Received error frame with code: 0x${errorCode.toString(16)}`);
showErrorToast(errorCode);
if (lsReject) {
lsReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
const currentReject = lsReject;
lsResolve = null;
lsReject = null;
currentReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
}
if (fileGetReject && fileTransfer.active) {
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
fileTransfer.active = false;
fileGetReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
const currentReject = fileGetReject;
fileGetResolve = null;
fileGetReject = null;
currentReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
}
if (uploadState.active && uploadState.onError) {
uploadState.onError(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
}
break;
@@ -214,6 +325,17 @@ export function buildProtocolInfoRequest(): ArrayBuffer {
return buffer;
}
export function buildDeviceInfoRequest(): ArrayBuffer {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, 1, true);
view.setUint8(3, DATA.DEVICE_INFO);
return buffer;
}
export function buildFSInfoRequest(): ArrayBuffer {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
@@ -225,6 +347,17 @@ export function buildFSInfoRequest(): ArrayBuffer {
return buffer;
}
export function buildFWInfoRequest(): ArrayBuffer {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, 1, true);
view.setUint8(3, DATA.FW_INFO);
return buffer;
}
export function buildLSRequest(path: string): ArrayBuffer {
const encoder = new TextEncoder();
const pathBytes = encoder.encode(path);
@@ -250,7 +383,6 @@ async function sendCredits(count: number, send: FrameSender) {
const buffer = new ArrayBuffer(5);
const view = new DataView(buffer);
console.debug(`Sende ${count} Credits für Stream...`);
view.setUint8(0, FRAME.ACK);
view.setUint16(1, 2, true);
view.setUint16(3, count, true);
@@ -264,9 +396,10 @@ function resetLsWatchdog() {
addToast("Verzeichnis-Streaming abgebrochen (Timeout)", "warning");
lsBuffer = [];
if (lsReject) {
lsReject(new Error("Timeout beim Lesen des Verzeichnisses"));
const currentReject = lsReject;
lsResolve = null;
lsReject = null;
currentReject(new Error("Timeout beim Lesen des Verzeichnisses"));
}
}, 3000);
}
@@ -278,18 +411,32 @@ export function setLsResolver(resolve: (data: any[]) => void, reject: (error: Er
const fileTransfer = {
active: false,
mode: 'file' as 'file' | 'tags',
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
};
export function setFileGetResolver(resolve: (success: boolean) => void, reject: (error: Error) => void) {
export const uploadState = {
active: false,
credits: 0,
onCreditsAdded: null as (() => void) | null,
onSuccess: null as (() => void) | null,
onError: null as ((err: Error) => void) | null,
};
export function setFileGetResolver(
resolve: (result: { success: boolean, blob?: Blob }) => void,
reject: (error: Error) => void,
mode: 'file' | 'tags' = 'file' // Standard ist 'file'
) {
fileGetResolve = resolve;
fileGetReject = reject;
fileTransfer.mode = mode;
}
export function buildFileGetRequest(path: string): ArrayBuffer {
@@ -301,7 +448,23 @@ 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);
return buffer;
}
export function buildTagsGetRequest(path: string): ArrayBuffer {
const encoder = new TextEncoder();
const pathBytes = encoder.encode(path);
const buffer = new ArrayBuffer(4 + pathBytes.length);
const view = new DataView(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, 1 + pathBytes.length, true);
view.setUint8(3, DATA.TAGS_GET);
const uint8Buffer = new Uint8Array(buffer);
uint8Buffer.set(pathBytes, 4);

View File

@@ -1,11 +1,46 @@
import type { AudioProcessingOptions } from './types';
export const SETTINGS = {
storage: {
connectionKey: 'buzzer_connection_state'
},
bluetooth: {
connectionTimeoutMs: 10000 // Timeout für den Verbindungsaufbau
connectionTimeoutMs: 3000, // Timeout für den Verbindungsaufbau
appleMaxInflight: 15, // iOS erlaubt nur wenige unbestätigte Nachrichten, daher begrenzen wir die Anzahl der gleichzeitig gesendeten Frames
},
ui: {
toastDurationMs: 5000
}
};
toastDurationMs: 5000,
transferUpdateIntervalMs: 200,
speedSmoothingSamples: 50, // Anzahl der Messwerte für den gleitenden ETA-Durchschnitt
transferOverlayPersistMs: 4000,
estimatedInterFileGapMs: 700, // Initialer Schätzwert für die Pause zwischen zwei Dateien
},
};
export const AUDIO_PRESETS: Record<'normal' | 'broadcast', Partial<AudioProcessingOptions>> = {
normal: {
compressorThreshold: -45,
compressorRatio: 8,
compressorKnee: 10,
compressorAttack: 0.005,
compressorRelease: 0.15,
},
broadcast: {
// Noch aggressiver für maximale, konstante Lautstärke (Radio-Style)
compressorThreshold: -50,
compressorRatio: 12,
compressorKnee: 2,
compressorAttack: 0.002,
compressorRelease: 0.10,
}
};
export const DEFAULT_AUDIO_OPTIONS: AudioProcessingOptions = {
preset: 'normal',
lowCut: true,
lowCutFreq: 150,
compress: true,
...AUDIO_PRESETS.normal, // Initialisierung mit Normal-Werten
normalize: true,
normalizeTargetDb: -0.5,
} as AudioProcessingOptions;

View File

@@ -1,5 +1,8 @@
import { writable, derived } from 'svelte/store';
import type { BuzzerFile } from './types';
import{ type BuzzerFile, SyncState, type SyncStatus, type AudioProcessingOptions } from './types';
import {DEFAULT_AUDIO_OPTIONS, SETTINGS } from './settings';
const CONNECTION_STATE_KEY = 'buzzer_connection_state';
// Fallback-Typ fuer Build-Umgebungen ohne DOM-Library.
interface BluetoothDevice {
@@ -28,6 +31,13 @@ export interface ProtocolInfo {
maxChunkSize: number;
}
export interface DeviceInfo {
deviceId: string;
boardName: string;
boardRevision: string;
socName: string;
}
export interface FsInfo {
totalSize: number;
freeSize: number;
@@ -36,6 +46,13 @@ export interface FsInfo {
audioPath: string;
}
export interface FwInfo {
fwStatus: number;
slot1Size: number;
fwVersion: string;
kernelVersion: string;
}
export interface StorageUsage {
totalBytes: number;
freeBytes: number;
@@ -46,8 +63,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);
@@ -64,9 +79,11 @@ export const targetDeviceId = writable<string | null>(null);
export const pairedDevices = writable<BluetoothDevice[]>([]);
export const availableDevices = writable<Set<string>>(new Set()); // IDs der derzeit advertisierten Geräte
// Protokoll- und Dateisystem-Metadaten aus dem Device
// Metadaten aus dem Device
export const protocolInfo = writable<ProtocolInfo | null>(null);
export const deviceInfo = writable<DeviceInfo | null>(null);
export const fsInfo = writable<FsInfo | null>(null);
export const fwInfo = writable<FwInfo | null>(null);
// Dateilisten
export const buzzerAudioFiles = writable<BuzzerFile[]>([]);
@@ -74,7 +91,7 @@ export const buzzerSysFiles = writable<BuzzerFile[]>([]);
export const localAudioFiles = writable<BuzzerFile[]>([]);
// Ladezustände getrennt nach Quelle
export const isFetchingRemote = writable<boolean>(false);
export const isTransferingRemote = writable<boolean>(false);
export const isFetchingLocal = writable<boolean>(false);
// Persistenz des letzten Verbindungsziels (nur im Browser nutzbar)
@@ -120,55 +137,151 @@ 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({
isActive: false,
currentFileName: '',
pendingFileName: '',
bytesDone: 0,
bytesTotal: 0,
overallDone: 0,
overallTotal: 0,
bulkStartTime: 0,
fileStartTime: 0,
filesRemaining: 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;
export const resetTransferStats = () => {
transferStats.set({
isActive: false,
currentFileName: '',
pendingFileName: '',
bytesDone: 0,
bytesTotal: 0,
overallDone: 0,
overallTotal: 0,
bulkStartTime: 0,
fileStartTime: 0,
filesRemaining: 0
});
};
pairedDevices.update((devices) => {
if (!devices.find((d) => d.id === 'dummy-1')) {
return [...devices, dummy1, dummy2];
let speedSamples: number[] = [];
let lastSampleTime = 0;
let lastSampleBytes = 0;
let lastCalculatedSpeedKbs = 0;
let gapStartTime = 0;
let gapTimes: number[] = [];
let currentAverageGapMs = SETTINGS.ui.estimatedInterFileGapMs;
let wasActivelyTransferring = false;
export const transferDetails = derived(transferStats, ($s) => {
const now = performance.now();
// Nur nullen, wenn wirklich kein Transfer mehr im Store steht.
// Erlaubt das saubere Ausfaden des UI mit 100% Werten, auch wenn isActive schon false ist.
if ($s.overallTotal === 0) {
speedSamples = [];
lastSampleTime = 0;
lastSampleBytes = 0;
lastCalculatedSpeedKbs = 0;
gapStartTime = 0;
gapTimes = [];
currentAverageGapMs = SETTINGS.ui.estimatedInterFileGapMs;
wasActivelyTransferring = false;
return { filePercent: 0, totalPercent: 0, speedKbs: 0, fileEta: Infinity, totalEta: Infinity };
}
const isActivelyTransferring = $s.bytesDone > 0 && $s.bytesDone < $s.bytesTotal;
// --- 1. Gap Calculation (Realer Overhead zwischen den Dateien) ---
if (!isActivelyTransferring) {
if (wasActivelyTransferring || gapStartTime === 0) {
gapStartTime = now;
}
return devices;
});
} else {
if (!wasActivelyTransferring && gapStartTime > 0) {
const gapMs = now - gapStartTime;
gapStartTime = 0;
if (gapMs > 0 && gapMs < 10000) { // Plausibilitäts-Check
gapTimes.push(gapMs);
currentAverageGapMs = gapTimes.reduce((a, b) => a + b, 0) / gapTimes.length;
console.debug(`[Transfer] Inter-file overhead gap: ${gapMs.toFixed(1)}ms. Neues Bulk-Average: ${currentAverageGapMs.toFixed(1)}ms`);
}
}
}
wasActivelyTransferring = isActivelyTransferring;
availableDevices.update((set) => {
const newSet = new Set(set);
newSet.add('dummy-1');
return newSet;
});
}
// --- 2. Speed Calculation (Gleitender Durchschnitt) ---
if (isActivelyTransferring) {
if (lastSampleTime > 0) {
const timeDiff = (now - lastSampleTime) / 1000;
const updateIntervalSecs = SETTINGS.ui.transferUpdateIntervalMs / 1000;
if (timeDiff >= updateIntervalSecs) { // Dynamisches Fenster gemäß Config
const bytesDiff = $s.overallDone - lastSampleBytes;
if (bytesDiff >= 0) {
const currentSpeedKbs = (bytesDiff / 1024) / timeDiff;
speedSamples.push(currentSpeedKbs);
if (speedSamples.length > SETTINGS.ui.speedSmoothingSamples) {
speedSamples.shift();
}
lastCalculatedSpeedKbs = speedSamples.reduce((a, b) => a + b, 0) / speedSamples.length;
}
lastSampleTime = now;
lastSampleBytes = $s.overallDone;
}
} else {
lastSampleTime = now;
lastSampleBytes = $s.overallDone;
}
} else {
lastSampleTime = 0; // Friert den Speed ein
}
const speedBytesPerSec = lastCalculatedSpeedKbs * 1024;
const estimatedGapSecs = ($s.filesRemaining * currentAverageGapMs) / 1000;
// ETA Aufrunden (Math.ceil), damit die letzte Sekunde immer als "1s" und nicht "0s" angezeigt wird.
// Harter Fallback auf 0, sobald die Datei/der Bulk physisch 100% erreicht hat.
let fileEta = Infinity;
if ($s.bytesTotal > 0 && $s.bytesDone >= $s.bytesTotal) {
fileEta = 0;
} else if (speedBytesPerSec > 100) {
fileEta = Math.ceil(($s.bytesTotal - $s.bytesDone) / speedBytesPerSec);
}
let totalEta = Infinity;
if ($s.overallTotal > 0 && $s.overallDone >= $s.overallTotal) {
totalEta = 0;
} else if (speedBytesPerSec > 100) {
totalEta = Math.ceil((($s.overallTotal - $s.overallDone) / speedBytesPerSec) + estimatedGapSecs);
}
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(lastCalculatedSpeedKbs.toFixed(2)),
fileEta,
totalEta
};
});
// Reset-Funktionen für verschiedene Anwendungsfälle
export function resetRemote(): void {
isConnected.set(false);
isConnecting.set(false);
protocolInfo.set(null);
deviceInfo.set(null);
fsInfo.set(null);
fwInfo.set(null);
activeDeviceId.set(null);
buzzerAudioFiles.set([]);
buzzerSysFiles.set([]);
isFetchingRemote.set(false);
isTransferingRemote.set(false);
resetTransferStats();
}
export function resetLocal(): void {
@@ -179,4 +292,150 @@ 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
);
export const tagEditorState = writable<{show: boolean, type: "local" | "buzzer", fileName: string}>({
show: false,
type: "buzzer",
fileName: ""
});
function tagsAreEqual(tagsA: any, tagsB: any): boolean {
const a = tagsA || {};
const b = tagsB || {};
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) return false;
}
return true;
}
export const syncStateMap = derived(
[localAudioFiles, buzzerAudioFiles],
([$local, $remote]) => {
const result = {
local: {} as Record<string, SyncStatus>,
buzzer: {} as Record<string, SyncStatus>
};
const localByCrc = new Map<number, typeof $local>();
const remoteByCrc = new Map<number, typeof $remote>();
// 1. Gruppierung und Filterung von fehlenden CRCs
for (const file of $local) {
if (!file.sysTags || !file.sysTags.crc32) {
result.local[file.name] = { state: SyncState.UNKNOWN, linkedFiles: [] };
continue;
}
const crc = file.sysTags.crc32;
if (!localByCrc.has(crc)) localByCrc.set(crc, []);
localByCrc.get(crc)!.push(file);
}
for (const file of $remote) {
if (!file.sysTags || !file.sysTags.crc32) {
result.buzzer[file.name] = { state: SyncState.UNKNOWN, linkedFiles: [] };
continue;
}
const crc = file.sysTags.crc32;
if (!remoteByCrc.has(crc)) remoteByCrc.set(crc, []);
remoteByCrc.get(crc)!.push(file);
}
// 2. Auswertung der Gruppen
const allCrcs = new Set([...localByCrc.keys(), ...remoteByCrc.keys()]);
for (const crc of allCrcs) {
const locals = localByCrc.get(crc) || [];
const remotes = remoteByCrc.get(crc) || [];
const remoteNames = remotes.map(f => f.name);
const localNames = locals.map(f => f.name);
// Regel 5: Duplikate
if (locals.length > 1 || remotes.length > 1) {
locals.forEach(f => result.local[f.name] = { state: SyncState.DUPLICATE, linkedFiles: localNames.filter(n => n !== f.name) });
remotes.forEach(f => result.buzzer[f.name] = { state: SyncState.DUPLICATE, linkedFiles: remoteNames.filter(n => n !== f.name) });
continue;
}
// Regel 2: Einseitig vorhanden
if (locals.length === 1 && remotes.length === 0) {
result.local[locals[0].name] = { state: SyncState.SINGLE_SIDED, linkedFiles: [] };
continue;
}
if (locals.length === 0 && remotes.length === 1) {
result.buzzer[remotes[0].name] = { state: SyncState.SINGLE_SIDED, linkedFiles: [] };
continue;
}
// Regel 3 & 4: Beidseitig vorhanden (genau 1 lokal, genau 1 remote)
if (locals.length === 1 && remotes.length === 1) {
const localFile = locals[0];
const remoteFile = remotes[0];
const namesMatch = localFile.name === remoteFile.name;
const tagsMatch = tagsAreEqual(localFile.metaTags, remoteFile.metaTags);
const finalState = (namesMatch && tagsMatch) ? SyncState.SYNCED : SyncState.CONFLICT;
result.local[localFile.name] = { state: finalState, linkedFiles: remoteNames };
result.buzzer[remoteFile.name] = { state: finalState, linkedFiles: localNames };
}
}
return result;
}
);
function createAudioOptionsStore() {
// 1. Initialen Wert aus dem Local Storage laden
const stored = localStorage.getItem("edi_audio_options");
// Merge aus Defaults und gespeicherten Werten, falls neue Felder hinzukommen
const initialValue: AudioProcessingOptions = stored
? { ...DEFAULT_AUDIO_OPTIONS, ...JSON.parse(stored) }
: DEFAULT_AUDIO_OPTIONS;
const { subscribe, set, update } = writable<AudioProcessingOptions>(initialValue);
// 2. Bei jeder Änderung automatisch in den Local Storage schreiben
subscribe((currentValue) => {
localStorage.setItem("edi_audio_options", JSON.stringify(currentValue));
});
return {
subscribe,
set,
update,
};
}
export const audioOptions = createAudioOptionsStore();

View File

@@ -1,7 +1,12 @@
import { get } from 'svelte/store';
import { isConnected, fsInfo, buzzerAudioFiles, buzzerSysFiles, isFetchingRemote, isFetchingLocal, storageUsage} from './store';
import { requestProtocolInfo, requestFSInfo, fetchDirectory } from './transport';
import { isConnected, deviceInfo, fsInfo, fwInfo, buzzerAudioFiles, buzzerSysFiles, isTransferingRemote, isFetchingLocal, storageUsage, localAudioFiles, transferStats } from './store';
import { requestProtocolInfo, requestFSInfo, fetchDirectory, getFile, putFile, deleteRemoteFile, requestDeviceInfo, requestFWInfo } from './transport';
import type { BuzzerFile } from './types';
import { addToast } from './toast';
import { getLocalFiles, deleteLocalFile, getLocalFile } from './db';
import { parseAudioFileTags } from './tagHandler';
import { SETTINGS } from './settings';
import { fetchFileTags } from './tagHandler';
function mapToBuzzerFile(rawFile: any): BuzzerFile {
return {
@@ -10,50 +15,259 @@ function mapToBuzzerFile(rawFile: any): BuzzerFile {
type: rawFile.type,
tagsLoaded: false,
sysTags: { format: null, crc32: null },
metaTags: {}
metaTags: {},
selected: false,
};
}
export async function refreshRemote() {
if (!get(isConnected)) return;
isFetchingRemote.set(true);
isTransferingRemote.set(true);
try {
await requestProtocolInfo();
await requestFSInfo();
await requestFWInfo();
await requestDeviceInfo();
// Kurze Verzögerung für Store-Propagation
await new Promise(r => setTimeout(r, 100));
const currentFsInfo = get(fsInfo);
// Sequenzielle Abfrage via Transport-Layer
const sysFiles = await fetchDirectory(currentFsInfo?.sysPath || "/lfs/sys");
buzzerSysFiles.set(sysFiles.map(mapToBuzzerFile));
const audioFiles = await fetchDirectory(currentFsInfo?.audioPath || "/lfs/a");
buzzerAudioFiles.set(audioFiles.map(mapToBuzzerFile));
let mappedAudio = audioFiles.map(mapToBuzzerFile);
// Dateien sofort im UI anzeigen, bevor die Tags geladen sind
buzzerAudioFiles.set([...mappedAudio]);
// Tags sequenziell für alle gefundenen Audiodateien laden
for (let i = 0; i < mappedAudio.length; i++) {
const fileName = mappedAudio[i].name;
try {
const tags = await fetchFileTags(fileName, "buzzer");
mappedAudio[i].sysTags = tags.sysTags;
mappedAudio[i].metaTags = tags.metaTags;
mappedAudio[i].tagsLoaded = true;
// Store aktualisieren, um das UI pro Datei neu zu rendern
buzzerAudioFiles.set([...mappedAudio]);
} catch (error) {
console.warn(`Konnte Tags für ${fileName} nicht laden.`);
}
}
console.log("Audiodatein: ", audioFiles);
console.log("Systemdatein: ", sysFiles);
console.log("Aktuelle FS-Info: ", currentFsInfo);
console.log("Storage Usage: ", get(storageUsage));
} catch (error) {
console.error("Fehler beim Aktualisieren der Buzzer-Daten:", error);
addToast("Fehler beim Laden der Daten vom Buzzer: " + (error instanceof Error ? error.message : "Unbekannter Fehler"), "error");
} finally {
isFetchingRemote.set(false);
isTransferingRemote.set(false);
}
}
export async function refreshLocal() {
isFetchingLocal.set(true);
try {
// TODO: Implementierung lokaler Dateisystem-Zugriff (z.B. File System Access API)
// const files = await readLocalDirectory();
// localAudioFiles.set(files.map(mapToBuzzerFile));
const dbFiles = await getLocalFiles();
// Paralleles Parsen aller Blobs in der lokalen Datenbank
const files: BuzzerFile[] = await Promise.all(dbFiles.map(async (record) => {
const { sysTags, metaTags } = await parseAudioFileTags(record.blob, record.name);
return {
name: record.name,
size: record.size,
type: 0, // 0 = File
tagsLoaded: true, // Marker, dass Tags erfolgreich extrahiert wurden
sysTags: sysTags,
metaTags: metaTags,
selected: false,
};
}));
localAudioFiles.set(files);
} catch (error) {
console.error("Fehler beim Aktualisieren der lokalen Daten:", error);
console.error("Fehler beim Laden der lokalen Datenbank:", error);
addToast("Fehler beim Laden der lokalen Datenbank: " + (error instanceof Error ? error.message : "Unbekannter Fehler"), "error");
} 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,
isActive: true,
overallTotal: totalBytes,
overallDone: 0,
bulkStartTime: bulkStart,
filesRemaining: files.length
}));
isTransferingRemote.set(true);
try {
for (const file of files) {
console.debug(`Starte Download von: ${file.name}`);
// Setzt die Einzel-Balken hart auf 0 und bereitet UI perfekt auf neue Datei vor
transferStats.update(s => ({
...s,
pendingFileName: file.name,
currentFileName: file.name,
bytesTotal: file.size,
bytesDone: 0,
filesRemaining: s.filesRemaining > 0 ? s.filesRemaining - 1 : 0
}));
const fullPath = `${pathPrefix}/${file.name}`;
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,
isActive: false, // UI Overlay verstecken
overallDone: s.overallTotal,
}));
isTransferingRemote.set(false);
}
}
export async function deleteSelectedLocalFiles() {
const files = get(localAudioFiles);
const selectedFiles = files.filter(f => f.selected);
if (selectedFiles.length === 0) return;
if (!confirm(`Möchten Sie wirklich ${selectedFiles.length} lokale Datei(en) löschen?`)) {
return;
}
try {
for (const file of selectedFiles) {
await deleteLocalFile(file.name);
}
await refreshLocal();
} catch (error) {
console.error("Fehler beim Löschen lokaler Dateien:", error);
}
}
export async function deleteSelectedRemoteFiles() {
const files = get(buzzerAudioFiles).filter(f => f.selected);
const pathPrefix = get(fsInfo)?.audioPath || "/lfs/a";
if (files.length === 0) return;
if (!confirm(`Möchten Sie wirklich ${files.length} Datei(en) auf dem Buzzer löschen?`)) {
return;
}
isTransferingRemote.set(true);
try {
for (const file of files) {
const fullPath = `${pathPrefix}/${file.name}`;
console.debug(`Lösche Datei auf dem Buzzer: ${fullPath}`);
await deleteRemoteFile(fullPath);
}
addToast(`${files.length} Datei(en) auf dem Buzzer gelöscht.`, "success");
await refreshRemote();
} catch (error) {
console.error("Fehler beim Löschen auf dem Buzzer:", error);
addToast("Fehler beim Löschen: " + (error instanceof Error ? error.message : "Unbekannt"), "error");
} finally {
isTransferingRemote.set(false);
}
}
export async function uploadSelectedFiles() {
const files = get(localAudioFiles).filter(f => f.selected);
const pathPrefix = get(fsInfo)?.audioPath || "/lfs/a";
if (files.length === 0) {
addToast("Keine Dateien zum Hochladen ausgewählt.", "warning");
return;
}
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
const bulkStart = performance.now();
transferStats.update(s => ({
...s,
isActive: true,
overallTotal: totalBytes,
overallDone: 0,
bulkStartTime: bulkStart,
filesRemaining: files.length
}));
isTransferingRemote.set(true);
try {
for (const file of files) {
console.debug(`Starte Upload von: ${file.name} (${(file.size / 1024).toFixed(1)} kB)`);
// Resetted die Store-Stats VOR der DB-Abfrage, UI glättet sich sofort
transferStats.update(s => ({
...s,
pendingFileName: file.name,
currentFileName: file.name,
bytesTotal: file.size,
bytesDone: 0,
filesRemaining: s.filesRemaining > 0 ? s.filesRemaining - 1 : 0
}));
const dbRecord = await getLocalFile(file.name);
if (!dbRecord || !dbRecord.blob) {
throw new Error(`Datei ${file.name} nicht in lokaler Datenbank gefunden.`);
}
const fullPath = `${pathPrefix}/${file.name}`;
await putFile(dbRecord.blob, fullPath, file.name);
}
const totalTimeSec = (performance.now() - bulkStart) / 1000;
const avgSpeedKbs = ((totalBytes / 1024) / totalTimeSec).toFixed(1);
addToast(` ${files.length === 1 ? "Eine Datei" : files.length + " Dateien"} erfolgreich hochgeladen. (${avgSpeedKbs} kB/s)`, "success");
// Buzzer-Ansicht nach erfolgreichem Upload aktualisieren
refreshRemote();
} catch (error) {
console.error("Bulk-Upload Fehler:", error);
addToast("Upload abgebrochen: " + (error instanceof Error ? error.message : "Unbekannter Fehler"), "error");
} finally {
transferStats.update(s => ({
...s,
isActive: false,
overallDone: s.overallTotal, // Schließt den Ladebalken visuell sauber ab
}));
isTransferingRemote.set(false);
}
}

View File

@@ -0,0 +1,369 @@
import { getLocalFiles, saveLocalFile, deleteLocalFile } from './db';
import type { SystemTags, MetadataTags, AudioFormat } from './types';
import { addToast } from './toast';
import { crc32 } from './protocol/crc32';
import { getTags, putTags, renameRemoteFile } from './transport';
import { get } from 'svelte/store';
import { fsInfo, syncStateMap } from './store';
function getEmptyTags() {
return {
sysTags: { format: null, crc32: null } as SystemTags,
metaTags: {} as MetadataTags
};
}
export async function parseAudioFileTags(blob: Blob, filename: string): Promise<{ sysTags: SystemTags, metaTags: MetadataTags }> {
const MAGIC = "TAG!";
const VERSION = 1;
// Ein Footer ist exakt 8 Bytes groß
if (blob.size < 8) return getEmptyTags();
// 1. Footer auslesen (EOF - 8)
const footerBuffer = await blob.slice(-8).arrayBuffer();
const footerView = new DataView(footerBuffer);
// Magic-Signatur prüfen ("TAG!")
const magicDecoder = new TextDecoder('ascii');
const magicStr = magicDecoder.decode(new Uint8Array(footerBuffer, 4, 4));
if (magicStr !== MAGIC) {
return getEmptyTags(); // Datei hat keine angehängten Metadaten
}
// Version prüfen (Little-Endian)
const version = footerView.getUint16(2, true);
if (version !== VERSION) {
console.warn(`TagHandler: Nicht unterstützte Format-Version (${version}) in der Datei ${filename}.`);
addToast(`Warnung: Unbekanntes Metadaten-Format in der Datei <b>${filename}</b>. Es können keine Tags ausgelesen werden.`, "warning");
return getEmptyTags();
}
// Gesamtgröße der Metadaten ermitteln
const totalSize = footerView.getUint16(0, true);
if (totalSize < 8 || totalSize > blob.size) {
console.error(`TagHandler: total_size ist ungültig (korrupt) in der Datei ${filename}.`);
addToast(`Fehler: Ungültige Metadaten in der Datei <b>${filename}</b>. Es können keine Tags ausgelesen werden.`, "error");
return getEmptyTags();
}
// 2. TLV-Block isolieren (alles zwischen Audio-Daten und Footer)
const tlvSize = totalSize - 8;
if (tlvSize <= 0) return getEmptyTags(); // Keine Tags vorhanden, nur Footer
const metadataStart = blob.size - totalSize;
const tlvBuffer = await blob.slice(metadataStart, metadataStart + tlvSize).arrayBuffer();
const tlvView = new DataView(tlvBuffer);
const tags = getEmptyTags();
let offset = 0;
// 3. TLV-Blöcke iterieren
while (offset + 4 <= tlvSize) { // 4 Bytes für den Header (Type, Index, Length)
const type = tlvView.getUint8(offset);
const index = tlvView.getUint8(offset + 1);
const length = tlvView.getUint16(offset + 2, true);
offset += 4; // Zeiger hinter den Header verschieben
// Sicherheitsprüfung gegen Pufferüberläufe durch korrupte Längenangaben
if (offset + length > tlvSize) {
console.warn(`TagHandler: TLV-Block überschreitet Puffergröße in der Datei ${filename}. Abbruch.`);
addToast(`Warnung: Ungültige Metadatenstruktur in der Datei <b>${filename}</b>. Es können nur teilweise Tags ausgelesen werden.`, "warning");
break;
}
if (type === 0x00) {
// System Metadata
if (index === 0x00 && length === 8) {
// Audio Format
tags.sysTags.format = {
codec: tlvView.getUint8(offset),
bitDepth: tlvView.getUint8(offset + 1),
// Bytes an Offset 2 und 3 sind reserviert/Padding
sampleRate: tlvView.getUint32(offset + 4, true)
};
} else if (index === 0x01 && length === 4) {
// Audio CRC32
tags.sysTags.crc32 = tlvView.getUint32(offset, true);
}
} else if (type === 0x10) {
// JSON Metadata
try {
const jsonBytes = new Uint8Array(tlvBuffer, offset, length);
const jsonStr = new TextDecoder('utf-8').decode(jsonBytes);
const parsed = JSON.parse(jsonStr);
// Geparste Daten in das MetaTag-Interface mergen
tags.metaTags = { ...tags.metaTags, ...parsed };
} catch (e) {
console.error(`TagHandler: Fehler beim Parsen der JSON-Metadaten in der Datei ${filename}.`, e);
addToast(`Fehler: Ungültige JSON-Metadaten in der Datei <b>${filename}</b>. Es können keine Tags ausgelesen werden.`, "error");
}
}
offset += length; // Zum nächsten TLV-Block springen
}
return tags;
}
// Baut den binären TLV-Block gemäß Spezifikation
function buildTagBlock(sysTags: SystemTags, metaTags: MetadataTags): ArrayBuffer {
const jsonStr = JSON.stringify(metaTags);
const jsonBytes = new TextEncoder().encode(jsonStr);
// Größen berechnen
const tlvAudioFormatSize = sysTags.format ? 4 + 8 : 0; // Header(4) + Payload(8)
const tlvCrcSize = sysTags.crc32 ? 4 + 4 : 0; // Header(4) + Payload(4)
const tlvJsonSize = 4 + jsonBytes.length; // Header(4) + Payload(variabel)
const totalSize = tlvAudioFormatSize + tlvCrcSize + tlvJsonSize + 8; // + 8 für Footer
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const uint8View = new Uint8Array(buffer);
let offset = 0;
// 1. Audio Format TLV (Type 0x00, Index 0x00)
if (sysTags.format) {
view.setUint8(offset, 0x00);
view.setUint8(offset + 1, 0x00);
view.setUint16(offset + 2, 8, true);
view.setUint8(offset + 4, sysTags.format.codec);
view.setUint8(offset + 5, sysTags.format.bitDepth);
// Bytes an Offset 6 und 7 bleiben 0 (Reserved)
view.setUint32(offset + 8, sysTags.format.sampleRate, true);
offset += 12;
}
// 2. CRC32 TLV (Type 0x00, Index 0x01)
if (sysTags.crc32) {
view.setUint8(offset, 0x00);
view.setUint8(offset + 1, 0x01);
view.setUint16(offset + 2, 4, true);
view.setUint32(offset + 4, sysTags.crc32, true);
offset += 8;
}
// 3. JSON Metadata TLV (Type 0x10, Index 0x00)
view.setUint8(offset, 0x10);
view.setUint8(offset + 1, 0x00);
view.setUint16(offset + 2, jsonBytes.length, true);
uint8View.set(jsonBytes, offset + 4);
offset += 4 + jsonBytes.length;
console.log(`TagHandler: JSON-Metadaten mit ${jsonBytes.length} Bytes hinzugefügt.`, { jsonStr });
// 4. Footer
view.setUint16(offset, totalSize, true); // Gesamtgröße
view.setUint16(offset + 2, 1, true); // Version 1
// Magic "TAG!"
view.setUint8(offset + 4, 0x54); // T
view.setUint8(offset + 5, 0x41); // A
view.setUint8(offset + 6, 0x47); // G
view.setUint8(offset + 7, 0x21); // !
return buffer;
}
// Hauptfunktion für das lokale Aktualisieren und Umbenennen
export async function updateLocalFile(oldName: string, newName: string, sysTags: SystemTags, newMetaTags: MetadataTags): Promise<void> {
const files = await getLocalFiles();
const record = files.find(f => f.name === oldName);
if (!record) throw new Error(`Datei ${oldName} nicht in der lokalen Datenbank gefunden.`);
const blob = record.blob;
let audioBlob = blob;
// Alte Metadaten abschneiden, falls vorhanden
if (blob.size >= 8) {
const footerBuf = await blob.slice(-8).arrayBuffer();
const footerView = new DataView(footerBuf);
const magicStr = new TextDecoder('ascii').decode(new Uint8Array(footerBuf, 4, 4));
if (magicStr === "TAG!") {
const totalSize = footerView.getUint16(0, true);
if (totalSize >= 8 && totalSize <= blob.size) {
audioBlob = blob.slice(0, blob.size - totalSize);
}
}
}
// Neue Tags generieren und an die Rohdaten anhängen
const newTagsBuffer = buildTagBlock(sysTags, newMetaTags);
const finalBlob = new Blob([audioBlob, newTagsBuffer]);
// In die Datenbank schreiben
if (oldName !== newName) {
await deleteLocalFile(oldName);
}
await saveLocalFile(newName, finalBlob, finalBlob.size);
}
export async function updateRemoteFile(
oldName: string,
newName: string,
sysTags: SystemTags,
newMetaTags: MetadataTags
): Promise<void> {
const currentFsInfo = get(fsInfo);
const basePath = currentFsInfo?.audioPath || "/lfs/a";
const oldFullPath = `${basePath}/${oldName}`;
const newFullPath = `${basePath}/${newName}`;
if (oldName !== newName) {
console.log(`TagHandler: Benenne Datei auf dem Buzzer um (${oldFullPath} -> ${newFullPath})`);
await renameRemoteFile(oldFullPath, newFullPath);
}
// 1. Den binären TLV-Block inkl. Footer bauen
const newTagsBuffer = buildTagBlock(sysTags, newMetaTags);
const tagsBlob = new Blob([newTagsBuffer]);
console.log(`Sende modifizierte Tags (${tagsBlob.size} Bytes) an ${newFullPath}...`);
// 3. Über das Protokoll an den Buzzer senden
await putTags(tagsBlob, newFullPath);
}
export async function updateFile(
oldName: string,
newName: string,
sysTags: SystemTags,
newMetaTags: MetadataTags,
type: "local" | "buzzer",
applyToBoth: boolean = false
): Promise<void> {
if (type === "local") {
await updateLocalFile(oldName, newName, sysTags, newMetaTags);
if (applyToBoth) {
try {
const sMap = get(syncStateMap);
const syncStatus = sMap.local[oldName];
if (syncStatus && syncStatus.linkedFiles.length > 0) {
const remoteOldName = syncStatus.linkedFiles[0];
await updateRemoteFile(remoteOldName, newName, sysTags, newMetaTags);
} else {
console.warn(`Keine verknüpfte Remote-Datei für '${oldName}' gefunden. Überspringe Remote-Update.`);
}
} catch (e) {
console.error("Fehler beim synchronen Aktualisieren der Buzzer-Datei:", e);
addToast("Fehler beim Update der Buzzer-Datei: " + (e instanceof Error ? e.message : "Unbekannt"), "error");
}
}
} else {
await updateRemoteFile(oldName, newName, sysTags, newMetaTags);
if (applyToBoth) {
try {
const sMap = get(syncStateMap);
const syncStatus = sMap.buzzer[oldName];
if (syncStatus && syncStatus.linkedFiles.length > 0) {
const localOldName = syncStatus.linkedFiles[0];
await updateLocalFile(localOldName, newName, sysTags, newMetaTags);
} else {
throw new Error(`Keine verknüpfte lokale Datei für '${oldName}' gefunden.`);
}
} catch (e) {
console.error("Fehler beim synchronen Aktualisieren der lokalen Datei:", e);
addToast("Fehler beim Update der lokalen Datei: " + (e instanceof Error ? e.message : "Unbekannt"), "error");
}
}
}
}
/**
* FASADE: Einheitlicher Einstiegspunkt zum Auslesen von Tags.
*/
export async function fetchFileTags(
filename: string,
type: "local" | "buzzer"
): Promise<{ sysTags: SystemTags, metaTags: MetadataTags }> {
if (type === "local") {
const files = await getLocalFiles();
const record = files.find(f => f.name === filename);
if (!record) throw new Error(`Datei ${filename} lokal nicht gefunden.`);
return await parseAudioFileTags(record.blob, filename);
} else {
try {
const currentFsInfo = get(fsInfo);
const basePath = currentFsInfo?.audioPath || "/lfs/a";
const fullPath = `${basePath}/${filename}`;
const blob = await getTags(fullPath);
return await parseAudioFileTags(blob, filename);
} catch (e) {
console.warn(`Fehler beim Abrufen der Remote-Tags für ${filename}:`, e);
return getEmptyTags();
}
}
}
export async function calculateLocalAudioCrc(filename: string): Promise<number> {
const files = await getLocalFiles();
const record = files.find(f => f.name === filename);
if (!record) throw new Error(`Datei ${filename} nicht gefunden.`);
const blob: Blob = record.blob;
let audioSize = blob.size;
// 1. Prüfen, ob Metadaten vorhanden sind, um nur den Audioanteil zu berechnen
if (blob.size >= 8) {
const footerBuffer = await blob.slice(-8).arrayBuffer();
const footerView = new DataView(footerBuffer);
const magic = new TextDecoder('ascii').decode(new Uint8Array(footerBuffer, 4, 4));
if (magic === "TAG!") {
const totalSize = footerView.getUint16(0, true);
if (totalSize >= 8 && totalSize <= blob.size) {
audioSize = blob.size - totalSize; // Nur bis zum Beginn der Tags lesen
}
}
}
// 2. Audio-Daten einlesen
const audioData = await blob.slice(0, audioSize).arrayBuffer();
// 3. CRC32 berechnen
return crc32(new Uint8Array(audioData));
}
export async function updateLocalAudioCrc(filename: string): Promise<number> {
const files = await getLocalFiles();
const record = files.find(f => f.name === filename);
if (!record) throw new Error(`Datei ${filename} nicht gefunden.`);
// Tags auslesen
const { sysTags, metaTags } = await parseAudioFileTags(record.blob, filename);
// Neue CRC berechnen
const newCrc = await calculateLocalAudioCrc(filename);
if (newCrc === sysTags.crc32) {
console.log(`TagHandler: CRC32 für ${filename} ist bereits aktuell (${newCrc}). Kein Update nötig.`);
return newCrc; // Keine Änderung, daher kein Update
}
console.log(`TagHandler: Aktualisiere CRC32 für ${filename} von ${sysTags.crc32} auf ${newCrc}.`);
// Mit aktualisierter CRC speichern
const updatedSysTags = { ...sysTags, crc32: newCrc };
await updateLocalFile(filename, filename, updatedSysTags, metaTags);
return newCrc;
}
export async function updateLocalAudioFormat(filename: string, audioFormat: AudioFormat ): Promise<AudioFormat> {
const files = await getLocalFiles();
const record = files.find(f => f.name === filename);
if (!record) throw new Error(`Datei ${filename} nicht gefunden.`);
// Tags auslesen
const { sysTags, metaTags } = await parseAudioFileTags(record.blob, filename);
// Neue CRC berechnen
console.log(`TagHandler: Aktualisiere Audioformat für ${filename}`, { oldFormat: sysTags.format, newFormat: audioFormat });
// Mit aktualisierter CRC speichern
const updatedSysTags = { ...sysTags, format: audioFormat };
await updateLocalFile(filename, filename, updatedSysTags, metaTags);
return audioFormat;
}

View File

@@ -1,6 +1,15 @@
import { buildLSRequest, buildProtocolInfoRequest, buildFSInfoRequest, setLsResolver } from './protocol/parser';
import { buildFileGetRequest, setFileGetResolver } from './protocol/parser';
import { buildLSRequest, buildProtocolInfoRequest, buildDeviceInfoRequest, buildFSInfoRequest, buildFWInfoRequest, setLsResolver, buildFileGetRequest, setFileGetResolver, buildTagsGetRequest, uploadState } from './protocol/parser';
import { crc32 } from './protocol/crc32';
import { get } from 'svelte/store';
import { protocolInfo, transferStats, } from './store';
import { DATA, FRAME } from './protocol/constants';
import { isConnected, resetRemote } from './store';
import { SETTINGS } from './settings';
const isMac = navigator.userAgent.includes('Macintosh') || navigator.userAgent.includes('Mac OS X');
const MAX_INFLIGHT = isMac ? SETTINGS.bluetooth.appleMaxInflight : Infinity; // iOS erlaubt nur wenige unbestätigte Nachrichten
console.log("Transport: Max Inflight Frames =", MAX_INFLIGHT);
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
let currentSender: FrameSender | null = null;
@@ -17,6 +26,8 @@ export async function handleTransportConnect(sender: FrameSender) {
// Basis-Informationen zwingend vorab laden
await requestProtocolInfo();
await requestFSInfo();
await requestDeviceInfo();
await requestFWInfo();
// Erst wenn diese Basisdaten da sind, wird die UI freigeschaltet
isConnected.set(true);
@@ -35,10 +46,18 @@ export async function requestProtocolInfo() {
await sendFrame(buildProtocolInfoRequest());
}
export async function requestDeviceInfo() {
await sendFrame(buildDeviceInfoRequest());
}
export async function requestFSInfo() {
await sendFrame(buildFSInfoRequest());
}
export async function requestFWInfo() {
await sendFrame(buildFWInfoRequest());
}
let isListing = false;
export async function fetchDirectory(path: string): Promise<any[]> {
@@ -70,7 +89,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.");
}
@@ -78,7 +97,7 @@ export async function fetchFileThroughputTest(path: string): Promise<boolean> {
return new Promise(async (resolve, reject) => {
setFileGetResolver(
(success) => { isFileTransferring = false; resolve(success); },
(result: any) => { isFileTransferring = false; resolve(result.success); },
(err) => { isFileTransferring = false; reject(err); }
);
@@ -89,4 +108,314 @@ export async function fetchFileThroughputTest(path: string): Promise<boolean> {
reject(e);
}
});
}
export async function getTags(path: string): Promise<Blob> {
if (isFileTransferring) {
throw new Error("Ein Dateitransfer läuft bereits.");
}
isFileTransferring = true;
return new Promise(async (resolve, reject) => {
setFileGetResolver(
(result: any) => {
isFileTransferring = false;
// Wenn wir erfolgreich sind, geben wir den Blob zurück. Bei 0 Bytes ist er leer.
if (result.success && result.blob) {
resolve(result.blob);
} else {
resolve(new Blob([])); // Fallback
}
},
(err) => {
isFileTransferring = false;
reject(err);
},
'tags' // WICHTIG: Setzt den Parser in den stummen Modus ohne UI-Ladebalken!
);
try {
await sendFrame(buildTagsGetRequest(path));
} catch (e) {
isFileTransferring = false;
reject(e);
}
});
}
export async function putFile(fileBlob: Blob, remotePath: string, fileNameForUI: string): Promise<void> {
if (isFileTransferring) {
throw new Error("Ein Dateitransfer läuft bereits.");
}
isFileTransferring = true;
uploadState.active = true;
uploadState.credits = 0; // Warten auf das initiale ACK
uploadState.onCreditsAdded = null;
uploadState.onSuccess = null;
uploadState.onError = null;
try {
const pathBytes = new TextEncoder().encode(remotePath);
const reqBuffer = new ArrayBuffer(4 + 1 + 4 + pathBytes.length); // Header(3) + DataType(1) + Size(4) + Path
const reqView = new DataView(reqBuffer);
reqView.setUint8(0, FRAME.REQUEST);
reqView.setUint16(1, 1 + 4 + pathBytes.length, true);
reqView.setUint8(3, DATA.FILE_PUT);
reqView.setUint32(4, fileBlob.size, true);
new Uint8Array(reqBuffer).set(pathBytes, 8);
// UI Statistiken initialisieren
const startTime = performance.now();
transferStats.update(s => ({
...s,
bytesTotal: fileBlob.size,
bytesDone: 0,
currentFileName: fileNameForUI,
fileStartTime: startTime,
bulkStartTime: s.bulkStartTime === 0 ? startTime : s.bulkStartTime
}));
await sendFrame(reqBuffer);
// Chunking Loop
const maxChunkSize = get(protocolInfo)?.maxChunkSize || 240;
const fileData = new Uint8Array(await fileBlob.arrayBuffer());
let offset = 0;
let currentCrc = 0;
let lastUiUpdate = 0;
while (offset < fileData.length) {
// Blockieren, falls keine Credits vorhanden sind
if (uploadState.credits <= 0) {
await new Promise<void>((resolve) => {
uploadState.onCreditsAdded = () => {
if (uploadState.credits > 0) {
uploadState.onCreditsAdded = null;
resolve();
}
};
});
}
if (uploadState.credits > MAX_INFLIGHT) {
uploadState.credits = MAX_INFLIGHT;
}
const chunkLen = Math.min(maxChunkSize, fileData.length - offset);
const chunkData = fileData.subarray(offset, offset + chunkLen);
// CRC32 fortlaufend berechnen
currentCrc = crc32(chunkData, currentCrc);
const chunkBuffer = new ArrayBuffer(3 + chunkLen); // Header(3) + Payload
const chunkView = new DataView(chunkBuffer);
chunkView.setUint8(0, FRAME.FILE_CHUNK);
chunkView.setUint16(1, chunkLen, true);
new Uint8Array(chunkBuffer).set(chunkData, 3);
await sendFrame(chunkBuffer);
uploadState.credits--;
offset += chunkLen;
// UI gedrosselt updaten (gemäß Settings)
const now = performance.now();
if (now - lastUiUpdate > SETTINGS.ui.transferUpdateIntervalMs) {
transferStats.update(s => ({
...s,
bytesDone: offset,
overallDone: s.overallDone + (offset - s.bytesDone)
}));
lastUiUpdate = now;
}
}
// Abschließendes UI Update
transferStats.update(s => ({
...s,
bytesDone: fileData.length,
overallDone: s.overallDone + (fileData.length - s.bytesDone)
}));
// END Frame senden
const endBuffer = new ArrayBuffer(3 + 4);
const endView = new DataView(endBuffer);
endView.setUint8(0, FRAME.FILE_END);
endView.setUint16(1, 4, true);
endView.setUint32(3, currentCrc, true);
await sendFrame(endBuffer);
// Auf Erfolgsmeldung vom Dateisystem warten
await new Promise<void>((resolve, reject) => {
const finalTimeout = setTimeout(() => {
uploadState.onSuccess = null;
uploadState.onError = null;
reject(new Error("Timeout: Keine Bestätigung (SUCCESS) vom Buzzer erhalten."));
}, 5000); // 5 Sekunden warten auf das Dateisystem
uploadState.onSuccess = () => {
clearTimeout(finalTimeout);
resolve();
};
uploadState.onError = (err) => {
clearTimeout(finalTimeout);
reject(err);
};
});
} finally {
// Cleanup
isFileTransferring = false;
uploadState.active = false;
uploadState.onCreditsAdded = null;
uploadState.onSuccess = null;
uploadState.onError = null;
}
}
export async function deleteRemoteFile(fullPath: string): Promise<void> {
const pathBytes = new TextEncoder().encode(fullPath);
const payloadLength = 1 + 1 + pathBytes.length; // data_type(1) + path_length(1) + path
const buffer = new ArrayBuffer(3 + payloadLength);
const view = new DataView(buffer);
const uint8View = new Uint8Array(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, payloadLength, true);
view.setUint8(3, 0x24); // BUZZ_DATA_RM_FILE
view.setUint8(4, pathBytes.length);
uint8View.set(pathBytes, 5);
await sendFrame(buffer);
// Kurze Wartezeit, bis der Parser SUCCESS verarbeitet und der Flash fertig ist
await new Promise(resolve => setTimeout(resolve, 200));
}
export async function renameRemoteFile(oldFullPath: string, newFullPath: string): Promise<void> {
const oldBytes = new TextEncoder().encode(oldFullPath);
const newBytes = new TextEncoder().encode(newFullPath);
const payloadLength = 1 + 1 + 1 + oldBytes.length + newBytes.length; // data_type + 2x len + 2x string
const buffer = new ArrayBuffer(3 + payloadLength);
const view = new DataView(buffer);
const uint8View = new Uint8Array(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, payloadLength, true);
view.setUint8(3, 0x25); // BUZZ_DATA_RENAME_FILE
view.setUint8(4, oldBytes.length);
view.setUint8(5, newBytes.length);
uint8View.set(oldBytes, 6);
uint8View.set(newBytes, 6 + oldBytes.length);
await sendFrame(buffer);
// Kurze Wartezeit, bis der Parser SUCCESS verarbeitet und der Flash fertig ist
await new Promise(resolve => setTimeout(resolve, 200));
}
export async function putTags(tagsBlob: Blob, remotePath: string): Promise<void> {
if (isFileTransferring) {
throw new Error("Ein Dateitransfer läuft bereits.");
}
isFileTransferring = true;
uploadState.active = true;
uploadState.credits = 0;
uploadState.onCreditsAdded = null;
uploadState.onSuccess = null;
uploadState.onError = null;
try {
const pathBytes = new TextEncoder().encode(remotePath);
const reqBuffer = new ArrayBuffer(4 + 1 + 4 + pathBytes.length);
const reqView = new DataView(reqBuffer);
reqView.setUint8(0, FRAME.REQUEST);
reqView.setUint16(1, 1 + 4 + pathBytes.length, true);
reqView.setUint8(3, DATA.TAGS_PUT);
reqView.setUint32(4, tagsBlob.size, true);
new Uint8Array(reqBuffer).set(pathBytes, 8);
await sendFrame(reqBuffer);
const maxChunkSize = get(protocolInfo)?.maxChunkSize || 240;
const tagsData = new Uint8Array(await tagsBlob.arrayBuffer());
let offset = 0;
let currentCrc = 0;
while (offset < tagsData.length) {
if (uploadState.credits <= 0) {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
uploadState.onCreditsAdded = null;
reject(new Error("Timeout beim Senden der Tags."));
}, 5000);
uploadState.onCreditsAdded = () => {
if (uploadState.credits > 0) {
clearTimeout(timeout);
uploadState.onCreditsAdded = null;
resolve();
}
};
});
}
const chunkLen = Math.min(maxChunkSize, tagsData.length - offset);
const chunkData = tagsData.subarray(offset, offset + chunkLen);
currentCrc = crc32(chunkData, currentCrc);
const chunkBuffer = new ArrayBuffer(3 + chunkLen);
const chunkView = new DataView(chunkBuffer);
chunkView.setUint8(0, FRAME.FILE_CHUNK);
chunkView.setUint16(1, chunkLen, true);
new Uint8Array(chunkBuffer).set(chunkData, 3);
await sendFrame(chunkBuffer);
uploadState.credits--;
offset += chunkLen;
}
const endBuffer = new ArrayBuffer(3 + 4);
const endView = new DataView(endBuffer);
endView.setUint8(0, FRAME.FILE_END);
endView.setUint16(1, 4, true);
endView.setUint32(3, currentCrc, true);
await sendFrame(endBuffer);
await new Promise<void>((resolve, reject) => {
const finalTimeout = setTimeout(() => {
uploadState.onSuccess = null;
uploadState.onError = null;
reject(new Error("Timeout: Keine Bestätigung (SUCCESS) vom Buzzer erhalten."));
}, 5000); // 5 Sekunden warten auf das Dateisystem
uploadState.onSuccess = () => {
clearTimeout(finalTimeout);
resolve();
};
uploadState.onError = (err) => {
clearTimeout(finalTimeout);
reject(err);
};
});
} finally {
isFileTransferring = false;
uploadState.active = false;
uploadState.onCreditsAdded = null;
uploadState.onSuccess = null;
uploadState.onError = null;
}
}

View File

@@ -26,4 +26,32 @@ export interface BuzzerFile {
tagsLoaded: boolean;
sysTags: SystemTags;
metaTags: MetadataTags;
selected: boolean;
}
export enum SyncState {
UNKNOWN = "UNKNOWN", // Amber Question (Keine CRC32)
SINGLE_SIDED = "SINGLE_SIDED", // Green Circle (Nur lokal oder nur remote)
SYNCED = "SYNCED", // Green CheckCircle (Beidseitig, identisch)
CONFLICT = "CONFLICT", // Amber Warning (Beidseitig, abweichender Name/Tags)
DUPLICATE = "DUPLICATE" // Red Warning (Mehrfach gleiche CRC32 auf einer Seite)
}
export interface SyncStatus {
state: SyncState;
linkedFiles: string[]; // Referenzen auf die Dateinamen der Gegenseite (für Tooltips)
}
export interface AudioProcessingOptions {
preset: 'normal' | 'broadcast' | 'custom';
lowCut: boolean;
lowCutFreq: number;
compress: boolean;
compressorThreshold: number;
compressorRatio: number;
compressorKnee: number;
compressorAttack: number;
compressorRelease: number;
normalize: boolean;
normalizeTargetDb: number;
}

View File

@@ -1,41 +1,14 @@
---
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";
---
<!-- index.astro -->
<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>
<AppGuard client:only="svelte">
<Header client:only="svelte" />
<MainGrid client:only="svelte" />
</AppGuard>
</MainLayout>

View File

@@ -0,0 +1,77 @@
/* 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 first:mt-0 mt-5 sm:mt-0 border-t first:border-t-0 sm:border-t-0 border-border-card;
@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-tighter;
}
/* .card-body {
@apply;
} */
.buzzer-card .icon {
@apply w-7 h-7 p-1;
}
.buzzer-card .list-menu-icon {
@apply w-8 h-8 p-2;
}
.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];
}
}

View File

@@ -1,8 +0,0 @@
@import "tailwindcss";
@theme {
--color-primary: var(--color-slate-700);
--color-surface: var(--color-slate-50);
--shadow-top: 0 -2px 3px -1px color-mix(in srgb, var(--color-slate-500), transparent 70%);
--shadow-bottom: 0 2px 3px -1px color-mix(in srgb, var(--color-slate-500), transparent 70%);
}