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}) 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) 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 { aliases {
qspi-flash = &mx25r64; 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(fs_mgmt)
add_subdirectory(ble_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 "fs_mgmt/Kconfig"
rsource "ble_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 menuconfig BLE_MGMT
bool "Bluetooth Management" bool "Bluetooth Management"
default n
select BT select BT
select BT_PERIPHERAL select BT_PERIPHERAL
select BT_LOG_LEVEL_WARN select BT_LOG_LEVEL_WARN
@@ -8,6 +9,12 @@ menuconfig BLE_MGMT
Library for initializing and managing Bluetooth functionality. Library for initializing and managing Bluetooth functionality.
if BLE_MGMT if BLE_MGMT
config BLE_MGMT_TX_QUEUE_DEPTH
int "BLE TX queue depth"
default 32
help
Number of notification payloads that can be queued in the BLE transport.
config BLE_MGMT_DEFAULT_DEVICE_NAME config BLE_MGMT_DEFAULT_DEVICE_NAME
string "Default Bluetooth Device Name" string "Default Bluetooth Device Name"
default "Edis Buzzer" default "Edis Buzzer"
@@ -22,39 +29,61 @@ if BLE_MGMT
help help
Maximal advertising interval. 160 equals to 100ms. Maximal advertising interval. 160 equals to 100ms.
# 1. MTU und Data Length (Maximale Paketgrößen) # Airtime
config BT_L2CAP_TX_MTU config BT_CTLR_SDC_MAX_CONN_EVENT_LEN_DEFAULT
default 247 default 4000000
# MTU Setup
config BT_BUF_ACL_RX_SIZE config BT_BUF_ACL_RX_SIZE
default 251 default 502
config BT_BUF_ACL_TX_SIZE config BT_BUF_ACL_TX_SIZE
default 251 default 502
config BT_L2CAP_TX_MTU
default 498
config BT_CTLR_DATA_LENGTH_MAX config BT_CTLR_DATA_LENGTH_MAX
default 251 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 config BT_USER_DATA_LEN_UPDATE
default y default y
# 2. Physical Layer (Erlaubt 2M PHY) # Automatic updates
config BT_USER_PHY_UPDATE config BT_AUTO_PHY_UPDATE
default y
config BT_AUTO_DATA_LEN_UPDATE
default y
config BT_GAP_AUTO_UPDATE_CONN_PARAMS
default y default y
# 3. Flow-Control und Queues (High Throughput, Host + SDC Controller synchronisiert) # Preferred defaults
config BT_HCI_ACL_FLOW_CONTROL config BT_PERIPHERAL_PREF_MIN_INT
default y default 6
config BT_BUF_EVT_RX_COUNT config BT_PERIPHERAL_PREF_MAX_INT
default 22 default 40
config BT_BUF_ACL_TX_COUNT config BT_PERIPHERAL_PREF_LATENCY
default 20 default 0
config BT_L2CAP_TX_BUF_COUNT config BT_PERIPHERAL_PREF_TIMEOUT
default 20 default 400
config BT_CONN_TX_MAX
default 20
# 4. SDC Controller Buffering (an Host-Tiefen angeglichen) # Connections
config BT_CTLR_SDC_TX_PACKET_COUNT config BT_MAX_CONN
default 20 default 2
config BT_CTLR_SDC_RX_PACKET_COUNT
default 20
module = BLE_MGMT module = BLE_MGMT
module-str = ble_mgmt module-str = ble_mgmt

View File

@@ -5,6 +5,8 @@
#include <zephyr/bluetooth/gatt.h> #include <zephyr/bluetooth/gatt.h>
#include <zephyr/logging/log.h> #include <zephyr/logging/log.h>
#include <zephyr/bluetooth/gatt.h> #include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/conn.h>
#include <string.h> #include <string.h>
#include "ble_mgmt.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 ble_mgmt_rx_cb_t app_rx_cb = NULL;
static bool notify_enabled = false; static bool notify_enabled = false;
static uint16_t current_tx_mtu = 23; static uint16_t current_tx_mtu = 23;
static uint16_t current_rx_mtu = 23;
#define MAX_ADV_NAME_LEN 29 #define MAX_ADV_NAME_LEN 29
static char current_device_name[MAX_ADV_NAME_LEN + 1]; 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); LOG_INF("MTU exchanged: TX %u bytes, RX %u bytes", tx, rx);
current_tx_mtu = tx; current_tx_mtu = tx;
current_rx_mtu = rx;
} }
static ssize_t rx_cb(struct bt_conn *conn, const struct bt_gatt_attr *attr, static ssize_t rx_cb(struct bt_conn *conn, const struct bt_gatt_attr *attr,
@@ -60,7 +65,8 @@ static ssize_t rx_cb(struct bt_conn *conn, const struct bt_gatt_attr *attr,
LOG_DBG("Received %u bytes", len); LOG_DBG("Received %u bytes", len);
LOG_HEXDUMP_DBG(buf, len, "Data:"); LOG_HEXDUMP_DBG(buf, len, "Data:");
if (app_rx_cb) { if (app_rx_cb)
{
app_rx_cb((const uint8_t *)buf, len); app_rx_cb((const uint8_t *)buf, len);
} }
return len; return len;
@@ -69,7 +75,7 @@ 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) static void tx_ccc_cfg_changed(const struct bt_gatt_attr *attr, uint16_t value)
{ {
notify_enabled = (value == BT_GATT_CCC_NOTIFY); 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_SERVICE_DEFINE(ble_mgmt_svc,
@@ -78,16 +84,16 @@ BT_GATT_SERVICE_DEFINE(ble_mgmt_svc,
BT_GATT_PERM_WRITE, NULL, rx_cb, NULL), BT_GATT_PERM_WRITE, NULL, rx_cb, NULL),
BT_GATT_CHARACTERISTIC(&buzz_tx_uuid.uuid, BT_GATT_CHRC_NOTIFY, BT_GATT_CHARACTERISTIC(&buzz_tx_uuid.uuid, BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_NONE, NULL, NULL, NULL), BT_GATT_PERM_NONE, NULL, NULL, NULL),
BT_GATT_CCC(tx_ccc_cfg_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE) BT_GATT_CCC(tx_ccc_cfg_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE));
);
uint16_t ble_mgmt_get_max_payload(void) uint16_t ble_mgmt_get_max_payload(void)
{ {
/* Kappe die verhandelte MTU auf die hart konfigurierte Zephyr-Puffergrenze */ /* 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 #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; effective_mtu = CONFIG_BT_L2CAP_TX_MTU;
} }
#endif #endif
@@ -98,19 +104,23 @@ uint16_t ble_mgmt_get_max_payload(void)
int ble_mgmt_send(const uint8_t *data, uint16_t len) int ble_mgmt_send(const uint8_t *data, uint16_t len)
{ {
if (!notify_enabled) { if (!notify_enabled)
{
return -EACCES; return -EACCES;
} }
int rc; int rc;
do { do
{
rc = bt_gatt_notify(NULL, &ble_mgmt_svc.attrs[4], data, len); 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 k_sleep(K_MSEC(5)); // Thread pausieren, bis TX-Buffer frei wird
} }
} while (rc == -ENOMEM); } while (rc == -ENOMEM);
if (rc) { if (rc)
{
LOG_ERR("Failed to send notification (err %d)", rc); LOG_ERR("Failed to send notification (err %d)", rc);
return rc; return rc;
} }
@@ -120,7 +130,8 @@ int ble_mgmt_send(const uint8_t *data, uint16_t len)
/* Interne Hilfsfunktion zur Zuweisung des Namens */ /* Interne Hilfsfunktion zur Zuweisung des Namens */
static void set_device_name(const char *name) static void set_device_name(const char *name)
{ {
if (!name) { if (!name)
{
return; return;
} }
@@ -144,7 +155,8 @@ int ble_mgmt_update_adv_name(const char *new_name)
set_device_name(new_name); set_device_name(new_name);
rc = bt_le_adv_start(&adv_param, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd)); 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); LOG_ERR("Advertising failed to restart after name update (err %d)", rc);
return rc; return rc;
} }
@@ -153,40 +165,10 @@ int ble_mgmt_update_adv_name(const char *new_name)
return 0; 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) static void connected(struct bt_conn *conn, uint8_t err)
{ {
if (err) { if (err)
{
LOG_ERR("Connection failed (err 0x%02x)", err); LOG_ERR("Connection failed (err 0x%02x)", err);
return; return;
} }
@@ -195,50 +177,38 @@ static void connected(struct bt_conn *conn, uint8_t err)
struct bt_conn_info info; struct bt_conn_info info;
int rc = bt_conn_get_info(conn, &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)); bt_addr_le_to_str(info.le.dst, addr_str, sizeof(addr_str));
LOG_INF("Connected to %s", addr_str); LOG_INF("Connected to %s", addr_str);
LOG_INF("Role: %s", info.role == BT_CONN_ROLE_CENTRAL ? "Central" : "Peripheral");
/* Nur noch die Rolle ausgeben, da Timing-Parameter hier deprecated sind */ }
LOG_DBG("Role: %s", info.role == BT_CONN_ROLE_CENTRAL ? "Central" : "Peripheral"); else
} else { {
LOG_INF("Connected (info retrieval failed)"); 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) 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 */ /* Startet Advertising mit dem global definierten Setup neu */
int rc = bt_le_adv_start(&adv_param, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd)); 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); 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) 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" : 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";
(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 *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); 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_param_updated = le_param_updated,
.le_phy_updated = le_phy_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 menuconfig BUZZ_PROTO
bool "Buzzer Protocol" bool "Buzzer Protocol"
default y
select CRC select CRC
help help
Library for initializing and managing the buzzer protocol. Library for initializing and managing the buzzer protocol.
config BUZZ_PROTO_SLAB_SIZE config BUZZ_PROTO_SLAB_SIZE
int "Slab Size" int "Slab Size"
default 256 default 512
help help
Size of the memory slabs used for message buffers. Must be large enough to hold the largest expected message. 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 config BUZZ_PROTO_MSGQ_SIZE
int "Message Queue Size" int "Message Queue Size"
default 16 default 64
help help
Number of messages that can be queued for processing. Adjust based on expected message burstiness. 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_RESPONSE = 0x10,
BUZZ_FRAME_ACK = 0x11, BUZZ_FRAME_ACK = 0x11,
BUZZ_FRAME_ERROR = 0x12, BUZZ_FRAME_ERROR = 0x12,
BUZZ_FRAME_SUCCESS = 0x13,
BUZZ_FRAME_FILE_START = 0x20, BUZZ_FRAME_FILE_START = 0x20,
BUZZ_FRAME_FILE_CHUNK = 0x21, BUZZ_FRAME_FILE_CHUNK = 0x21,
@@ -34,9 +35,16 @@ enum buzz_data_type
BUZZ_DATA_PROTO_INFO = 0x01, BUZZ_DATA_PROTO_INFO = 0x01,
BUZZ_DATA_DEVICE_INFO = 0x02, BUZZ_DATA_DEVICE_INFO = 0x02,
BUZZ_DATA_FS_INFO = 0x03, BUZZ_DATA_FS_INFO = 0x03,
BUZZ_DATA_FW_INFO = 0x04,
BUZZ_DATA_FILE_GET = 0x20, BUZZ_DATA_FILE_GET = 0x20,
BUZZ_DATA_FILE_PUT = 0x21, 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, 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 */ 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) */ /* Payload für eine Standard-Anfrage (Request) */
struct __attribute__((packed)) buzz_request_payload struct __attribute__((packed)) buzz_request_payload
{ {
@@ -76,6 +89,17 @@ struct __attribute__((packed)) buzz_resp_proto_version
uint16_t max_chunk_size; /* Little Endian */ 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 */ /* Payload für die Dateisystem-Informationen */
struct __attribute__((packed)) buzz_resp_fs_info struct __attribute__((packed)) buzz_resp_fs_info
{ {
@@ -88,6 +112,33 @@ struct __attribute__((packed)) buzz_resp_fs_info
uint8_t data[]; /* Pfadnamen */ 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) */ /* Payload für das Credit-System (ACK) */
struct __attribute__((packed)) buzz_ack_payload 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 */ /* Übergabe eines empfangenen Frames an den Protokoll-Thread */
int buzz_proto_submit_frame(struct buzz_frame_msg *msg); 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 */ #endif /* BUZZ_PROTO_H */

View File

@@ -8,6 +8,7 @@
#include "buzz_proto.h" #include "buzz_proto.h"
#include "fs_mgmt.h" #include "fs_mgmt.h"
#include "fw_mgmt.h"
LOG_MODULE_REGISTER(buzz_proto, CONFIG_BUZZ_PROTO_LOG_LEVEL); 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); 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 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) int buzz_proto_buf_alloc(uint8_t **buf)
{ {
@@ -74,7 +75,7 @@ void buzz_proto_buf_free(uint8_t **buf)
{ {
if (buf && *buf) if (buf && *buf)
{ {
k_mem_slab_free(&buzz_proto_slabs, (void **)*buf); k_mem_slab_free(&buzz_proto_slabs, *buf);
*buf = NULL; *buf = NULL;
} }
} }
@@ -84,6 +85,8 @@ int buzz_proto_submit_frame(struct buzz_frame_msg *msg)
return k_msgq_put(&buzz_proto_msgq, msg, K_NO_WAIT); return k_msgq_put(&buzz_proto_msgq, msg, K_NO_WAIT);
} }
static void send_stream_error(buzz_transport_reply_fn reply_cb, uint16_t error_code);
static void send_error_frame(struct buzz_frame_msg *msg, uint16_t error_code) static void send_error_frame(struct buzz_frame_msg *msg, uint16_t error_code)
{ {
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr; struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
@@ -99,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) static void handle_proto_version_request(struct buzz_frame_msg *msg)
{ {
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr; struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
hdr->frame_type = BUZZ_FRAME_RESPONSE; hdr->frame_type = BUZZ_FRAME_RESPONSE;
struct buzz_resp_proto_version *resp_data = (struct buzz_resp_proto_version *)(msg->data_ptr + sizeof(*hdr)); 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->data_type = BUZZ_DATA_PROTO_INFO;
resp_data->version = sys_cpu_to_le16(BUZZ_PROTO_VERSION); 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)); 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); uint16_t total_len = sizeof(struct buzz_proto_header) + sizeof(struct buzz_resp_proto_version);
if (msg->reply_cb) 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) void handle_fs_info_request(struct buzz_frame_msg *msg)
{ {
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr; struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
@@ -147,7 +246,7 @@ void handle_fs_info_request(struct buzz_frame_msg *msg)
resp_data->data_type = BUZZ_DATA_FS_INFO; resp_data->data_type = BUZZ_DATA_FS_INFO;
resp_data->total_size = sys_cpu_to_le32(total_size); resp_data->total_size = sys_cpu_to_le32(total_size);
resp_data->free_size = sys_cpu_to_le32(free_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->sys_path_length = strlen(FS_SYSTEM_PATH);
resp_data->audio_path_length = strlen(FS_AUDIO_PATH); resp_data->audio_path_length = strlen(FS_AUDIO_PATH);
memcpy(resp_data->data, FS_SYSTEM_PATH, resp_data->sys_path_length); 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) static void handle_ls_request(struct buzz_frame_msg *msg)
{ {
struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr; struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
@@ -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; struct buzz_proto_header *hdr = (struct buzz_proto_header *)msg->data_ptr;
uint16_t payload_len = sys_le16_to_cpu(hdr->payload_length); 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); memcpy(src_path, msg->data_ptr + sizeof(*hdr) + 1, path_len);
src_path[path_len] = '\0'; src_path[path_len] = '\0';
// 1. Datei-Größe ermitteln
struct fs_dirent entry; struct fs_dirent entry;
if (fs_mgmt_pm_stat(src_path, &entry) != 0) 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; return;
} }
// 2. Datei öffnen
fs_file_t_init(&get_file_state.file); fs_file_t_init(&get_file_state.file);
int rc = fs_mgmt_pm_open(&get_file_state.file, src_path, FS_O_READ); int rc = fs_mgmt_pm_open(&get_file_state.file, src_path, FS_O_READ);
if (rc != 0) if (rc != 0)
@@ -256,7 +383,31 @@ static void handle_file_get_request(struct buzz_frame_msg *msg)
return; 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; current_stream = STREAM_FILE_GET;
get_file_state.active = true; get_file_state.active = true;
get_file_state.credits = 0; 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); 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->frame_type = BUZZ_FRAME_FILE_START;
hdr->payload_length = sys_cpu_to_le16(sizeof(struct buzz_file_start_payload)); 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)); 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) 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); fs_mgmt_pm_close(&get_file_state.file);
get_file_state.active = false; get_file_state.active = false;
current_stream = STREAM_IDLE; current_stream = STREAM_IDLE;
k_sleep(K_MSEC(10));
send_error_frame(msg, EIO);
return; return;
} }
} }
} }
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) static void process_file_get_stream(void)
{ {
uint8_t *buf = NULL; uint8_t *buf = NULL;
@@ -363,16 +622,20 @@ static void process_file_get_stream(void)
return; return;
} }
// Daten gelesen -> CRC aktualisieren und Chunk senden // Chunk senden; CRC/Offset erst nach erfolgreichem Enqueue aktualisieren
get_file_state.crc32 = crc32_ieee_update(get_file_state.crc32, payload_ptr, read_len);
get_file_state.offset += read_len;
hdr->frame_type = BUZZ_FRAME_FILE_CHUNK; hdr->frame_type = BUZZ_FRAME_FILE_CHUNK;
hdr->payload_length = sys_cpu_to_le16(read_len); hdr->payload_length = sys_cpu_to_le16(read_len);
if (get_file_state.reply_cb) if (get_file_state.reply_cb)
{ {
int send_rc = get_file_state.reply_cb(buf, sizeof(*hdr) + read_len); int send_rc = get_file_state.reply_cb(buf, sizeof(*hdr) + read_len);
if (send_rc == -ENOMEM)
{
// BLE TX queue voll - Datei zurücksetzen, nächster Zyklus wiederholt den Chunk
fs_seek(&get_file_state.file, -(off_t)read_len, FS_SEEK_CUR);
buzz_proto_buf_free(&buf);
return;
}
if (send_rc) if (send_rc)
{ {
LOG_ERR("Failed to send FILE_CHUNK (err %d)", send_rc); LOG_ERR("Failed to send FILE_CHUNK (err %d)", send_rc);
@@ -380,10 +643,15 @@ static void process_file_get_stream(void)
get_file_state.active = false; get_file_state.active = false;
current_stream = STREAM_IDLE; current_stream = STREAM_IDLE;
buzz_proto_buf_free(&buf); buzz_proto_buf_free(&buf);
k_sleep(K_MSEC(10));
send_stream_error(get_file_state.reply_cb, EIO);
return; return;
} }
} }
// Erfolgreich eingereiht: State aktualisieren
get_file_state.crc32 = crc32_ieee_update(get_file_state.crc32, payload_ptr, read_len);
get_file_state.offset += read_len;
get_file_state.credits--; get_file_state.credits--;
get_file_state.retry_counter = 0; get_file_state.retry_counter = 0;
buzz_proto_buf_free(&buf); buzz_proto_buf_free(&buf);
@@ -409,18 +677,116 @@ static void handle_request(struct buzz_frame_msg *msg)
LOG_DBG("Received Proto Version Request"); LOG_DBG("Received Proto Version Request");
handle_proto_version_request(msg); handle_proto_version_request(msg);
break; break;
case BUZZ_DATA_DEVICE_INFO:
LOG_DBG("Received Device Info Request");
handle_device_info_request(msg);
break;
case BUZZ_DATA_FS_INFO: case BUZZ_DATA_FS_INFO:
LOG_DBG("Received FS Info Request"); LOG_DBG("Received FS Info Request");
handle_fs_info_request(msg); handle_fs_info_request(msg);
break; break;
case BUZZ_DATA_LS: case BUZZ_DATA_LS:
LOG_DBG("Received LS Request"); LOG_DBG("Received LS Request");
handle_ls_request(msg); handle_ls_request(msg);
break; break;
case BUZZ_DATA_FW_INFO:
LOG_DBG("Received FW Info Request");
handle_fw_info_request(msg);
break;
case BUZZ_DATA_FILE_GET: case BUZZ_DATA_FILE_GET:
LOG_DBG("Received FILE_GET Request"); LOG_DBG("Received FILE_GET Request");
handle_file_get_request(msg); handle_file_get_request(msg, false);
break; 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: default:
LOG_WRN("Unknown request data_type: 0x%02x", req_data->data_type); LOG_WRN("Unknown request data_type: 0x%02x", req_data->data_type);
send_error_frame(msg, EINVAL); send_error_frame(msg, EINVAL);
@@ -555,7 +921,64 @@ static void buzz_proto_thread_fn(void *p1, void *p2, void *p3)
break; break;
case BUZZ_FRAME_FILE_CHUNK: 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); buzz_proto_buf_free(&msg.data_ptr);
break; break;
@@ -590,6 +1013,7 @@ static void buzz_proto_thread_fn(void *p1, void *p2, void *p3)
{ {
LOG_WRN("LS timeout waiting for ACK"); LOG_WRN("LS timeout waiting for ACK");
fs_mgmt_pm_closedir(&ls_state.dir); fs_mgmt_pm_closedir(&ls_state.dir);
send_stream_error(ls_state.reply_cb, ETIMEDOUT);
ls_state.active = false; ls_state.active = false;
current_stream = STREAM_IDLE; current_stream = STREAM_IDLE;
} }
@@ -608,6 +1032,7 @@ static void buzz_proto_thread_fn(void *p1, void *p2, void *p3)
{ {
LOG_WRN("FILE_GET timeout waiting for ACK"); LOG_WRN("FILE_GET timeout waiting for ACK");
fs_close(&get_file_state.file); fs_close(&get_file_state.file);
send_stream_error(get_file_state.reply_cb, ETIMEDOUT);
get_file_state.active = false; get_file_state.active = false;
current_stream = STREAM_IDLE; current_stream = STREAM_IDLE;
} }

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 menuconfig FS_MGMT
bool "File System Management" bool "File System Management"
default y
select FLASH select FLASH
select FLASH_MAP select FLASH_MAP
select FILE_SYSTEM select FILE_SYSTEM
@@ -11,12 +12,41 @@ menuconfig FS_MGMT
Library for initializing and managing the file system. Library for initializing and managing the file system.
if FS_MGMT 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 config FS_MGMT_MOUNT_POINT
string "Littlefs Mount Point" string "Littlefs Mount Point"
default "/lfs" default "/lfs"
help help
Set the mount point for the Littlefs file system. Default is "/lfs". 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 if SOC_SERIES_NRF52X
config PM_PARTITION_REGION_LITTLEFS_EXTERNAL config PM_PARTITION_REGION_LITTLEFS_EXTERNAL
default y default y

View File

@@ -2,25 +2,52 @@
#define FS_MGMT_H #define FS_MGMT_H
#include <zephyr/fs/fs.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 CONFIG_FS_MGMT_AUDIO_SUBDIR
#define FS_AUDIO_PATH CONFIG_FS_MGMT_MOUNT_POINT "/a" #define FS_SYSTEM_PATH CONFIG_FS_MGMT_MOUNT_POINT CONFIG_FS_MGMT_SYSTEM_SUBDIR
#define FS_SYSTEM_PATH CONFIG_FS_MGMT_MOUNT_POINT "/sys"
#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 * @brief Structure representing a write message for the FS write thread
// */ */
// int fs_pm_flash_suspend(void); struct fs_write_msg
{
// /** enum fs_write_op op;
// * @brief Resumes the QSPI flash from deep sleep mode 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 */
// int fs_pm_flash_resume(void); 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 * @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); 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 */ #endif /* FS_MGMT_H */

View File

@@ -1,9 +1,13 @@
#include <zephyr/fs/littlefs.h> #include <zephyr/fs/littlefs.h>
#include <zephyr/fs/fs.h> #include <zephyr/fs/fs.h>
#include <zephyr/sys/byteorder.h>
#include <zephyr/logging/log.h> #include <zephyr/logging/log.h>
#include <zephyr/pm/device.h> #include <zephyr/pm/device.h>
#include <zephyr/sys/crc.h>
#include "fs_mgmt.h" #include "fs_mgmt.h"
#include "buzz_proto.h"
#include "event_mgmt.h"
LOG_MODULE_REGISTER(fs_mgmt, CONFIG_FS_MGMT_LOG_LEVEL); 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) #define QSPI_FLASH_NODE DT_ALIAS(qspi_flash)
static const struct device *flash_dev = DEVICE_DT_GET(QSPI_FLASH_NODE); 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 = { static struct fs_mount_t fs_storage_mnt = {
.type = FS_LITTLEFS, .type = FS_LITTLEFS,
.fs_data = &fs_storage_data, .fs_data = &fs_storage_data,
@@ -23,6 +30,37 @@ static struct fs_mount_t fs_storage_mnt = {
static int open_count = 0; static int open_count = 0;
static struct k_mutex flash_pm_lock; 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 * @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 * Decrements the open count and suspends the flash if no more users are active
@@ -349,11 +387,12 @@ int fs_mgmt_pm_mkdir_recursive(char *path)
return rc; return rc;
} }
int fs_mgmt_init(void) static int fs_mgmt_init(void)
{ {
k_mutex_init(&flash_pm_lock); k_mutex_init(&flash_pm_lock);
if (!device_is_ready(flash_dev)) { if (!device_is_ready(flash_dev))
{
LOG_ERR("Flash device not ready!"); LOG_ERR("Flash device not ready!");
return -ENODEV; return -ENODEV;
} }
@@ -367,7 +406,349 @@ int fs_mgmt_init(void)
LOG_ERR("Error mounting filesystem: %d", rc); LOG_ERR("Error mounting filesystem: %d", rc);
return rc; return rc;
} }
fs_mgmt_pm_flash_suspend(); fs_mgmt_pm_flash_suspend();
LOG_DBG("Filesystem mounted successfully"); LOG_DBG("Filesystem mounted successfully");
event_mgmt_set_event(EVENT_MGMT_FS_READY);
return 0; 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 ### Logging
CONFIG_LOG=y CONFIG_LOG=y
CONFIG_AUDIO_LOG_LEVEL_DBG=y
### File System
CONFIG_FS_MGMT=y
CONFIG_FS_MGMT_LOG_LEVEL_DBG=y
CONFIG_FS_LOG_LEVEL_WRN=y
### Bluetooth ### Bluetooth
CONFIG_BLE_MGMT=y CONFIG_BLE_MGMT=y
# CONFIG_BLE_MGMT_LOG_LEVEL_DBG=y
# Explicit throughput tuning in project config (wins over competing defaults) ### Error handling
CONFIG_BT_HCI_ACL_FLOW_CONTROL=y CONFIG_HW_STACK_PROTECTION=y
CONFIG_BT_BUF_CMD_TX_COUNT=24 CONFIG_RESET_ON_FATAL_ERROR=y
CONFIG_BT_BUF_ACL_TX_COUNT=20
CONFIG_BT_L2CAP_TX_BUF_COUNT=20
CONFIG_BT_CONN_TX_MAX=20
CONFIG_BT_CTLR_SDC_TX_PACKET_COUNT=20
CONFIG_BT_CTLR_SDC_RX_PACKET_COUNT=20
# Advertising 500ms - 1s ### Power management
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
CONFIG_PM_DEVICE=y CONFIG_PM_DEVICE=y
## Shell ### Stack
# CONFIG_SHELL=y CONFIG_MAIN_STACK_SIZE=2048
# CONFIG_FILE_SYSTEM_SHELL=y 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 <string.h>
#include "fs_mgmt.h" #include "fs_mgmt.h"
#include "ble_mgmt.h"
#include "buzz_proto.h" #include "buzz_proto.h"
#include "fw_mgmt.h"
#include "audio.h"
LOG_MODULE_REGISTER(main); 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) void ble_rx_cb(const uint8_t *data, uint16_t len)
{ {
uint8_t *buf; uint8_t *buf;
/* 1. Länge prüfen (darf SLAB_BLOCK_SIZE = 256 nicht überschreiten) */ /* 1. Länge prüfen (darf SLAB_BLOCK_SIZE = 256 nicht überschreiten) */
if (len > 256) { if (len > CONFIG_BUZZ_PROTO_SLAB_SIZE) {
LOG_ERR("Received data too large for proto buf (%u bytes)", len); LOG_ERR("Received data too large for proto buf (%u > %u)", len, CONFIG_BUZZ_PROTO_SLAB_SIZE);
return; 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 */ buzz_proto_buf_free(&buf); /* Speicher bei Fehler sofort wieder freigeben */
} }
} }
#endif
int main(void) int main(void)
{ {
LOG_INF("Starting app on %s (SOC: %s)", CONFIG_BOARD, CONFIG_SOC); #if IS_ENABLED(CONFIG_BLE_MGMT)
int rc;
rc = fs_mgmt_init();
if (rc < 0) {
LOG_ERR("Failed to initialize file system management: %d", rc);
return rc;
}
/* BLE-Subsystem initialisieren und RX-Callback registrieren */ /* 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) { if (rc < 0) {
LOG_ERR("Failed to initialize BLE management: %d", rc); LOG_ERR("Failed to initialize BLE management: %d", rc);
return rc; return rc;
} }
#endif
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);
LOG_INF("Init complete");
k_sleep(K_FOREVER); 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) # Buzzer Protocol (Wire Specification)
## 1. Zweck und Geltungsbereich Stand: 2026-03-18
Das Buzzer Protocol definiert ein transportunabhaengiges, binaeres Frame-Format fuer die Kommunikation zwischen Host und Device. Quelle: aktueller Implementierungsstand aus Firmware (`buzz_proto`, `fs_mgmt`, `ble_mgmt`) und Web-Client (`transport`, `parser`).
Unterstuetzte Transporte sind aktuell BLE und USB CDC ACM/UART.
Das Protokoll spezifiziert: ## Ziel und Scope
- Frame-Struktur (Header + Payload)
- Frametypen
- Datentypen fuer Request/Response
- Semantik fuer Stream-Transfers (Verzeichnisliste, Datei, Firmware)
## 2. Transport- und Codierungsregeln Das Buzzer Protocol ist ein binäres Frame-Protokoll für Host <-> Device Kommunikation.
- 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.
## 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 ```c
uint8_t frame_type uint8_t frame_type;
uint16_t payload_length // Little Endian uint16_t payload_length; // LE
``` ```
### 3.2 Gesamtframe
``` ### Paketstruktur
+------------------+-------------------------+
| Header (3 Byte) | Payload (optional) | ```mermaid
| frame_type (1B) | payload_length Byte | ---
| payload_len (2B) | | 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 Firmware-Buffer ist slab-basiert (`CONFIG_BUZZ_PROTO_SLAB_SIZE`).
| Wert | Name | Richtung | Beschreibung | Der effektive Chunk für Transfers wird zusätzlich durch den Transport limitiert. Bei Bluetooth sind das zum Beispiel 3 Bytes:
|--------|------------|----------------|---------------------------------------|
| `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 |
### 4.2 Datei-Transfer (reserviert, noch nicht implementiert) `PROTO_INFO.max_chunk_size` wird dynamisch berechnet als:
| Wert | Name |
|--------|--------------|
| `0x20` | `FILE_START` |
| `0x21` | `FILE_CHUNK` |
| `0x22` | `FILE_END` |
### 4.3 Firmware-Transfer (reserviert, noch nicht implementiert) `min(slab_size - 3, transport_max_payload - 3)`
| Wert | Name |
|--------|------------|
| `0x30` | `FW_START` |
| `0x31` | `FW_CHUNK` |
| `0x32` | `FW_END` |
### 4.4 Verzeichnisliste Das Protokoll ist so ausgelegt, dass es mit mindestens 100 Bytes auskommen sollte.
| 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 |
## 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 ```c
uint8_t data_type // Nutzt enum buzz_data_type uint8_t data_type;
// optional: datentypspezifische Parameter // optional daten_typspezifische Parameter
``` ```
Wire-Format: Wire:
```
[0x00][payload_length LE][data_type][optional parameters] ```text
[0x00][payload_len LE][data_type][optional...]
``` ```
### 5.2 Response (`frame_type = 0x10`) ```mermaid
Payload-Mindestformat: ---
```c title: "Generic Requst Structure"
uint8_t data_type // Echo des angefragten data_type ---
// danach: datentypspezifische Response-Daten packet
+8: "Frame type REQUEST: 0x00"
+16: "Payload length (LE)"
+8: "Data type"
+32: "Optional payload (variable length)"
``` ```
Wire-Format: ### `PROTO_INFO` (`0x01`)
```
[0x10][payload_length LE][data_type][response payload]
```
## 6. Datentypen (Request/Response) Request: keine Zusatzdaten.
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
Response-Payload: Response-Payload:
```c ```c
uint8_t data_type; // 0x01 uint8_t data_type; // 0x01
uint16_t version; // Protokollversion (LE) uint16_t version; // LE
uint16_t max_chunk_size; // max. Nutzdaten pro Frame ohne Header (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`) ### `DEVICE_INFO` (`0x02`)
TBD
### 6.3 `FS_INFO` (`0x03`) Request: keine Zusatzdaten.
Request-Parameter: keine
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: Response-Payload:
```c ```c
uint8_t data_type; // 0x03 uint8_t data_type; // 0x03
uint32_t total_size; // Gesamtgroesse Flash in Bytes (LE) uint32_t total_size; // LE
uint32_t free_size; // Freier Speicher in Bytes (LE) uint32_t free_size; // LE
uint8_t max_path_length; // Maximal erlaubte Pfadlaenge uint8_t max_path_length;
uint8_t sys_path_length; // Laenge des System-Pfades (ohne 0-Terminator) uint8_t sys_path_length;
uint8_t audio_path_length; // Laenge des Audio-Pfades (ohne 0-Terminator) uint8_t audio_path_length;
uint8_t data[]; // sys_path gefolgt von audio_path, nicht nullterminiert uint8_t data[]; // sys_path + audio_path ohne Nullterminierung
``` ```
### 6.4 `LS` (`0x40`) — Verzeichnisliste anfordern Im `data` folgen sich System- und Audiopfad ohne Abstand, und ohne 0-Terminierung (`\0`). Beispiel für Systempfad `/lfs/sys`und Audiopfad `/lfs/a`:
Startet einen LS-Stream fuer den angegebenen Pfad.
`/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: Request-Payload:
```c ```c
uint8_t data_type; // 0x40 uint8_t data_type; // 0x40
char path[]; // Pfad ohne 0-Terminator, Laenge ergibt sich aus payload_length - 1 char path[]; // ohne Nullterminierung
``` ```
Wire-Format (Beispiel fuer Pfad `/a`): ### `FILE_GET` (`0x20`) und `TAGS_GET` (`0x22`)
```
[0x00][0x03 0x00][0x40][0x2F 0x61]
```
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 ```c
// Header: uint8_t data_type; // 0x20 oder 0x22
uint8_t frame_type; // 0x11 char path[]; // ohne Nullterminierung
uint16_t payload_length; // 0x0002
// Payload:
uint16_t credits; // Anzahl der Entries, die das Device senden darf (LE)
``` ```
Wire-Format (Beispiel: 64 Credits): Antwort ist ein Stream aus `FILE_START` -> `FILE_CHUNK`* -> `FILE_END`.
```
[0x11][0x02 0x00][0x40 0x00]
```
Semantik: ### `FILE_PUT` (`0x21`) und `TAGS_PUT` (`0x23`)
- Der Host sendet nach Empfang von `LS_START` initial Credits (typisch 64).
- Das Device dekrementiert seinen internen Credit-Zaehler mit jeder gesendeten `LS_ENTRY`. Request-Payload:
- 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.
### 7.2 ERROR (`frame_type = 0x12`) — Device → Host
Format:
```c ```c
// Header: uint8_t data_type; // 0x21 oder 0x23
uint8_t frame_type; // 0x12 uint32_t total_size; // LE
uint16_t payload_length; // 0x0002 char path[]; // ohne Nullterminierung
// Payload:
uint16_t error_code; // Positiver Zephyr-errno-Wert (LE)
``` ```
Wire-Format (Beispiel: ENOENT = 2): Danach sendet der Host:
``` - `FILE_CHUNK` Frames
[0x12][0x02 0x00][0x02 0x00] - 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. ### `RENAME_FILE` (`0x25`)
Ein ERROR-Frame waehrend eines aktiven LS-Streams beendet diesen implizit.
Fehlercode-Tabelle (Zephyr errno, positiver Wert): Request-Payload:
| 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 |
## 8. LS-Stream (Verzeichnisliste) ```c
uint8_t data_type; // 0x25
Der LS-Stream wird durch einen `REQUEST` mit `data_type = 0x40` ausgeloest und laeuft wie folgt ab: uint8_t old_path_length;
uint8_t new_path_length;
``` char paths[]; // old_path + new_path (jeweils ohne Nullterminierung)
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 -----------------|
``` ```
### 8.1 `LS_START` (`0x40`) — Device → Host ## ACK / ERROR / SUCCESS
Signalisiert den Beginn des Streams. Keine Payload.
``` ### ACK (`0x11`)
[0x40][0x00 0x00]
```
### 8.2 `LS_ENTRY` (`0x41`) — Device → Host
Ein Eintrag pro Verzeichniselement.
Payload: Payload:
```c ```c
uint8_t type; // 0x00 = Datei, 0x01 = Verzeichnis (buzz_fs_entry_type) uint16_t credits; // LE
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
``` ```
`type`-Werte: Wichtig: Es gibt zwei Semantiken je nach Richtung.
| Wert | Bedeutung |
|--------|---------------|
| `0x00` | Datei (FILE) |
| `0x01` | Verzeichnis (DIR) |
### 8.3 `LS_END` (`0x42`) — Device → Host - Download (`LS`, `FILE_GET`, `TAGS_GET`):
Signalisiert das Ende des Streams. 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: Payload:
```c ```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 | Code | Name | Bedeutung |
- 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). | `1` | `EPERM` | fehlende Rechte |
- Der Host sollte einen eigenen Watchdog implementieren; empfohlener Timeout: 3 s ohne empfangenen Frame. | `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: Request:
```
```text
00 01 00 01 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 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 03 00 40 2F 61
``` ```
- `00`: `REQUEST`
- `03 00`: `payload_length = 3`
- `40`: `data_type = LS`
- `2F 61`: Pfad `/a`
Antwort (Sequenz): Interpretation:
``` - `00`: REQUEST
40 00 00 // LS_START, keine Payload - `03 00`: Payload 3 Byte
// Host sendet ACK mit Credits - `40`: data_type LS
11 02 00 40 00 // ACK, 64 Credits - `2F 61`: `/a`
// 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) ### Implementierungsnotizen
// ... weitere Eintraege ...
42 04 00 01 00 00 00 // LS_END, total_entries = 1 - 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, "singleAttributePerLine": false,
"overrides": [ "overrides": [
{ {
"files": ["*.svelte", "*.astro"], "files": ["*.svelte", "*.astro", "*.ts", "*.js", "*.tsx", "*.jsx"],
"options": { "options": {
"printWidth": 100 "printWidth": 100
} }

View File

@@ -1,12 +1,15 @@
// @ts-check // @ts-check
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import svelte from '@astrojs/svelte'; import svelte from '@astrojs/svelte';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
const isProd = process.env.NODE_ENV === 'production';
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
base: isProd ? '/buzzer/' : '/',
site: 'https://home.iten.pro',
integrations: [svelte()], integrations: [svelte()],
vite: { 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" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/svelte": "^7.2.5", "@astrojs/svelte": "^8.0.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"astro": "^5.17.1", "astro": "^6.0.3",
"prettier-plugin-svelte": "^3.5.1", "prettier-plugin-svelte": "^3.5.1",
"svelte": "^5.53.7", "svelte": "^5.53.7",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",

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"> <svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><style>
<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" /> #light-icon {
<style> display: inline;
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
} }
</style> #dark-icon {
</svg> 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"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { isInitializing, isBluetoothSupported, isSerialSupported } from "../lib/store"; import { isInitializing, isBluetoothSupported } from "../lib/store";
import { performHardwareCheck, getBrowserName } from "../lib/init"; import { performHardwareCheck } from "../lib/init";
import ToastContainer from "./ToastContainer.svelte"; import ToastContainer from "./ToastContainer.svelte";
import { injectDummyDevices } from "../lib/store";
let browserName = ""; onMount(async () => {
onMount(() => {
browserName = getBrowserName();
performHardwareCheck(); performHardwareCheck();
injectDummyDevices(); // Fügt Dummy-Geräte für Testzwecke hinzu
if ($isBluetoothSupported) {
const { restoreSession } = await import("../lib/bluetooth");
await restoreSession();
}
}); });
</script> </script>
{#if $isInitializing} {#if $isInitializing}
<div class="fixed inset-0 bg-slate-50 flex items-center justify-center z-[100]"> <div class="fixed inset-0 bg-surface flex items-center justify-center z-[100]">
<p class="text-slate-600 font-mono animate-pulse">SYSTEM_CHECK_RUNNING...</p> <p class="text-on-surface font-mono animate-pulse text-base md:text-lg text-center">
</div> Browserkompatibilität wird geprüft...
{:else if !$isBluetoothSupported && !$isSerialSupported}
<div class="min-h-[60vh] flex items-center justify-center p-4">
<div class="max-w-md w-full bg-white border-2 border-red-600 shadow-2xl rounded-sm p-8 pb-4">
<h1 class="text-2xl font-black text-red-600 mb-4 uppercase italic">Inkompatibler Browser</h1>
<p class="text-slate-800 mb-6">
Du nutzt aktuell <strong>{browserName}</strong>
. Dieser Browser unterstützt weder Bluetooth noch serielle USB-Verbindungen.
</p> </p>
<div class="space-y-2 mb-4 text-sm font-mono">
<div class="flex justify-between border-b border-slate-100 pb-1">
<span>Web Bluetooth:</span>
<span class="text-red-600 font-bold">NICHT UNTERSTÜTZT</span>
</div> </div>
<div class="flex justify-between border-b border-slate-100 pb-1"> {:else if !$isBluetoothSupported}
<span>Web Serial:</span> <div
<span class="text-red-600 font-bold">NICHT UNTERSTÜTZT</span> class="fixed lg:h-screen inset-0 flex flex-col items-center justify-center p-0 lg:p-4 z-[100] bg-white lg:bg-transparent"
</div> style="hyphens:auto;"
</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 <div
</a> 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-xs text-slate-500 mb-6 italic"> <div class="space-y-4 text-base md:text-lg text-center md:text-left">
Gerüchten zufolge soll <b>Winzigweichs Kante</b> <p>
-Browser diese Technologien auch unterstützen. Aber wer nutzt schon diese Weichware? Leider unterstützt dein Browser die benötigten Bluetooth-Funktionen nicht. Bitte versuche
es mit einem aktuellen <span class="font-semibold">Chrome</span>
oder einem andern Chromium-basierten Browser.
<span class="font-semibold">Winzigweich Kante</span>
soll gerüchteweise auch Chromium-basiert sein...
</p> </p>
<p>
Rundreise auf iOS unterstützt Bluetooth leider nicht, aber du kannst es mit einem
vernünftigen Gerät oder Browser versuchen.
</p>
</div>
</div> </div>
</div> </div>
{:else} {:else}
<ToastContainer client:load /> <ToastContainer />
<slot /> <slot />
{/if} {/if}

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

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

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); $: console.debug("Aktuelle Toasts im Store:", $toasts);
</script> </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)} {#each $toasts as toast (toast.id)}
<div <div
in:fly={{ y: 20, duration: 300 }} in:fly={{ y: 20, duration: 300 }}
out: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 === '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 === 'info' ? 'bg-blue-100/50 border-blue-500 text-blue-800' : ''}
{toast.type === 'warning' ? 'bg-amber-100/50 border-amber-500 text-amber-800' : ''} {toast.type === '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"; import "../styles/app.css";
const year = new Date().getFullYear();
--- ---
<!-- MainLayout.astro -->
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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> <title>Edis Buzzer</title>
</head> </head>
<body class="bg-surface text-on-surface antialiased transition-colors duration-300">
<body class="antialiased bg-slate-50 text-primary pt-16 pb-12">
<nav class="fixed top-0 left-0 w-full z-50 bg-white shadow-bottom px-4 py-3 h-16 flex items-center">
<span class="uppercase font-bold text-xl tracking-narrow font-mono italic">EDIS_BUZZER</span>
</nav>
<main class="mx-auto max-w-screen-lg px-4 py-8 w-full">
<slot /> <slot />
</main>
<footer class="fixed bottom-0 left-0 w-full z-50 bg-white text-xs text-slate-500 h-12 flex items-center shadow-top">
<div class="mx-auto px-4 w-full text-center">
&copy; 2026-{year} iten engineering. Alle Rechte vorbehalten.
</div>
</footer>
</body> </body>
</html> </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 { get } from 'svelte/store';
import { injectDummyDevices, isConnected, isPaired, isConnecting, pairedDevices, availableDevices, activeDeviceId, saveConnectionState, loadConnectionState, targetDeviceId, resetLocal, resetRemote } from './store'; import { isConnected, isPaired, isConnecting, pairedDevices, availableDevices, activeDeviceId, saveConnectionState, loadConnectionState, targetDeviceId, resetLocal, resetRemote, autoConnect } from './store';
import { BLE } from './protocol/constants'; import { BLE } from './protocol/constants';
import { parseIncomingFrame } from './protocol'; import { parseIncomingFrame } from './protocol';
import { registerTransport, handleTransportDisconnect, handleTransportConnect } from './transport'; import { registerTransport, handleTransportDisconnect, handleTransportConnect } from './transport';
@@ -9,27 +9,25 @@ import { SETTINGS } from './settings';
let rxCharacteristic: BluetoothRemoteGATTCharacteristic | null = null; let rxCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
let txCharacteristic: BluetoothRemoteGATTCharacteristic | null = null; let txCharacteristic: BluetoothRemoteGATTCharacteristic | null = null;
let device: BluetoothDevice | null = null; let device: BluetoothDevice | null = null;
let writeQueue = Promise.resolve();
export async function restoreSession() { export async function restoreSession() {
try { try {
const devices = await getPairedDevices(); const devices = await getPairedDevices();
if (devices.length > 0) { if (devices.length > 0) {
isPaired.set(true); isPaired.set(true);
startScanningAdvertisements(devices);
// Zuerst das Zielgerät definieren
const savedState = loadConnectionState(); const savedState = loadConnectionState();
if (savedState && savedState.autoConnect && savedState.transport === 'ble') { if (savedState) {
const targetDev = devices.find(d => d.id === savedState.deviceId);
if (targetDev) {
addToast("Versuche automatische Wiederverbindung...", "info");
await connectBuzzer(targetDev);
}
} else if (savedState) {
targetDeviceId.set(savedState.deviceId); targetDeviceId.set(savedState.deviceId);
device = devices.find(d => d.id === savedState.deviceId) || devices[0]; device = devices.find(d => d.id === savedState.deviceId) || devices[0];
} else { } else {
device = devices[0]; device = devices[0];
} }
// Danach das Scanning starten (die Auto-Connect-Logik liegt nun in den Callbacks)
startScanningAdvertisements(devices);
} }
} catch (error) { } catch (error) {
console.error("Session-Wiederherstellung fehlgeschlagen:", error); console.error("Session-Wiederherstellung fehlgeschlagen:", error);
@@ -38,19 +36,24 @@ export async function restoreSession() {
async function startScanningAdvertisements(devices: BluetoothDevice[]) { async function startScanningAdvertisements(devices: BluetoothDevice[]) {
for (const dev of devices) { for (const dev of devices) {
// Sicherheits-Check für Mock-Objekte
if (typeof dev.addEventListener !== 'function') continue; if (typeof dev.addEventListener !== 'function') continue;
dev.addEventListener('advertisementreceived', () => { dev.addEventListener('advertisementreceived', async () => {
// Gerät als verfügbar markieren
availableDevices.update(set => { availableDevices.update(set => {
const newSet = new Set(set); const newSet = new Set(set);
newSet.add(dev.id); newSet.add(dev.id);
return newSet; return newSet;
}); });
// Auto-Connect ausführen, sobald das Gerät funkt und falls die Voraussetzungen stimmen
if (get(autoConnect) && get(targetDeviceId) === dev.id && !get(isConnected) && !get(isConnecting)) {
console.debug("Auto-Connect: Gerät in Reichweite, starte Verbindung.");
await connectBuzzer(dev);
}
}); });
try { try {
// Auch hier vorher prüfen
if (typeof dev.watchAdvertisements === 'function') { if (typeof dev.watchAdvertisements === 'function') {
await dev.watchAdvertisements(); await dev.watchAdvertisements();
} }
@@ -201,22 +204,17 @@ export async function forgetDevice(targetDevice: BluetoothDevice) {
export async function getPairedDevices() { export async function getPairedDevices() {
let rawDevices: BluetoothDevice[] = []; let rawDevices: BluetoothDevice[] = [];
// 1. Physische Geräte abrufen, falls die API verfügbar ist
if ('bluetooth' in navigator && 'getDevices' in navigator.bluetooth) { if ('bluetooth' in navigator && 'getDevices' in navigator.bluetooth) {
try { try {
rawDevices = await navigator.bluetooth.getDevices(); rawDevices = await navigator.bluetooth.getDevices();
} catch (error) { } catch (error) {
console.error("Fehler beim Abrufen der gekoppelten Geräte:", error); console.error("Fehler beim Abrufen der gekoppelten Geräte:", error);
} }
console.log("Bluetooth-Devices", rawDevices);
} }
// 2. Physische Geräte in den Store schreiben
pairedDevices.set(rawDevices); pairedDevices.set(rawDevices);
// 3. Testdaten anfügen
injectDummyDevices();
// 4. Den aktualisierten Store-Inhalt (inkl. Dummies) für die weiterverarbeitenden Funktionen zurückgeben
return get(pairedDevices); return get(pairedDevices);
} }
@@ -226,7 +224,7 @@ function handleDisconnect() {
if (get(isConnected)) { if (get(isConnected)) {
addToast("Verbindung zu Buzzer verloren", "warning"); addToast("Verbindung zu Buzzer verloren", "warning");
} }
writeQueue = Promise.resolve();
resetRemote(); resetRemote();
registerTransport(null); registerTransport(null);
rxCharacteristic = null; rxCharacteristic = null;
@@ -240,8 +238,15 @@ function handleIncomingData(event: Event) {
} }
} }
export async function sendBleFrame(buffer: ArrayBuffer) { export function sendBleFrame(buffer: ArrayBuffer): Promise<void> {
// TODO: MTU Check einfügen! // TODO: MTU Check einfügen!
if (!rxCharacteristic) return; if (!rxCharacteristic) return Promise.resolve();
await rxCharacteristic.writeValueWithoutResponse(buffer);
writeQueue = writeQueue.then(() =>
rxCharacteristic!.writeValueWithoutResponse(buffer)
).catch(error => {
console.error("BLE Sende-Fehler:", error);
});
return writeQueue;
} }

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

View File

@@ -10,11 +10,13 @@ export const FRAME = {
RESPONSE: 0x10, RESPONSE: 0x10,
ACK: 0x11, ACK: 0x11,
ERROR: 0x12, ERROR: 0x12,
SUCCESS: 0x13,
FILE_START: 0x20, FILE_START: 0x20,
FILE_CHUNK: 0x21, FILE_CHUNK: 0x21,
FILE_END: 0x22, FILE_END: 0x22,
LS_START: 0x40, LS_START: 0x40,
LS_ENTRY: 0x41, LS_ENTRY: 0x41,
LS_END: 0x42, LS_END: 0x42,
@@ -22,11 +24,14 @@ export const FRAME = {
export const DATA = { export const DATA = {
PROTO_INFO: 0x01, PROTO_INFO: 0x01,
DEVICE_INFO: 0x02,
FS_INFO: 0x03, FS_INFO: 0x03,
FW_INFO: 0x04,
FILE_GET: 0x20, FILE_GET: 0x20,
FILE_PUT: 0x21, FILE_PUT: 0x21,
TAGS_GET: 0x22,
TAGS_PUT: 0x23,
LS: 0x40 LS: 0x40
}; };
@@ -54,3 +59,10 @@ export const ZEPHYR_ERRORS: Record<number, ZephyrError> = {
88: { text: "Funktion im Buzzer nicht implementiert", zephyr: "ENOSYS" }, 88: { text: "Funktion im Buzzer nicht implementiert", zephyr: "ENOSYS" },
134: { text: "Operation nicht unterstützt", zephyr: "ENOTSUP" } 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 { 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 { 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>; export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
let lsBuffer: any[] = []; let lsBuffer: any[] = [];
let fileChunks: Uint8Array[] = [];
let lsTimeout: ReturnType<typeof setTimeout> | null = null; let lsTimeout: ReturnType<typeof setTimeout> | null = null;
let lsResolve: ((data: any[]) => void) | null = null; let lsResolve: ((data: any[]) => void) | null = null;
let lsReject: ((error: Error) => 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; let fileGetReject: ((error: Error) => void) | null = null;
export function showErrorToast(errorCode: number) { export function showErrorToast(errorCode: number) {
@@ -38,12 +48,13 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
switch (frameType) { switch (frameType) {
case FRAME.RESPONSE: case FRAME.RESPONSE:
const dataType = view.getUint8(3); const dataType = view.getUint8(3);
switch (dataType) {
if (dataType === DATA.PROTO_INFO && payloadLength >= 5) { case DATA.PROTO_INFO:
const version = view.getUint16(4, true); const version = view.getUint16(4, true);
const maxChunkSize = view.getUint16(6, true); const maxChunkSize = view.getUint16(6, true);
protocolInfo.set({ version, maxChunkSize }); protocolInfo.set({ version, maxChunkSize });
} else if (dataType === DATA.FS_INFO && payloadLength >= 14) { break;
case DATA.FS_INFO:
const totalSizeBytes = view.getUint32(4, true); const totalSizeBytes = view.getUint32(4, true);
const freeSizeBytes = view.getUint32(8, true); const freeSizeBytes = view.getUint32(8, true);
const maxPathLength = view.getUint8(12); const maxPathLength = view.getUint8(12);
@@ -52,6 +63,27 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
const sysPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15, sysPathLength)); const sysPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15, sysPathLength));
const audioPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + sysPathLength, audioPathLength)); 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 }); 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; break;
@@ -78,26 +110,40 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
case FRAME.LS_END: case FRAME.LS_END:
if (lsTimeout) clearTimeout(lsTimeout); if (lsTimeout) clearTimeout(lsTimeout);
const total = view.getUint32(3, true); const total = view.getUint32(3, true);
console.debug(`LS Stream beendet. Erwartete Einträge: ${total}, empfangen: ${lsBuffer.length}`, lsBuffer);
if (total !== lsBuffer.length) { if (total !== lsBuffer.length) {
console.warn(`LS Stream: Erwartete Anzahl Einträge laut Header: ${total}, tatsächlich empfangen: ${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'); addToast(`Warnung: LS Stream erwartete ${total} Einträge, aber nur ${lsBuffer.length} empfangen.`, 'warning');
} else if (lsResolve) { } else if (lsResolve) {
lsResolve([...lsBuffer]); const currentResolve = lsResolve;
lsResolve = null; lsResolve = null;
lsReject = null; lsReject = null;
currentResolve([...lsBuffer]);
} }
break; break;
case FRAME.FILE_START: case FRAME.FILE_START:
fileTransfer.totalBytes = view.getUint32(3, true); currentFileCrc32 = 0;
fileTransfer.receivedBytes = 0; const totalBytes = view.getUint32(3, true);
fileTransfer.lastReceivedBytes = 0; const nowStart = performance.now();
fileTransfer.stalledSeconds = 0; fileChunks = [];
fileTransfer.active = true;
fileTransfer.startTime = performance.now();
console.log(`[FILE_GET] Stream gestartet. Erwartete Größe: ${fileTransfer.totalBytes} Bytes.`); 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.active = true;
fileTransfer.startTime = nowStart;
lastUiUpdate = 0;
fileTransfer.metricsTimer = setInterval(() => { fileTransfer.metricsTimer = setInterval(() => {
if (!fileTransfer.active) return; if (!fileTransfer.active) return;
@@ -112,29 +158,21 @@ case FRAME.FILE_START:
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer); if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
fileTransfer.active = false; fileTransfer.active = false;
// Hier optional einen Toast anzeigen lassen, falls importiert: addToast("Dateitransfer abgebrochen (Timeout)", "error");
// addToast("Dateitransfer abgebrochen (Timeout)", "error");
if (fileGetReject) { const currentReject = fileGetReject;
fileGetReject(new Error("Timeout beim Dateitransfer"));
fileGetResolve = null; fileGetResolve = null;
fileGetReject = null; fileGetReject = null;
if (currentReject) {
currentReject(new Error("Timeout beim Dateitransfer"));
} }
return; return;
} }
} else { } else {
// Daten fließen -> Watchdog zurücksetzen
fileTransfer.stalledSeconds = 0; fileTransfer.stalledSeconds = 0;
fileTransfer.lastReceivedBytes = fileTransfer.receivedBytes; fileTransfer.lastReceivedBytes = fileTransfer.receivedBytes;
} }
const elapsedSec = (performance.now() - fileTransfer.startTime) / 1000;
const speedKB = (fileTransfer.receivedBytes / 1024) / elapsedSec;
const percent = fileTransfer.totalBytes > 0
? ((fileTransfer.receivedBytes / fileTransfer.totalBytes) * 100).toFixed(1)
: "0.0";
console.log(`[FILE_GET] Fortschritt: ${percent}% | Speed: ${speedKB.toFixed(2)} KB/s`);
}, 1000); }, 1000);
// Initiale Credits (z.B. 64) // Initiale Credits (z.B. 64)
@@ -145,37 +183,105 @@ case FRAME.FILE_START:
case FRAME.FILE_CHUNK: case FRAME.FILE_CHUNK:
if (!fileTransfer.active) break; if (!fileTransfer.active) break;
const chunkData = new Uint8Array(view.buffer, 3, payloadLength);
currentFileCrc32 = crc32(chunkData, currentFileCrc32);
fileChunks.push(new Uint8Array(chunkData));
const previousReceived = fileTransfer.receivedBytes;
fileTransfer.receivedBytes += payloadLength; fileTransfer.receivedBytes += payloadLength;
fileTransfer.credits--; 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) { if (fileTransfer.credits <= 64) {
fileTransfer.credits = 128; fileTransfer.credits = 128;
sendCredits(fileTransfer.credits, sender); sendCredits(fileTransfer.credits, sender);
} }
break; break;
case FRAME.FILE_END: case FRAME.FILE_END:
if (fileTransfer.metricsTimer) { if (fileTransfer.mode === 'file') {
clearInterval(fileTransfer.metricsTimer); transferStats.update(s => ({
fileTransfer.metricsTimer = null; ...s,
bytesDone: s.bytesTotal,
}));
} }
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
fileTransfer.active = false; fileTransfer.active = false;
const crc32 = view.getUint32(3, true); const buzzerCrc32 = view.getUint32(3, true);
const totalElapsed = (performance.now() - fileTransfer.startTime) / 1000; const totalElapsed = (performance.now() - fileTransfer.startTime) / 1000;
const avgSpeed = (fileTransfer.receivedBytes / 1024) / totalElapsed; const avgSpeed = (fileTransfer.receivedBytes / 1024) / totalElapsed;
console.log(`[FILE_GET] Stream beendet.`); if (currentFileCrc32 === buzzerCrc32) {
console.log(`[FILE_GET] Empfangen: ${fileTransfer.receivedBytes} Bytes in ${totalElapsed.toFixed(2)}s.`); const fileBlob = new Blob(fileChunks, { type: 'application/octet-stream' });
console.log(`[FILE_GET] Durchschnitt: ${avgSpeed.toFixed(2)} KB/s`);
console.log(`[FILE_GET] Zephyr CRC32: 0x${crc32.toString(16).toUpperCase().padStart(8, '0')}`);
if (fileGetResolve) { if (fileTransfer.mode === 'file') {
fileGetResolve(true); 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; fileGetResolve = null;
fileGetReject = 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; break;
@@ -184,16 +290,21 @@ case FRAME.FILE_START:
console.error(`Received error frame with code: 0x${errorCode.toString(16)}`); console.error(`Received error frame with code: 0x${errorCode.toString(16)}`);
showErrorToast(errorCode); showErrorToast(errorCode);
if (lsReject) { if (lsReject) {
lsReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`)); const currentReject = lsReject;
lsResolve = null; lsResolve = null;
lsReject = null; lsReject = null;
currentReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
} }
if (fileGetReject && fileTransfer.active) { if (fileGetReject && fileTransfer.active) {
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer); if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
fileTransfer.active = false; fileTransfer.active = false;
fileGetReject(new Error(`Buzzer Error 0x${errorCode.toString(16)}`)); const currentReject = fileGetReject;
fileGetResolve = null; fileGetResolve = null;
fileGetReject = 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; break;
@@ -214,6 +325,17 @@ export function buildProtocolInfoRequest(): ArrayBuffer {
return buffer; 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 { export function buildFSInfoRequest(): ArrayBuffer {
const buffer = new ArrayBuffer(4); const buffer = new ArrayBuffer(4);
const view = new DataView(buffer); const view = new DataView(buffer);
@@ -225,6 +347,17 @@ export function buildFSInfoRequest(): ArrayBuffer {
return buffer; 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 { export function buildLSRequest(path: string): ArrayBuffer {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const pathBytes = encoder.encode(path); const pathBytes = encoder.encode(path);
@@ -250,7 +383,6 @@ async function sendCredits(count: number, send: FrameSender) {
const buffer = new ArrayBuffer(5); const buffer = new ArrayBuffer(5);
const view = new DataView(buffer); const view = new DataView(buffer);
console.debug(`Sende ${count} Credits für Stream...`);
view.setUint8(0, FRAME.ACK); view.setUint8(0, FRAME.ACK);
view.setUint16(1, 2, true); view.setUint16(1, 2, true);
view.setUint16(3, count, true); view.setUint16(3, count, true);
@@ -264,9 +396,10 @@ function resetLsWatchdog() {
addToast("Verzeichnis-Streaming abgebrochen (Timeout)", "warning"); addToast("Verzeichnis-Streaming abgebrochen (Timeout)", "warning");
lsBuffer = []; lsBuffer = [];
if (lsReject) { if (lsReject) {
lsReject(new Error("Timeout beim Lesen des Verzeichnisses")); const currentReject = lsReject;
lsResolve = null; lsResolve = null;
lsReject = null; lsReject = null;
currentReject(new Error("Timeout beim Lesen des Verzeichnisses"));
} }
}, 3000); }, 3000);
} }
@@ -278,18 +411,32 @@ export function setLsResolver(resolve: (data: any[]) => void, reject: (error: Er
const fileTransfer = { const fileTransfer = {
active: false, active: false,
mode: 'file' as 'file' | 'tags',
startTime: 0, startTime: 0,
totalBytes: 0, totalBytes: 0,
receivedBytes: 0, receivedBytes: 0,
lastReceivedBytes: 0, // NEU: Für die Timeout-Berechnung lastReceivedBytes: 0,
stalledSeconds: 0, // NEU: Zähler für Stillstand stalledSeconds: 0,
credits: 0, credits: 0,
metricsTimer: null as ReturnType<typeof setInterval> | null metricsTimer: null as ReturnType<typeof setInterval> | null
}; };
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; fileGetResolve = resolve;
fileGetReject = reject; fileGetReject = reject;
fileTransfer.mode = mode;
} }
export function buildFileGetRequest(path: string): ArrayBuffer { export function buildFileGetRequest(path: string): ArrayBuffer {
@@ -307,3 +454,19 @@ export function buildFileGetRequest(path: string): ArrayBuffer {
return buffer; 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);
return buffer;
}

View File

@@ -1,11 +1,46 @@
import type { AudioProcessingOptions } from './types';
export const SETTINGS = { export const SETTINGS = {
storage: { storage: {
connectionKey: 'buzzer_connection_state' connectionKey: 'buzzer_connection_state'
}, },
bluetooth: { 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: { 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 { 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. // Fallback-Typ fuer Build-Umgebungen ohne DOM-Library.
interface BluetoothDevice { interface BluetoothDevice {
@@ -28,6 +31,13 @@ export interface ProtocolInfo {
maxChunkSize: number; maxChunkSize: number;
} }
export interface DeviceInfo {
deviceId: string;
boardName: string;
boardRevision: string;
socName: string;
}
export interface FsInfo { export interface FsInfo {
totalSize: number; totalSize: number;
freeSize: number; freeSize: number;
@@ -36,6 +46,13 @@ export interface FsInfo {
audioPath: string; audioPath: string;
} }
export interface FwInfo {
fwStatus: number;
slot1Size: number;
fwVersion: string;
kernelVersion: string;
}
export interface StorageUsage { export interface StorageUsage {
totalBytes: number; totalBytes: number;
freeBytes: number; freeBytes: number;
@@ -46,8 +63,6 @@ export interface StorageUsage {
freePercent: number; freePercent: number;
} }
const CONNECTION_STATE_KEY = 'buzzer_connection_state';
// App-Status: Initialisierung und Feature-Support // App-Status: Initialisierung und Feature-Support
export const isInitializing = writable<boolean>(true); export const isInitializing = writable<boolean>(true);
export const isBluetoothSupported = writable<boolean | null>(null); export const isBluetoothSupported = writable<boolean | null>(null);
@@ -64,9 +79,11 @@ export const targetDeviceId = writable<string | null>(null);
export const pairedDevices = writable<BluetoothDevice[]>([]); export const pairedDevices = writable<BluetoothDevice[]>([]);
export const availableDevices = writable<Set<string>>(new Set()); // IDs der derzeit advertisierten Geräte 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 protocolInfo = writable<ProtocolInfo | null>(null);
export const deviceInfo = writable<DeviceInfo | null>(null);
export const fsInfo = writable<FsInfo | null>(null); export const fsInfo = writable<FsInfo | null>(null);
export const fwInfo = writable<FwInfo | null>(null);
// Dateilisten // Dateilisten
export const buzzerAudioFiles = writable<BuzzerFile[]>([]); export const buzzerAudioFiles = writable<BuzzerFile[]>([]);
@@ -74,7 +91,7 @@ export const buzzerSysFiles = writable<BuzzerFile[]>([]);
export const localAudioFiles = writable<BuzzerFile[]>([]); export const localAudioFiles = writable<BuzzerFile[]>([]);
// Ladezustände getrennt nach Quelle // Ladezustände getrennt nach Quelle
export const isFetchingRemote = writable<boolean>(false); export const isTransferingRemote = writable<boolean>(false);
export const isFetchingLocal = writable<boolean>(false); export const isFetchingLocal = writable<boolean>(false);
// Persistenz des letzten Verbindungsziels (nur im Browser nutzbar) // 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 // Für die Anzeige der Transferdetails (Dateiname, Fortschritt, Geschwindigkeit, ETA)
export function injectDummyDevices(): void { export const transferStats = writable({
const dummy1 = { isActive: false,
id: 'dummy-1', currentFileName: '',
name: 'Dev Buzzer (Erreichbar)', pendingFileName: '',
forget: async () => { bytesDone: 0,
console.log('Forget dummy-1'); bytesTotal: 0,
}, overallDone: 0,
addEventListener: () => {}, overallTotal: 0,
removeEventListener: () => {}, bulkStartTime: 0,
watchAdvertisements: async () => {}, fileStartTime: 0,
gatt: { connected: false, disconnect: () => {} }, filesRemaining: 0
} as unknown as BluetoothDevice;
const dummy2 = {
id: 'dummy-2',
name: 'Dev Buzzer (Offline)',
forget: async () => {
console.log('Forget dummy-2');
},
addEventListener: () => {},
removeEventListener: () => {},
watchAdvertisements: async () => {},
gatt: { connected: false, disconnect: () => {} },
} as unknown as BluetoothDevice;
pairedDevices.update((devices) => {
if (!devices.find((d) => d.id === 'dummy-1')) {
return [...devices, dummy1, dummy2];
}
return devices;
}); });
availableDevices.update((set) => { export const resetTransferStats = () => {
const newSet = new Set(set); transferStats.set({
newSet.add('dummy-1'); isActive: false,
return newSet; currentFileName: '',
pendingFileName: '',
bytesDone: 0,
bytesTotal: 0,
overallDone: 0,
overallTotal: 0,
bulkStartTime: 0,
fileStartTime: 0,
filesRemaining: 0
}); });
};
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;
}
} 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;
// --- 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 { export function resetRemote(): void {
isConnected.set(false); isConnected.set(false);
isConnecting.set(false); isConnecting.set(false);
protocolInfo.set(null); protocolInfo.set(null);
deviceInfo.set(null);
fsInfo.set(null); fsInfo.set(null);
fwInfo.set(null);
activeDeviceId.set(null); activeDeviceId.set(null);
buzzerAudioFiles.set([]); buzzerAudioFiles.set([]);
buzzerSysFiles.set([]); buzzerSysFiles.set([]);
isFetchingRemote.set(false); isTransferingRemote.set(false);
resetTransferStats();
} }
export function resetLocal(): void { export function resetLocal(): void {
@@ -180,3 +293,149 @@ export function resetAll(): void {
resetRemote(); resetRemote();
resetLocal(); resetLocal();
} }
// Initialisierung aus dem bestehenden LocalStorage-Eintrag
const initialState = loadConnectionState();
export const autoConnect = writable<boolean>(initialState?.autoConnect ?? true);
// Automatische Speicherung bei Änderungen
autoConnect.subscribe(value => {
// Verhindert Fehler beim serverseitigen Rendern (Astro)
if (typeof window !== 'undefined') {
const currentState = loadConnectionState() || { transport: 'ble', deviceId: '', autoConnect: true };
saveConnectionState({ ...currentState, autoConnect: value });
}
});
// Abgeleitete Stores für die Anzahl der ausgewählten Dateien und der Dateien
export const buzzerFilesCount = derived(
buzzerAudioFiles,
($files) => $files.length
);
export const selectedBuzzerFilesCount = derived(
buzzerAudioFiles,
($files) => $files.filter(f => f.selected).length
);
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 { get } from 'svelte/store';
import { isConnected, fsInfo, buzzerAudioFiles, buzzerSysFiles, isFetchingRemote, isFetchingLocal, storageUsage} from './store'; import { isConnected, deviceInfo, fsInfo, fwInfo, buzzerAudioFiles, buzzerSysFiles, isTransferingRemote, isFetchingLocal, storageUsage, localAudioFiles, transferStats } from './store';
import { requestProtocolInfo, requestFSInfo, fetchDirectory } from './transport'; import { requestProtocolInfo, requestFSInfo, fetchDirectory, getFile, putFile, deleteRemoteFile, requestDeviceInfo, requestFWInfo } from './transport';
import type { BuzzerFile } from './types'; 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 { function mapToBuzzerFile(rawFile: any): BuzzerFile {
return { return {
@@ -10,17 +15,20 @@ function mapToBuzzerFile(rawFile: any): BuzzerFile {
type: rawFile.type, type: rawFile.type,
tagsLoaded: false, tagsLoaded: false,
sysTags: { format: null, crc32: null }, sysTags: { format: null, crc32: null },
metaTags: {} metaTags: {},
selected: false,
}; };
} }
export async function refreshRemote() { export async function refreshRemote() {
if (!get(isConnected)) return; if (!get(isConnected)) return;
isFetchingRemote.set(true); isTransferingRemote.set(true);
try { try {
await requestProtocolInfo(); await requestProtocolInfo();
await requestFSInfo(); await requestFSInfo();
await requestFWInfo();
await requestDeviceInfo();
// Kurze Verzögerung für Store-Propagation // Kurze Verzögerung für Store-Propagation
await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 100));
@@ -32,28 +40,234 @@ export async function refreshRemote() {
buzzerSysFiles.set(sysFiles.map(mapToBuzzerFile)); buzzerSysFiles.set(sysFiles.map(mapToBuzzerFile));
const audioFiles = await fetchDirectory(currentFsInfo?.audioPath || "/lfs/a"); 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) { } catch (error) {
console.error("Fehler beim Aktualisieren der Buzzer-Daten:", 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 { } finally {
isFetchingRemote.set(false); isTransferingRemote.set(false);
} }
} }
export async function refreshLocal() { export async function refreshLocal() {
isFetchingLocal.set(true); isFetchingLocal.set(true);
try { try {
// TODO: Implementierung lokaler Dateisystem-Zugriff (z.B. File System Access API) const dbFiles = await getLocalFiles();
// const files = await readLocalDirectory();
// localAudioFiles.set(files.map(mapToBuzzerFile)); // 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) { } 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 { } finally {
isFetchingLocal.set(false); isFetchingLocal.set(false);
} }
} }
export async function downloadSelectedFiles() {
const files = get(buzzerAudioFiles).filter(f => f.selected);
const pathPrefix = get(fsInfo)?.audioPath || "/lfs/a";
if (files.length === 0) {
addToast("Keine Dateien zum Herunterladen ausgewählt.", "warning");
return;
}
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
const bulkStart = performance.now(); // Startzeitpunkt exakt erfassen
transferStats.update(s => ({
...s,
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 { buildLSRequest, buildProtocolInfoRequest, buildDeviceInfoRequest, buildFSInfoRequest, buildFWInfoRequest, setLsResolver, buildFileGetRequest, setFileGetResolver, buildTagsGetRequest, uploadState } from './protocol/parser';
import { buildFileGetRequest, setFileGetResolver } 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 { 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>; export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
let currentSender: FrameSender | null = null; let currentSender: FrameSender | null = null;
@@ -17,6 +26,8 @@ export async function handleTransportConnect(sender: FrameSender) {
// Basis-Informationen zwingend vorab laden // Basis-Informationen zwingend vorab laden
await requestProtocolInfo(); await requestProtocolInfo();
await requestFSInfo(); await requestFSInfo();
await requestDeviceInfo();
await requestFWInfo();
// Erst wenn diese Basisdaten da sind, wird die UI freigeschaltet // Erst wenn diese Basisdaten da sind, wird die UI freigeschaltet
isConnected.set(true); isConnected.set(true);
@@ -35,10 +46,18 @@ export async function requestProtocolInfo() {
await sendFrame(buildProtocolInfoRequest()); await sendFrame(buildProtocolInfoRequest());
} }
export async function requestDeviceInfo() {
await sendFrame(buildDeviceInfoRequest());
}
export async function requestFSInfo() { export async function requestFSInfo() {
await sendFrame(buildFSInfoRequest()); await sendFrame(buildFSInfoRequest());
} }
export async function requestFWInfo() {
await sendFrame(buildFWInfoRequest());
}
let isListing = false; let isListing = false;
export async function fetchDirectory(path: string): Promise<any[]> { export async function fetchDirectory(path: string): Promise<any[]> {
@@ -70,7 +89,7 @@ export function handleTransportDisconnect() {
let isFileTransferring = false; let isFileTransferring = false;
export async function fetchFileThroughputTest(path: string): Promise<boolean> { export async function getFile(path: string): Promise<boolean> {
if (isFileTransferring) { if (isFileTransferring) {
throw new Error("Ein Dateitransfer läuft bereits."); throw new Error("Ein Dateitransfer läuft bereits.");
} }
@@ -78,7 +97,7 @@ export async function fetchFileThroughputTest(path: string): Promise<boolean> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
setFileGetResolver( setFileGetResolver(
(success) => { isFileTransferring = false; resolve(success); }, (result: any) => { isFileTransferring = false; resolve(result.success); },
(err) => { isFileTransferring = false; reject(err); } (err) => { isFileTransferring = false; reject(err); }
); );
@@ -90,3 +109,313 @@ export async function fetchFileThroughputTest(path: string): Promise<boolean> {
} }
}); });
} }
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; tagsLoaded: boolean;
sysTags: SystemTags; sysTags: SystemTags;
metaTags: MetadataTags; 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 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> <MainLayout>
<AppGuard client:load> <AppGuard client:only="svelte">
<div class="max-w-4xl mx-auto mt-4"> <Header client:only="svelte" />
<header class="mb-12 text-center"> <MainGrid client:only="svelte" />
<h1 class="text-3xl md:text-4xl font-extrabold text-slate-800 tracking-tight mb-3">
Buzzer Management
</h1>
<p class="text-slate-500 text-lg max-w-2xl mx-auto">
Verbinde dich mit dem nRF52840 Buzzer, um Audio-Dateien zu übertragen und
Systemparameter auszulesen.
</p>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<BuzzerControl client:load />
</div>
<div>
<BLEList client:load />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<FlashUsage client:load />
</div>
<div>
</div>
</div>
</div>
</AppGuard> </AppGuard>
</MainLayout> </MainLayout>

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