Compare commits
10 Commits
5bb0d345da
...
947346777f
| Author | SHA1 | Date | |
|---|---|---|---|
| 947346777f | |||
| 01448223ad | |||
| b863b04505 | |||
| ff63dda086 | |||
| 7c7f19a4b7 | |||
| 735129d83d | |||
| 574ab9fa30 | |||
| 6ec66cd9da | |||
| b5eb3b56c0 | |||
| 1a4a22eafd |
122
Tags.md
Normal 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!`)
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
5
firmware/libs/audio/CMakeLists.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
if(CONFIG_AUDIO)
|
||||||
|
zephyr_library()
|
||||||
|
zephyr_library_sources(src/audio.c)
|
||||||
|
zephyr_include_directories(include)
|
||||||
|
endif()
|
||||||
60
firmware/libs/audio/Kconfig
Normal 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
|
||||||
33
firmware/libs/audio/include/audio.h
Normal 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 */
|
||||||
622
firmware/libs/audio/src/audio.c
Normal 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);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,25 +75,25 @@ static ssize_t rx_cb(struct bt_conn *conn, const struct bt_gatt_attr *attr,
|
|||||||
static void tx_ccc_cfg_changed(const struct bt_gatt_attr *attr, uint16_t value)
|
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,
|
||||||
BT_GATT_PRIMARY_SERVICE(&buzz_service_uuid),
|
BT_GATT_PRIMARY_SERVICE(&buzz_service_uuid),
|
||||||
BT_GATT_CHARACTERISTIC(&buzz_rx_uuid.uuid, BT_GATT_CHRC_WRITE_WITHOUT_RESP,
|
BT_GATT_CHARACTERISTIC(&buzz_rx_uuid.uuid, BT_GATT_CHRC_WRITE_WITHOUT_RESP,
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
5
firmware/libs/event_mgmt/CMakeLists.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
if(CONFIG_EVENT_MGMT)
|
||||||
|
zephyr_library()
|
||||||
|
zephyr_library_sources(src/event_mgmt.c)
|
||||||
|
zephyr_include_directories(include)
|
||||||
|
endif()
|
||||||
10
firmware/libs/event_mgmt/Kconfig
Normal 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
|
||||||
29
firmware/libs/event_mgmt/include/event_mgmt.h
Normal 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 */
|
||||||
3
firmware/libs/event_mgmt/src/event_mgmt.c
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#include "event_mgmt.h"
|
||||||
|
|
||||||
|
K_EVENT_DEFINE(event_mgmt_events);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */
|
||||||
@@ -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);
|
||||||
16
firmware/libs/fw_mgmt/CMakeLists.txt
Normal 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()
|
||||||
23
firmware/libs/fw_mgmt/Kconfig
Normal 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
|
||||||
23
firmware/libs/fw_mgmt/include/fw_mgmt.h
Normal 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 */
|
||||||
79
firmware/libs/fw_mgmt/src/fw_mgmt.c
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
SB_CONFIG_BOOTLOADER_MCUBOOT=y
|
||||||
7
firmware/sysbuild/mcuboot.conf
Normal 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
|
||||||
13
firmware/sysbuild/mcuboot.overlay
Normal 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";
|
||||||
|
};
|
||||||
|
};
|
||||||
647
protocol.md
@@ -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.
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
BIN
webpage/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
webpage/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 655 B After Width: | Height: | Size: 15 KiB |
@@ -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) {
|
#dark-icon {
|
||||||
path { fill: #FFF; }
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
</svg>
|
@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 |
21
webpage/public/site.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
BIN
webpage/public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
webpage/public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
31
webpage/src/components/App.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Header from "./components/Header.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col min-h-screen">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main class="main-layout flex-grow">
|
||||||
|
|
||||||
|
<section class="buzzer-card p-6 h-64">
|
||||||
|
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Dateiverarbeitung</h3>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="buzzer-card p-6 h-64">
|
||||||
|
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Buzzer Status</h3>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="buzzer-card p-6 h-96 lg:col-span-1">
|
||||||
|
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Lokale Sounds</h3>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="buzzer-card p-6 h-96 lg:col-span-1">
|
||||||
|
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Buzzer Sounds</h3>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="hidden sm:flex justify-center items-center py-4 bg-slate-50 border-t border-slate-200 text-[10px] text-slate-400 uppercase tracking-widest">
|
||||||
|
© 2026 Edis Buzzer Management Studio | Nerd Mode Active
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
@@ -1,61 +1,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">
|
||||||
|
Browserkompatibilität wird geprüft...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if !$isBluetoothSupported && !$isSerialSupported}
|
{:else if !$isBluetoothSupported}
|
||||||
<div class="min-h-[60vh] flex items-center justify-center p-4">
|
<div
|
||||||
<div class="max-w-md w-full bg-white border-2 border-red-600 shadow-2xl rounded-sm p-8 pb-4">
|
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"
|
||||||
<h1 class="text-2xl font-black text-red-600 mb-4 uppercase italic">Inkompatibler Browser</h1>
|
style="hyphens:auto;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full h-full lg:h-auto lg:max-w-md bg-red-50 shadow-red-500/30 lg:border border-red-600 lg:shadow-xl lg:rounded-lg p-6 lg:p-8 text-red-600 flex flex-col justify-center"
|
||||||
|
>
|
||||||
|
<h1 class="text-2xl font-bold mb-2 text-center">Dein Browser ist... suboptimal</h1>
|
||||||
|
<div class="text-center text-7xl md:text-9xl font-bold mb-4">🥺</div>
|
||||||
|
|
||||||
<p class="text-slate-800 mb-6">
|
<div class="space-y-4 text-base md:text-lg text-center md:text-left">
|
||||||
Du nutzt aktuell <strong>{browserName}</strong>
|
<p>
|
||||||
. Dieser Browser unterstützt weder Bluetooth noch serielle USB-Verbindungen.
|
Leider unterstützt dein Browser die benötigten Bluetooth-Funktionen nicht. Bitte versuche
|
||||||
</p>
|
es mit einem aktuellen <span class="font-semibold">Chrome</span>
|
||||||
|
oder einem andern Chromium-basierten Browser.
|
||||||
<div class="space-y-2 mb-4 text-sm font-mono">
|
<span class="font-semibold">Winzigweich Kante</span>
|
||||||
<div class="flex justify-between border-b border-slate-100 pb-1">
|
soll gerüchteweise auch Chromium-basiert sein...
|
||||||
<span>Web Bluetooth:</span>
|
</p>
|
||||||
<span class="text-red-600 font-bold">NICHT UNTERSTÜTZT</span>
|
<p>
|
||||||
</div>
|
Rundreise auf iOS unterstützt Bluetooth leider nicht, aber du kannst es mit einem
|
||||||
<div class="flex justify-between border-b border-slate-100 pb-1">
|
vernünftigen Gerät oder Browser versuchen.
|
||||||
<span>Web Serial:</span>
|
</p>
|
||||||
<span class="text-red-600 font-bold">NICHT UNTERSTÜTZT</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-slate-500 mb-6 italic">
|
|
||||||
(Info: Firefox und Safari blockieren diese Hardware-Schnittstellen aus Prinzip.)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://www.google.com/chrome/"
|
|
||||||
class="block w-full text-center bg-blue-600 text-white py-3 font-bold hover:bg-blue-700 transition uppercase tracking-widest text-sm mb-4"
|
|
||||||
>
|
|
||||||
Googles Glanzeisen installieren
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<p class="text-xs text-slate-500 mb-6 italic">
|
|
||||||
Gerüchten zufolge soll <b>Winzigweichs Kante</b>
|
|
||||||
-Browser diese Technologien auch unterstützen. Aber wer nutzt schon diese Weichware?
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ToastContainer client:load />
|
<ToastContainer />
|
||||||
<slot />
|
<slot />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
456
webpage/src/components/AudioDropzone.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
163
webpage/src/components/DeviceInfo.svelte
Normal 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>
|
||||||
25
webpage/src/components/FileList.svelte
Normal 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>
|
||||||
346
webpage/src/components/FileListItem.svelte
Normal 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}
|
||||||
|
 - 
|
||||||
|
<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))} 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>
|
||||||
138
webpage/src/components/FileListMenu.svelte
Normal 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>
|
||||||
181
webpage/src/components/FileMenuOverlay.svelte
Normal 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>
|
||||||
481
webpage/src/components/FileTagEditor.svelte
Normal 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>
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { storageUsage } from "../lib/store";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mt-4 w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner">
|
|
||||||
<div
|
|
||||||
class="h-full bg-slate-400 transition-all duration-500"
|
|
||||||
style="width: {$storageUsage?.systemPercent ?? 0}%"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<div class="mt-1 text-xs text-slate-400 flex justify-between uppercase tracking-tighter font-medium">
|
|
||||||
{#if $storageUsage}
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<div>
|
|
||||||
<span class="font-semibold text-slate-400">
|
|
||||||
Rate: {($storageUsage.systemBytes / 1048576).toFixed(2)} MB
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="font-semibold text-slate-400">
|
|
||||||
{($storageUsage.freeBytes / 1048576).toFixed(2)} Sekunden
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="text-slate-400">Kein Transfer aktiv</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -2,39 +2,37 @@
|
|||||||
import { storageUsage } from "../lib/store";
|
import { storageUsage } from "../lib/store";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-4 w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner">
|
<div class="w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner shadow-sm">
|
||||||
<div
|
<div
|
||||||
class="h-full bg-slate-400 transition-all duration-500"
|
class="h-full bg-gradient-to-b from-slate-300 to-slate-400 transition-all duration-500"
|
||||||
style="width: {$storageUsage?.systemPercent ?? 0}%"
|
style="width: {$storageUsage?.systemPercent ?? 0}%"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="h-full bg-indigo-500 transition-all duration-500"
|
class="h-full bg-gradient-to-b from-indigo-300 to-indigo-500 transition-all duration-500"
|
||||||
style="width: {$storageUsage?.audioPercent ?? 0}%"
|
style="width: {$storageUsage?.audioPercent ?? 0}%"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="h-full bg-emerald-500 transition-all duration-500"
|
class="h-full bg-gradient-to-b from-emerald-300 to-emerald-500 transition-all duration-500"
|
||||||
style="width: {$storageUsage?.freePercent ?? 0}%"
|
style="width: {$storageUsage?.freePercent ?? 0}%"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1 text-xs text-slate-400 flex justify-between uppercase tracking-tighter font-medium">
|
<div class="text-xs text-slate-400 flex justify-between">
|
||||||
{#if $storageUsage}
|
{#if $storageUsage}
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-semibold text-slate-400">System:
|
<span class="font-semibold text-slate-400">System:
|
||||||
{($storageUsage.systemBytes / 1048576).toFixed(2)} MB</span>
|
{($storageUsage.systemBytes / 1048576).toFixed(2)} MB</span>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="font-semibold text-indigo-500">Audio:
|
<span class="font-semibold text-indigo-500">Audio:
|
||||||
{($storageUsage.audioBytes / 1048576).toFixed(2)} MB</span>
|
{($storageUsage.audioBytes / 1048576).toFixed(2)} MB</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="font-semibold text-emerald-500">Frei:
|
<span class="font-semibold text-emerald-500">Frei:
|
||||||
{($storageUsage.freeBytes / 1048576).toFixed(2)} MB</span>
|
{($storageUsage.freeBytes / 1048576).toFixed(2)} MB</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-slate-400">Speicherdaten nicht verfügbar</div>
|
<div class="text-slate-400">Speicherdaten nicht verfügbar</div>
|
||||||
|
|||||||
245
webpage/src/components/Header.svelte
Normal 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 Buzzer</span>
|
||||||
|
|
||||||
|
<span class="font-light">CONTROL</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
class:disabled={$isConnecting}
|
||||||
|
use:clickOutside
|
||||||
|
on:outclick={() => (showDropdown = false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="btn-connect-group"
|
||||||
|
class:connected={$isConnected}
|
||||||
|
class:last-available={!$isConnected && canQuickConnect}
|
||||||
|
class:last-unavailable={!$isConnected && !canQuickConnect}
|
||||||
|
>
|
||||||
|
<button class="btn-main" on:click={handleMainAction} disabled={$isConnecting}>
|
||||||
|
{#if $isConnected}
|
||||||
|
<PlugsIcon weight="fill" class="w-4 h-4" />
|
||||||
|
<span class="hidden sm:inline">Trennen</span>
|
||||||
|
{:else if canQuickConnect}
|
||||||
|
<PlugsConnectedIcon weight="fill" class="w-4 h-4" />
|
||||||
|
<span class="hidden sm:inline">Verbinden</span>
|
||||||
|
{:else}
|
||||||
|
<LinkIcon weight="bold" class="w-4 h-4" />
|
||||||
|
<span class="hidden sm:inline">Neues Gerät</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-dropdown"
|
||||||
|
on:click={() => (showDropdown = !showDropdown)}
|
||||||
|
disabled={$isConnecting}
|
||||||
|
>
|
||||||
|
<CaretDownIcon
|
||||||
|
weight="bold"
|
||||||
|
class="w-4 h-4 transition-transform duration-300 {showDropdown ? '-scale-y-100' : ''}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showDropdown}
|
||||||
|
<div
|
||||||
|
transition:slide={{ duration: 300 }}
|
||||||
|
class="absolute right-0 top-12 bg-surface-card shadow-xl rounded-lg border border-border-card p-0 z-50 overflow-hidden
|
||||||
|
w-max max-w-[calc(100vw-2rem)] sm:max-w-md min-w-[16rem]"
|
||||||
|
>
|
||||||
|
{#each $pairedDevices as dev (dev.id)}
|
||||||
|
<HeaderDeviceListItem
|
||||||
|
device={dev}
|
||||||
|
name={dev.name || ""}
|
||||||
|
isConnected={$activeDeviceId === dev.id}
|
||||||
|
isLastConnectedDevice={lastDeviceId === dev.id}
|
||||||
|
isAvailable={$availableDevices.has(dev.id)}
|
||||||
|
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>
|
||||||
68
webpage/src/components/HeaderDeviceListItem.svelte
Normal 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>
|
||||||
170
webpage/src/components/MainGrid.svelte
Normal 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>
|
||||||
@@ -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' : ''}
|
||||||
|
|||||||
51
webpage/src/components/Tooltip.svelte
Normal 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>
|
||||||
106
webpage/src/components/TransferProgress.svelte
Normal 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}
|
||||||
@@ -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">
|
<slot />
|
||||||
|
|
||||||
<nav class="fixed top-0 left-0 w-full z-50 bg-white shadow-bottom px-4 py-3 h-16 flex items-center">
|
|
||||||
<span class="uppercase font-bold text-xl tracking-narrow font-mono italic">EDIS_BUZZER</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="mx-auto max-w-screen-lg px-4 py-8 w-full">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="fixed bottom-0 left-0 w-full z-50 bg-white text-xs text-slate-500 h-12 flex items-center shadow-top">
|
|
||||||
<div class="mx-auto px-4 w-full text-center">
|
|
||||||
© 2026-{year} iten engineering. Alle Rechte vorbehalten.
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
23
webpage/src/lib/actions/clickOutside.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
99
webpage/src/lib/actions/tooltip.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
131
webpage/src/lib/audioProcessor.ts
Normal 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(/\.[^/.]+$/, "")
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
isInitializing.set(false);
|
|
||||||
|
refreshLocal().then(() => {
|
||||||
|
isInitializing.set(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
16
webpage/src/lib/protocol/crc32.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const CRC32_TABLE = new Int32Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let c = i;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
}
|
||||||
|
CRC32_TABLE[i] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crc32(buffer: Uint8Array, previousCrc = 0): number {
|
||||||
|
let crc = previousCrc ^ -1;
|
||||||
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ buffer[i]) & 0xFF];
|
||||||
|
}
|
||||||
|
return (crc ^ -1) >>> 0; // Rückgabe als unsigned 32-bit
|
||||||
|
}
|
||||||
@@ -1,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,20 +48,42 @@ 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;
|
||||||
const totalSizeBytes = view.getUint32(4, true);
|
case DATA.FS_INFO:
|
||||||
const freeSizeBytes = view.getUint32(8, true);
|
const totalSizeBytes = view.getUint32(4, true);
|
||||||
const maxPathLength = view.getUint8(12);
|
const freeSizeBytes = view.getUint32(8, true);
|
||||||
const sysPathLength = view.getUint8(13);
|
const maxPathLength = view.getUint8(12);
|
||||||
const audioPathLength = view.getUint8(14);
|
const sysPathLength = view.getUint8(13);
|
||||||
const sysPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15, sysPathLength));
|
const audioPathLength = view.getUint8(14);
|
||||||
const audioPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + sysPathLength, audioPathLength));
|
const sysPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15, sysPathLength));
|
||||||
fsInfo.set({ totalSize: totalSizeBytes / 1024 / 1024, freeSize: freeSizeBytes / 1024 / 1024, maxPathLength, sysPath, audioPath });
|
const audioPath = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + sysPathLength, audioPathLength));
|
||||||
|
fsInfo.set({ totalSize: totalSizeBytes / 1024 / 1024, freeSize: freeSizeBytes / 1024 / 1024, maxPathLength, sysPath, audioPath });
|
||||||
|
break;
|
||||||
|
case DATA.DEVICE_INFO:
|
||||||
|
const deviceId = [0, 2, 4, 6]
|
||||||
|
.map(offset => view.getUint16(4 + offset, false).toString(16).padStart(4, '0').toUpperCase())
|
||||||
|
.join('-');
|
||||||
|
const boardNameLength = view.getUint8(12);
|
||||||
|
const boardRevisionLength = view.getUint8(13);
|
||||||
|
const socNameLength = view.getUint8(14);
|
||||||
|
const boardName = new TextDecoder().decode(new Uint8Array(view.buffer, 15, boardNameLength));
|
||||||
|
const boardRevision = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + boardNameLength, boardRevisionLength));
|
||||||
|
const socName = new TextDecoder().decode(new Uint8Array(view.buffer, 15 + boardNameLength + boardRevisionLength, socNameLength));
|
||||||
|
deviceInfo.set({ deviceId, boardName, boardRevision, socName });
|
||||||
|
break;
|
||||||
|
case DATA.FW_INFO:
|
||||||
|
const fwStatus = view.getUint8(4);
|
||||||
|
const slot1Size = view.getUint32(5, true);
|
||||||
|
const fw_version_length = view.getUint8(9);
|
||||||
|
const kernel_version_length = view.getUint8(10);
|
||||||
|
const fwVersion = new TextDecoder().decode(new Uint8Array(view.buffer, 11, fw_version_length));
|
||||||
|
const kernelVersion = new TextDecoder().decode(new Uint8Array(view.buffer, 11 + fw_version_length, kernel_version_length));
|
||||||
|
fwInfo.set({ fwStatus, slot1Size, fwVersion, kernelVersion });
|
||||||
}
|
}
|
||||||
break;
|
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;
|
||||||
|
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;
|
fileGetResolve = null;
|
||||||
fileGetReject = 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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 = {
|
export const resetTransferStats = () => {
|
||||||
id: 'dummy-2',
|
transferStats.set({
|
||||||
name: 'Dev Buzzer (Offline)',
|
isActive: false,
|
||||||
forget: async () => {
|
currentFileName: '',
|
||||||
console.log('Forget dummy-2');
|
pendingFileName: '',
|
||||||
},
|
bytesDone: 0,
|
||||||
addEventListener: () => {},
|
bytesTotal: 0,
|
||||||
removeEventListener: () => {},
|
overallDone: 0,
|
||||||
watchAdvertisements: async () => {},
|
overallTotal: 0,
|
||||||
gatt: { connected: false, disconnect: () => {} },
|
bulkStartTime: 0,
|
||||||
} as unknown as BluetoothDevice;
|
fileStartTime: 0,
|
||||||
|
filesRemaining: 0
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
pairedDevices.update((devices) => {
|
let speedSamples: number[] = [];
|
||||||
if (!devices.find((d) => d.id === 'dummy-1')) {
|
let lastSampleTime = 0;
|
||||||
return [...devices, dummy1, dummy2];
|
let lastSampleBytes = 0;
|
||||||
|
let lastCalculatedSpeedKbs = 0;
|
||||||
|
|
||||||
|
let gapStartTime = 0;
|
||||||
|
let gapTimes: number[] = [];
|
||||||
|
let currentAverageGapMs = SETTINGS.ui.estimatedInterFileGapMs;
|
||||||
|
let wasActivelyTransferring = false;
|
||||||
|
|
||||||
|
export const transferDetails = derived(transferStats, ($s) => {
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
// Nur nullen, wenn wirklich kein Transfer mehr im Store steht.
|
||||||
|
// Erlaubt das saubere Ausfaden des UI mit 100% Werten, auch wenn isActive schon false ist.
|
||||||
|
if ($s.overallTotal === 0) {
|
||||||
|
speedSamples = [];
|
||||||
|
lastSampleTime = 0;
|
||||||
|
lastSampleBytes = 0;
|
||||||
|
lastCalculatedSpeedKbs = 0;
|
||||||
|
|
||||||
|
gapStartTime = 0;
|
||||||
|
gapTimes = [];
|
||||||
|
currentAverageGapMs = SETTINGS.ui.estimatedInterFileGapMs;
|
||||||
|
wasActivelyTransferring = false;
|
||||||
|
|
||||||
|
return { filePercent: 0, totalPercent: 0, speedKbs: 0, fileEta: Infinity, totalEta: Infinity };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActivelyTransferring = $s.bytesDone > 0 && $s.bytesDone < $s.bytesTotal;
|
||||||
|
|
||||||
|
// --- 1. Gap Calculation (Realer Overhead zwischen den Dateien) ---
|
||||||
|
if (!isActivelyTransferring) {
|
||||||
|
if (wasActivelyTransferring || gapStartTime === 0) {
|
||||||
|
gapStartTime = now;
|
||||||
}
|
}
|
||||||
return devices;
|
} else {
|
||||||
});
|
if (!wasActivelyTransferring && gapStartTime > 0) {
|
||||||
|
const gapMs = now - gapStartTime;
|
||||||
|
gapStartTime = 0;
|
||||||
|
if (gapMs > 0 && gapMs < 10000) { // Plausibilitäts-Check
|
||||||
|
gapTimes.push(gapMs);
|
||||||
|
currentAverageGapMs = gapTimes.reduce((a, b) => a + b, 0) / gapTimes.length;
|
||||||
|
console.debug(`[Transfer] Inter-file overhead gap: ${gapMs.toFixed(1)}ms. Neues Bulk-Average: ${currentAverageGapMs.toFixed(1)}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wasActivelyTransferring = isActivelyTransferring;
|
||||||
|
|
||||||
availableDevices.update((set) => {
|
// --- 2. Speed Calculation (Gleitender Durchschnitt) ---
|
||||||
const newSet = new Set(set);
|
if (isActivelyTransferring) {
|
||||||
newSet.add('dummy-1');
|
if (lastSampleTime > 0) {
|
||||||
return newSet;
|
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();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
369
webpage/src/lib/tagHandler.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
77
webpage/src/styles/app.css
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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%);
|
|
||||||
}
|
|
||||||