File upload. Yeah
This commit is contained in:
9
firmware/VERSION
Normal file
9
firmware/VERSION
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
VERSION_MAJOR = 0
|
||||||
|
VERSION_MINOR = 0
|
||||||
|
PATCHLEVEL = 1
|
||||||
|
VERSION_TWEAK = 0
|
||||||
|
#if (IS_ENABLED(CONFIG_LOG))
|
||||||
|
EXTRAVERSION = debug
|
||||||
|
#else
|
||||||
|
EXTRAVERSION = 0
|
||||||
|
#endif
|
||||||
@@ -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,
|
||||||
@@ -37,6 +38,10 @@ enum buzz_data_type
|
|||||||
|
|
||||||
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_FW_UPDATE = 0x30,
|
||||||
|
|
||||||
BUZZ_DATA_LS = 0x40,
|
BUZZ_DATA_LS = 0x40,
|
||||||
};
|
};
|
||||||
@@ -62,6 +67,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
|
||||||
{
|
{
|
||||||
@@ -142,4 +152,17 @@ 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 */
|
||||||
@@ -113,10 +113,58 @@ static void send_stream_error(buzz_transport_reply_fn reply_cb, uint16_t error_c
|
|||||||
buzz_proto_buf_free(&buf);
|
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));
|
||||||
@@ -124,10 +172,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öße 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)
|
||||||
@@ -228,7 +284,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);
|
||||||
@@ -251,7 +307,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)
|
||||||
{
|
{
|
||||||
@@ -260,7 +315,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)
|
||||||
@@ -270,7 +324,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_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;
|
||||||
@@ -282,12 +360,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)
|
||||||
{
|
{
|
||||||
@@ -434,18 +511,96 @@ 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_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_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;
|
||||||
|
|
||||||
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);
|
||||||
@@ -580,7 +735,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;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,30 @@ if FS_MGMT
|
|||||||
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,41 @@
|
|||||||
#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_MGMT_MAX_PATH_LENGTH 32
|
||||||
#define FS_AUDIO_PATH CONFIG_FS_MGMT_MOUNT_POINT "/a"
|
#define FS_AUDIO_PATH CONFIG_FS_MGMT_MOUNT_POINT CONFIG_FS_MGMT_AUDIO_SUBDIR
|
||||||
#define FS_SYSTEM_PATH CONFIG_FS_MGMT_MOUNT_POINT "/sys"
|
#define FS_SYSTEM_PATH CONFIG_FS_MGMT_MOUNT_POINT CONFIG_FS_MGMT_SYSTEM_SUBDIR
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Initializes the filesystem management module.
|
* @brief Initializes the filesystem management module.
|
||||||
*/
|
*/
|
||||||
int fs_mgmt_init(void);
|
int fs_mgmt_init(void);
|
||||||
|
|
||||||
// /**
|
|
||||||
// * @brief Puts the QSPI flash into deep sleep mode to save power
|
|
||||||
// */
|
|
||||||
// int fs_pm_flash_suspend(void);
|
|
||||||
|
|
||||||
// /**
|
/**
|
||||||
// * @brief Resumes the QSPI flash from deep sleep mode
|
* @brief OP-Codes for the FS write thread
|
||||||
// */
|
*/
|
||||||
// int fs_pm_flash_resume(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 Structure representing a write message for the FS write thread
|
||||||
|
*/
|
||||||
|
struct fs_write_msg {
|
||||||
|
enum fs_write_op op;
|
||||||
|
uint8_t *slab_ptr; /* Basis-Pointer des Memory-Slabs (für k_mem_slab_free) */
|
||||||
|
uint16_t data_offset; /* Offset ab dem slab_ptr, wo die Nutzdaten beginnen */
|
||||||
|
uint16_t data_len; /* Länge der Nutzdaten */
|
||||||
|
uint32_t metadata; /* Zusatzinfo (Start: erwartete Dateigröße, End: erwartete CRC32) */
|
||||||
|
buzz_transport_reply_fn reply_cb; /* Callback für ACKs / Success / Error */
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Wrapper around fs_open that handles power management for the flash
|
* @brief Wrapper around fs_open that handles power management for the flash
|
||||||
@@ -114,4 +130,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_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,12 @@
|
|||||||
#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"
|
||||||
|
|
||||||
LOG_MODULE_REGISTER(fs_mgmt, CONFIG_FS_MGMT_LOG_LEVEL);
|
LOG_MODULE_REGISTER(fs_mgmt, CONFIG_FS_MGMT_LOG_LEVEL);
|
||||||
|
|
||||||
@@ -13,6 +16,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 +29,34 @@ 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)
|
||||||
|
|
||||||
|
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[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
|
||||||
@@ -353,7 +387,8 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -371,3 +406,309 @@ int fs_mgmt_init(void)
|
|||||||
LOG_DBG("Filesystem mounted successfully");
|
LOG_DBG("Filesystem mounted successfully");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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_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 = 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_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);
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BuzzerFile } from "../lib/types";
|
import { type BuzzerFile, SyncState } from "../lib/types";
|
||||||
import {
|
import {
|
||||||
MusicNotesIcon,
|
MusicNotesIcon,
|
||||||
DotsThreeVerticalIcon,
|
DotsThreeVerticalIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
InfoIcon,
|
PencilIcon,
|
||||||
CircleIcon,
|
CircleIcon,
|
||||||
QuestionIcon,
|
QuestionIcon,
|
||||||
UserCircleCheckIcon,
|
|
||||||
WarningCircleIcon,
|
|
||||||
PersonIcon,
|
|
||||||
TagIcon,
|
TagIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
@@ -20,6 +17,7 @@
|
|||||||
transferDetails,
|
transferDetails,
|
||||||
buzzerAudioFiles,
|
buzzerAudioFiles,
|
||||||
localAudioFiles,
|
localAudioFiles,
|
||||||
|
syncStateMap,
|
||||||
} from "../lib/store";
|
} from "../lib/store";
|
||||||
|
|
||||||
import { SETTINGS } from "../lib/settings";
|
import { SETTINGS } from "../lib/settings";
|
||||||
@@ -43,6 +41,8 @@
|
|||||||
return "default";
|
return "default";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
$: syncStatus = $syncStateMap[type][file.name] || { state: SyncState.UNKNOWN, linkedFiles: [] };
|
||||||
|
|
||||||
function toggleSelection() {
|
function toggleSelection() {
|
||||||
if ($isFetchingRemote) return;
|
if ($isFetchingRemote) return;
|
||||||
|
|
||||||
@@ -102,27 +102,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-0 text-xs text-text-muted mt-0.5 min-w-0">
|
<div class="flex items-center gap-0 text-xs text-text-muted mt-0.5 min-w-0">
|
||||||
{#if file.sysTags?.crc32}
|
{#if syncStatus.state === SyncState.UNKNOWN}
|
||||||
<span
|
<span
|
||||||
use:tooltip={{
|
use:tooltip={{
|
||||||
text:
|
text:
|
||||||
"crc32: <span class='font-mono'>0x" +
|
"Dieser Datei fehlt der CRC32-Tag. Bitte aktualisiere über das Menu die Metadaten.",
|
||||||
file.sysTags.crc32.toString(16).toUpperCase() +
|
|
||||||
"</span>",
|
|
||||||
pos: "right",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircleIcon weight="fill" class="mr-1 shrink-0 text-emerald-600 w-3.5 h-3.5" />
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
use:tooltip={{
|
|
||||||
text: "Keine Prüfsumme in den Tags verfügbar. Bitte aktualisiere über das Menü die CRC32-Tags.",
|
|
||||||
pos: "right",
|
pos: "right",
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<QuestionIcon weight="fill" class="mr-1 shrink-0 text-amber-500 w-3.5 h-3.5" />
|
<CircleIcon weight="fill" class="mr-1 shrink-0 text-emerald-600 w-3.5 h-3.5" />
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -130,11 +119,17 @@
|
|||||||
{parseFloat((file.size / 1024).toFixed(1))} kB
|
{parseFloat((file.size / 1024).toFixed(1))} kB
|
||||||
</span>
|
</span>
|
||||||
{#if file.metaTags?.a}
|
{#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" />
|
<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>
|
<span class="truncate min-w-0">{file.metaTags.a}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if file.metaTags?.c}
|
{#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" />
|
<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>
|
<span class="truncate min-w-0">{file.metaTags.c}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +151,7 @@
|
|||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrashIcon class="w-5 h-5" />
|
<TrashIcon class="list-menu-icon" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="menu-btn"
|
class="menu-btn"
|
||||||
@@ -166,18 +161,17 @@
|
|||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlayIcon class="w-5 h-5" />
|
<PlayIcon class="list-menu-icon" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="menu-btn"
|
class="menu-btn"
|
||||||
title="Datei-Info"
|
title="Datei-Info"
|
||||||
on:click|stopPropagation={() => {
|
on:click|stopPropagation={() => {
|
||||||
console.log("Info", file.name);
|
|
||||||
tagEditorState.set({ show: true, type, fileName: file.name });
|
tagEditorState.set({ show: true, type, fileName: file.name });
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<InfoIcon class="w-5 h-5" />
|
<PencilIcon class="list-menu-icon" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,7 +179,7 @@
|
|||||||
class="menu-btn !border-r-transparent"
|
class="menu-btn !border-r-transparent"
|
||||||
on:click|stopPropagation={() => (menuOpen = !menuOpen)}
|
on:click|stopPropagation={() => (menuOpen = !menuOpen)}
|
||||||
>
|
>
|
||||||
<DotsThreeVerticalIcon class="w-5 h-5" />
|
<DotsThreeVerticalIcon class="list-menu-icon" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,17 +190,17 @@
|
|||||||
.menu-btn-grp {
|
.menu-btn-grp {
|
||||||
@apply absolute right-2 top-1/2 -translate-y-1/2 z-20 overflow-hidden
|
@apply absolute right-2 top-1/2 -translate-y-1/2 z-20 overflow-hidden
|
||||||
p-0 flex items-center backdrop-blur-sm
|
p-0 flex items-center backdrop-blur-sm
|
||||||
border border-transparent rounded-full transition-all;
|
rounded-full transition-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Kombiniert Hover von Mausnutzern und aktiven Touch-Zustand */
|
/* Kombiniert Hover von Mausnutzern und aktiven Touch-Zustand */
|
||||||
.menu-btn-grp:hover,
|
.menu-btn-grp:hover,
|
||||||
.menu-btn-grp.is-open {
|
.menu-btn-grp.is-open {
|
||||||
@apply border-border-card bg-white shadow-sm;
|
@apply bg-white shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-btn {
|
.menu-btn {
|
||||||
@apply p-1.5 border-r border-r-border-card
|
@apply border-r border-r-border-card
|
||||||
flex items-center justify-center shrink-0 transition-colors;
|
flex items-center justify-center shrink-0 transition-colors;
|
||||||
|
|
||||||
&:not(:disabled):not(.danger) {
|
&:not(:disabled):not(.danger) {
|
||||||
|
|||||||
138
webpage/src/components/FileListMenu.svelte
Normal file
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>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes}\u202B`;
|
if (bytes < 1024) return `${bytes}\u202FB`;
|
||||||
else if (bytes < 10 * 1024) return `${(bytes / 1024).toFixed(2)}\u202F\KiB`;
|
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 < 100 * 1024) return `${(bytes / 1024).toFixed(1)}\u202F\KiB`;
|
||||||
else if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}\u202F\KiB`;
|
else if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}\u202F\KiB`;
|
||||||
|
|||||||
@@ -311,7 +311,7 @@
|
|||||||
value={activeName}
|
value={activeName}
|
||||||
on:input={updateName}
|
on:input={updateName}
|
||||||
maxlength={maxFilenameLength}
|
maxlength={maxFilenameLength}
|
||||||
class="editor-input font-medium {getInputClass(
|
class="editor-input {getInputClass(
|
||||||
hasDraft && activeName !== currentFile.name,
|
hasDraft && activeName !== currentFile.name,
|
||||||
)}"
|
)}"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,37 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isConnected, fsInfo, tagEditorState } from "../lib/store";
|
import { isConnected, fsInfo, tagEditorState } from "../lib/store";
|
||||||
import {
|
import { GearIcon, CloudArrowUpIcon, DotsThreeVerticalIcon } from "phosphor-svelte";
|
||||||
GearIcon,
|
|
||||||
CloudArrowUpIcon,
|
|
||||||
ArrowClockwiseIcon,
|
|
||||||
DotsThreeVerticalIcon,
|
|
||||||
CheckSquareOffsetIcon,
|
|
||||||
SquareIcon,
|
|
||||||
DownloadIcon,
|
|
||||||
} from "phosphor-svelte";
|
|
||||||
import FileList from "./FileList.svelte";
|
import FileList from "./FileList.svelte";
|
||||||
import DeviceInfo from "./DeviceInfo.svelte";
|
import DeviceInfo from "./DeviceInfo.svelte";
|
||||||
import { refreshRemote, downloadSelectedFiles } from "../lib/sync";
|
import { refreshRemote } from "../lib/sync";
|
||||||
import {
|
import { transferStats, isFetchingRemote, pairedDevices, activeDeviceId } from "../lib/store";
|
||||||
buzzerAudioFiles,
|
|
||||||
buzzerFilesCount,
|
|
||||||
selectedBuzzerFilesCount,
|
|
||||||
transferStats,
|
|
||||||
isFetchingRemote,
|
|
||||||
pairedDevices,
|
|
||||||
activeDeviceId,
|
|
||||||
transferDetails,
|
|
||||||
} from "../lib/store";
|
|
||||||
import { SETTINGS } from "../lib/settings";
|
import { SETTINGS } from "../lib/settings";
|
||||||
import TransferProgress from "./TransferProgress.svelte";
|
import TransferProgress from "./TransferProgress.svelte";
|
||||||
import FileMenuOverlay from "./FileMenuOverlay.svelte";
|
import FileMenuOverlay from "./FileMenuOverlay.svelte";
|
||||||
import FileTagEditor from "./FileTagEditor.svelte";
|
import FileTagEditor from "./FileTagEditor.svelte";
|
||||||
|
import FileListMenu from "./FileListMenu.svelte";
|
||||||
|
|
||||||
let showOverlay = false;
|
let showOverlay = false;
|
||||||
let isTransferFinished = false;
|
let isTransferFinished = false;
|
||||||
let overlayTimeout: ReturnType<typeof setTimeout>;
|
let overlayTimeout: ReturnType<typeof setTimeout>;
|
||||||
let showBuzzerMenu = false;
|
|
||||||
let showLocalMenu = false;
|
let showLocalMenu = false;
|
||||||
|
let showBuzzerMenu = false;
|
||||||
let editModeType: "local" | "buzzer" | null = null;
|
let editModeType: "local" | "buzzer" | null = null;
|
||||||
let fileToEdit: string | null = null;
|
let fileToEdit: string | null = null;
|
||||||
|
|
||||||
@@ -117,13 +101,7 @@
|
|||||||
<section class="buzzer-card relative self-start">
|
<section class="buzzer-card relative self-start">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Lokale Bibliothek</h3>
|
<h3 class="card-title">Lokale Bibliothek</h3>
|
||||||
<button
|
<FileListMenu type="local" onToggleMenu={() => (showLocalMenu = !showLocalMenu)} />
|
||||||
class="btn"
|
|
||||||
aria-label="Menu"
|
|
||||||
on:click|stopPropagation={() => (showLocalMenu = !showLocalMenu)}
|
|
||||||
>
|
|
||||||
<DotsThreeVerticalIcon class="icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="col-start-1 row-start-1">
|
<div class="col-start-1 row-start-1">
|
||||||
@@ -145,75 +123,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-start-1 row-start-1 z-20" class:pointer-events-none={!showLocalMenu}>
|
<div class="col-start-1 row-start-1 z-20" class:pointer-events-none={!showLocalMenu}>
|
||||||
<FileMenuOverlay type="local" show={showLocalMenu} onClose={() => (showLocalMenu = false)} />
|
<FileMenuOverlay
|
||||||
|
type="local"
|
||||||
|
show={showLocalMenu}
|
||||||
|
onClose={() => (showLocalMenu = false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="buzzer-card relative self-start">
|
<section class="buzzer-card relative self-start">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div>
|
|
||||||
<h3 class="card-title">
|
<h3 class="card-title">
|
||||||
Gerätebibliothek <span class="text-text-muted text-xs font-mono">
|
Gerätebibliothek <span class="text-text-muted text-xs font-mono">
|
||||||
{$fsInfo?.audioPath}
|
{$fsInfo?.audioPath}
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
<FileListMenu type="buzzer" onToggleMenu={() => (showBuzzerMenu = !showBuzzerMenu)} />
|
||||||
<div>
|
|
||||||
<div class="btn-connect-group group">
|
|
||||||
<button
|
|
||||||
class="btn-main"
|
|
||||||
aria-label="Alle ausgewählten Dateien herunterladen"
|
|
||||||
on:click={() => {
|
|
||||||
downloadSelectedFiles();
|
|
||||||
}}
|
|
||||||
disabled={!$isConnected || $selectedBuzzerFilesCount === 0}
|
|
||||||
title="Alle ausgewählten Dateien herunterladen"
|
|
||||||
>
|
|
||||||
<DownloadIcon class="icon" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-main"
|
|
||||||
aria-label="Alles Auswählen"
|
|
||||||
on:click={() => {
|
|
||||||
buzzerAudioFiles.update((files) => files.map((f) => ({ ...f, selected: true })));
|
|
||||||
}}
|
|
||||||
disabled={!$isConnected || $buzzerFilesCount === $selectedBuzzerFilesCount}
|
|
||||||
title="Alle auswählen"
|
|
||||||
>
|
|
||||||
<CheckSquareOffsetIcon class="icon" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-main"
|
|
||||||
on:click={() => {
|
|
||||||
buzzerAudioFiles.update((files) => files.map((f) => ({ ...f, selected: false })));
|
|
||||||
}}
|
|
||||||
aria-label="Auswahl löschen"
|
|
||||||
disabled={!$isConnected || $selectedBuzzerFilesCount === 0}
|
|
||||||
title="Auswahl aufheben"
|
|
||||||
>
|
|
||||||
<SquareIcon class="icon" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-main"
|
|
||||||
on:click={() => refreshRemote()}
|
|
||||||
aria-label="Reload"
|
|
||||||
disabled={!$isConnected}
|
|
||||||
title="Dateiliste neu laden"
|
|
||||||
>
|
|
||||||
<ArrowClockwiseIcon class="icon" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-main"
|
|
||||||
aria-label="Menu"
|
|
||||||
disabled={!$isConnected}
|
|
||||||
title="Menu"
|
|
||||||
on:click|stopPropagation={() => (showBuzzerMenu = !showBuzzerMenu)}
|
|
||||||
>
|
|
||||||
<DotsThreeVerticalIcon class="icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="col-start-1 row-start-1">
|
<div class="col-start-1 row-start-1">
|
||||||
@@ -222,6 +148,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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}>
|
<div class="col-start-1 row-start-1 z-20" class:pointer-events-none={!showBuzzerMenu}>
|
||||||
<FileMenuOverlay
|
<FileMenuOverlay
|
||||||
type="buzzer"
|
type="buzzer"
|
||||||
@@ -247,20 +184,4 @@
|
|||||||
@apply cursor-not-allowed;
|
@apply cursor-not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.btn-connect-group {
|
|
||||||
@apply flex items-stretch rounded-full overflow-hidden border-transparent transition-all hover:border-slate-200 hover:shadow-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-main {
|
|
||||||
@apply border-r-1 border-r-transparent last:border-r-0 focus:outline-none;
|
|
||||||
|
|
||||||
&:not(:disabled) {
|
|
||||||
@apply hover:border-border-card hover:shadow-sm hover:bg-slate-200 cursor-pointer group-hover:border-r-slate-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
color: color-mix(in srgb, currentColor 50%, transparent);
|
|
||||||
@apply cursor-not-allowed group-hover:border-r-slate-200;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -58,3 +58,15 @@ export async function deleteLocalFile(name: string): Promise<void> {
|
|||||||
request.onerror = () => reject(request.error);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -26,7 +28,8 @@ export const DATA = {
|
|||||||
|
|
||||||
FILE_GET: 0x20,
|
FILE_GET: 0x20,
|
||||||
FILE_PUT: 0x21,
|
FILE_PUT: 0x21,
|
||||||
|
TAGS_GET: 0x22,
|
||||||
|
TAGS_PUT: 0x23,
|
||||||
|
|
||||||
LS: 0x40
|
LS: 0x40
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { crc32 } from './crc32';
|
|||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { saveLocalFile } from '../db';
|
import { saveLocalFile } from '../db';
|
||||||
import { refreshLocal } from '../sync';
|
import { refreshLocal } from '../sync';
|
||||||
|
import { file } from 'astro:schema';
|
||||||
|
|
||||||
let lastUiUpdate = 0;
|
let lastUiUpdate = 0;
|
||||||
let currentFileCrc32 = 0;
|
let currentFileCrc32 = 0;
|
||||||
@@ -17,7 +18,7 @@ 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) {
|
||||||
@@ -87,7 +88,6 @@ 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');
|
||||||
@@ -104,6 +104,7 @@ case FRAME.FILE_START:
|
|||||||
const nowStart = performance.now();
|
const nowStart = performance.now();
|
||||||
fileChunks = [];
|
fileChunks = [];
|
||||||
|
|
||||||
|
if (fileTransfer.mode === 'file') {
|
||||||
transferStats.update(s => ({
|
transferStats.update(s => ({
|
||||||
...s,
|
...s,
|
||||||
bytesTotal: totalBytes,
|
bytesTotal: totalBytes,
|
||||||
@@ -112,6 +113,7 @@ case FRAME.FILE_START:
|
|||||||
fileStartTime: nowStart,
|
fileStartTime: nowStart,
|
||||||
bulkStartTime: s.bulkStartTime === 0 ? nowStart : s.bulkStartTime
|
bulkStartTime: s.bulkStartTime === 0 ? nowStart : s.bulkStartTime
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Parser-interne Metriken (Watchdog etc.)
|
// Parser-interne Metriken (Watchdog etc.)
|
||||||
fileTransfer.totalBytes = totalBytes;
|
fileTransfer.totalBytes = totalBytes;
|
||||||
@@ -120,8 +122,6 @@ case FRAME.FILE_START:
|
|||||||
fileTransfer.startTime = nowStart;
|
fileTransfer.startTime = nowStart;
|
||||||
lastUiUpdate = 0;
|
lastUiUpdate = 0;
|
||||||
|
|
||||||
console.log(`[FILE_GET] Stream gestartet. Erwartete Größe: ${fileTransfer.totalBytes} Bytes.`);
|
|
||||||
|
|
||||||
fileTransfer.metricsTimer = setInterval(() => {
|
fileTransfer.metricsTimer = setInterval(() => {
|
||||||
if (!fileTransfer.active) return;
|
if (!fileTransfer.active) return;
|
||||||
|
|
||||||
@@ -166,18 +166,19 @@ case FRAME.FILE_START:
|
|||||||
fileTransfer.receivedBytes += payloadLength;
|
fileTransfer.receivedBytes += payloadLength;
|
||||||
fileTransfer.credits--;
|
fileTransfer.credits--;
|
||||||
|
|
||||||
|
if (fileTransfer.mode === 'file') {
|
||||||
const nowChunk = performance.now();
|
const nowChunk = performance.now();
|
||||||
if (nowChunk - lastUiUpdate > SETTINGS.ui.transferUpdateIntervalMs) {
|
if (nowChunk - lastUiUpdate > SETTINGS.ui.transferUpdateIntervalMs) {
|
||||||
const delta = fileTransfer.receivedBytes - previousReceived; // Das Delta seit dem letzten Paket
|
const delta = fileTransfer.receivedBytes - previousReceived;
|
||||||
|
|
||||||
transferStats.update(s => ({
|
transferStats.update(s => ({
|
||||||
...s,
|
...s,
|
||||||
bytesDone: fileTransfer.receivedBytes,
|
bytesDone: fileTransfer.receivedBytes,
|
||||||
overallDone: s.overallDone + (fileTransfer.receivedBytes - s.bytesDone)
|
overallDone: s.overallDone + (fileTransfer.receivedBytes - s.bytesDone)
|
||||||
}));
|
}));
|
||||||
console.log("[FILE_GET] Fortschritt: " + ((fileTransfer.receivedBytes / fileTransfer.totalBytes) * 100).toFixed(2) + "%");
|
|
||||||
lastUiUpdate = nowChunk;
|
lastUiUpdate = nowChunk;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (fileTransfer.credits <= 64) {
|
if (fileTransfer.credits <= 64) {
|
||||||
fileTransfer.credits = 128;
|
fileTransfer.credits = 128;
|
||||||
@@ -186,53 +187,74 @@ case FRAME.FILE_START:
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case FRAME.FILE_END:
|
case FRAME.FILE_END:
|
||||||
transferStats.update(s => {
|
if (fileTransfer.mode === 'file') {
|
||||||
return {
|
transferStats.update(s => ({
|
||||||
...s,
|
...s,
|
||||||
bytesDone: s.bytesTotal,
|
bytesDone: s.bytesTotal,
|
||||||
};
|
}));
|
||||||
});
|
}
|
||||||
// Watchdog stoppen
|
|
||||||
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
|
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
|
||||||
fileTransfer.active = false;
|
fileTransfer.active = false;
|
||||||
|
|
||||||
const buzzerCrc32 = view.getUint32(3, true);
|
const buzzerCrc32 = view.getUint32(3, true);
|
||||||
|
|
||||||
console.log(`[CRC] Lokal: 0x${currentFileCrc32.toString(16).toUpperCase()}`);
|
|
||||||
console.log(`[CRC] Buzzer: 0x${buzzerCrc32.toString(16).toUpperCase()}`);
|
|
||||||
const totalElapsed = (performance.now() - fileTransfer.startTime) / 1000;
|
const totalElapsed = (performance.now() - fileTransfer.startTime) / 1000;
|
||||||
const avgSpeed = (fileTransfer.receivedBytes / 1024) / totalElapsed;
|
const avgSpeed = (fileTransfer.receivedBytes / 1024) / totalElapsed;
|
||||||
|
|
||||||
console.log(`[FILE_GET] Stream beendet.`);
|
|
||||||
console.log(`[FILE_GET] Empfangen: ${fileTransfer.receivedBytes} Bytes in ${totalElapsed.toFixed(2)}s.`);
|
|
||||||
console.log(`[FILE_GET] Durchschnitt: ${avgSpeed.toFixed(2)} KB/s`);
|
|
||||||
|
|
||||||
if (currentFileCrc32 === buzzerCrc32) {
|
if (currentFileCrc32 === buzzerCrc32) {
|
||||||
console.log("%c[CRC] Match! Datei ist integer.", "color: green; font-weight: bold;");
|
const fileBlob = new Blob(fileChunks, { type: 'application/octet-stream' });
|
||||||
const fileBlob = new Blob(fileChunks, { type: 'audio/wav' });
|
|
||||||
|
if (fileTransfer.mode === 'file') {
|
||||||
const fileName = get(transferStats).currentFileName;
|
const fileName = get(transferStats).currentFileName;
|
||||||
saveLocalFile(fileName, fileBlob, fileTransfer.totalBytes)
|
saveLocalFile(fileName, fileBlob, fileTransfer.totalBytes)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log(`Datei ${fileName} erfolgreich lokal gespeichert.`);
|
|
||||||
refreshLocal();
|
refreshLocal();
|
||||||
|
if (fileGetResolve) {
|
||||||
|
fileGetResolve({ success: true });
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Datenbankfehler:", err);
|
console.error("Datenbankfehler:", err);
|
||||||
addToast(`Speichern von ${fileName} fehlgeschlagen.`, 'error');
|
addToast(`Speichern von ${fileName} fehlgeschlagen.`, 'error');
|
||||||
|
if (fileGetReject) fileGetReject(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
fileGetResolve = null;
|
||||||
|
fileGetReject = null;
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// TAGS Modus: Blob direkt zurückgeben, nichts speichern
|
||||||
|
if (fileGetResolve) fileGetResolve({ success: true, blob: fileBlob });
|
||||||
|
fileGetResolve = null;
|
||||||
|
fileGetReject = null;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error("[CRC] Mismatch! Datei beschädigt.");
|
console.error("[CRC] Mismatch! Datei beschädigt.");
|
||||||
addToast("CRC Fehler: Datei wurde fehlerhaft übertragen.", "error");
|
addToast("CRC Fehler: Datei wurde fehlerhaft übertragen.", "error");
|
||||||
if (fileGetReject) fileGetReject(new Error("CRC Mismatch"));
|
if (fileGetReject) fileGetReject(new Error("CRC Mismatch"));
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileGetResolve) {
|
|
||||||
fileGetResolve(true);
|
|
||||||
fileGetResolve = null;
|
fileGetResolve = null;
|
||||||
fileGetReject = null;
|
fileGetReject = null;
|
||||||
}
|
}
|
||||||
break;
|
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;
|
||||||
|
|
||||||
case FRAME.ERROR:
|
case FRAME.ERROR:
|
||||||
const errorCode = view.getUint16(3, true);
|
const errorCode = view.getUint16(3, true);
|
||||||
console.error(`Received error frame with code: 0x${errorCode.toString(16)}`);
|
console.error(`Received error frame with code: 0x${errorCode.toString(16)}`);
|
||||||
@@ -249,6 +271,9 @@ case FRAME.FILE_START:
|
|||||||
fileGetResolve = null;
|
fileGetResolve = null;
|
||||||
fileGetReject = null;
|
fileGetReject = null;
|
||||||
}
|
}
|
||||||
|
if (uploadState.active && uploadState.onError) {
|
||||||
|
uploadState.onError(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -304,7 +329,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);
|
||||||
@@ -332,6 +356,7 @@ 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,
|
||||||
@@ -341,9 +366,22 @@ const fileTransfer = {
|
|||||||
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 {
|
||||||
@@ -361,3 +399,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,6 +1,7 @@
|
|||||||
import { writable, derived } from 'svelte/store';
|
import { writable, derived } from 'svelte/store';
|
||||||
import type { BuzzerFile } from './types';
|
import type { BuzzerFile } from './types';
|
||||||
import { SETTINGS } from './settings';
|
import { SETTINGS } from './settings';
|
||||||
|
import { syncState, type SyncStatus } from './sync';
|
||||||
|
|
||||||
const CONNECTION_STATE_KEY = 'buzzer_connection_state';
|
const CONNECTION_STATE_KEY = 'buzzer_connection_state';
|
||||||
|
|
||||||
@@ -231,3 +232,95 @@ export const tagEditorState = writable<{show: boolean, type: "local" | "buzzer",
|
|||||||
type: "buzzer",
|
type: "buzzer",
|
||||||
fileName: ""
|
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;
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -2,11 +2,12 @@ import { get } from 'svelte/store';
|
|||||||
import { isConnected, fsInfo, buzzerAudioFiles, buzzerSysFiles, isFetchingRemote, isFetchingLocal, storageUsage, localAudioFiles, transferStats } from './store';
|
import { isConnected, fsInfo, buzzerAudioFiles, buzzerSysFiles, isFetchingRemote, isFetchingLocal, storageUsage, localAudioFiles, transferStats } from './store';
|
||||||
import { requestProtocolInfo, requestFSInfo, fetchDirectory } from './transport';
|
import { requestProtocolInfo, requestFSInfo, fetchDirectory } from './transport';
|
||||||
import type { BuzzerFile } from './types';
|
import type { BuzzerFile } from './types';
|
||||||
import { getFile } from './transport';
|
import { getFile, putFile } from './transport';
|
||||||
import { addToast } from './toast';
|
import { addToast } from './toast';
|
||||||
import { getLocalFiles, deleteLocalFile } from './db';
|
import { getLocalFiles, deleteLocalFile, getLocalFile } from './db';
|
||||||
import { parseLocalFileTags } from './tagHandler';
|
import { parseAudioFileTags } from './tagHandler';
|
||||||
import { SETTINGS } from './settings';
|
import { SETTINGS } from './settings';
|
||||||
|
import { fetchFileTags } from './tagHandler';
|
||||||
|
|
||||||
function mapToBuzzerFile(rawFile: any): BuzzerFile {
|
function mapToBuzzerFile(rawFile: any): BuzzerFile {
|
||||||
return {
|
return {
|
||||||
@@ -38,14 +39,30 @@ 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);
|
isFetchingRemote.set(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +75,7 @@ export async function refreshLocal() {
|
|||||||
|
|
||||||
// Paralleles Parsen aller Blobs in der lokalen Datenbank
|
// Paralleles Parsen aller Blobs in der lokalen Datenbank
|
||||||
const files: BuzzerFile[] = await Promise.all(dbFiles.map(async (record) => {
|
const files: BuzzerFile[] = await Promise.all(dbFiles.map(async (record) => {
|
||||||
const { sysTags, metaTags } = await parseLocalFileTags(record.blob, record.name);
|
const { sysTags, metaTags } = await parseAudioFileTags(record.blob, record.name);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: record.name,
|
name: record.name,
|
||||||
@@ -74,6 +91,7 @@ export async function refreshLocal() {
|
|||||||
localAudioFiles.set(files);
|
localAudioFiles.set(files);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Laden der lokalen Datenbank:", 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);
|
||||||
}
|
}
|
||||||
@@ -102,7 +120,7 @@ export async function downloadSelectedFiles() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
console.log(`Starte Download von: ${file.name}`);
|
console.debug(`Starte Download von: ${file.name}`);
|
||||||
|
|
||||||
transferStats.update(s => ({ ...s, pendingFileName: file.name }));
|
transferStats.update(s => ({ ...s, pendingFileName: file.name }));
|
||||||
|
|
||||||
@@ -122,9 +140,8 @@ for (const file of files) {
|
|||||||
} finally {
|
} finally {
|
||||||
transferStats.update(s => ({
|
transferStats.update(s => ({
|
||||||
...s,
|
...s,
|
||||||
overallDone: s.overallTotal, // Schließt den Ladebalken visuell sauber ab
|
overallDone: s.overallTotal,
|
||||||
}));
|
}));
|
||||||
// Das console.log für den Live-Speed wurde entfernt, da es hier falsche Werte liefert
|
|
||||||
isFetchingRemote.set(false);
|
isFetchingRemote.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,3 +162,60 @@ export async function deleteSelectedLocalFiles() {
|
|||||||
console.error("Fehler beim Löschen lokaler Dateien:", error);
|
console.error("Fehler beim Löschen lokaler Dateien:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
overallTotal: totalBytes,
|
||||||
|
overallDone: 0,
|
||||||
|
bulkStartTime: bulkStart
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Wir nutzen isFetchingRemote als generischen "Transfer aktiv"-Trigger für das UI TODO: Namensänderung in isTransferring?
|
||||||
|
isFetchingRemote.set(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
console.debug(`Starte Upload von: ${file.name} (${(file.size / 1024).toFixed(1)} kB)`);
|
||||||
|
transferStats.update(s => ({ ...s, pendingFileName: file.name }));
|
||||||
|
|
||||||
|
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 new Promise(r => setTimeout(r, SETTINGS.ui.transferUpdateIntervalMs));
|
||||||
|
|
||||||
|
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,
|
||||||
|
overallDone: s.overallTotal, // Schließt den Ladebalken visuell sauber ab
|
||||||
|
}));
|
||||||
|
isFetchingRemote.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { getLocalFiles, saveLocalFile, deleteLocalFile } from './db';
|
|||||||
import type { SystemTags, MetadataTags } from './types';
|
import type { SystemTags, MetadataTags } from './types';
|
||||||
import { addToast } from './toast';
|
import { addToast } from './toast';
|
||||||
import { crc32 } from './protocol/crc32';
|
import { crc32 } from './protocol/crc32';
|
||||||
|
import { getTags, putTags } from './transport';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { fsInfo } from './store';
|
||||||
|
|
||||||
function getEmptyTags() {
|
function getEmptyTags() {
|
||||||
return {
|
return {
|
||||||
@@ -10,7 +13,7 @@ function getEmptyTags() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseLocalFileTags(blob: Blob, filename: string): Promise<{ sysTags: SystemTags, metaTags: MetadataTags }> {
|
export async function parseAudioFileTags(blob: Blob, filename: string): Promise<{ sysTags: SystemTags, metaTags: MetadataTags }> {
|
||||||
const MAGIC = "TAG!";
|
const MAGIC = "TAG!";
|
||||||
const VERSION = 1;
|
const VERSION = 1;
|
||||||
|
|
||||||
@@ -197,6 +200,33 @@ export async function updateLocalFile(oldName: string, newName: string, sysTags:
|
|||||||
await saveLocalFile(newName, finalBlob, finalBlob.size);
|
await saveLocalFile(newName, finalBlob, finalBlob.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateRemoteFile(
|
||||||
|
oldName: string,
|
||||||
|
newName: string,
|
||||||
|
sysTags: SystemTags,
|
||||||
|
newMetaTags: MetadataTags
|
||||||
|
): Promise<void> {
|
||||||
|
// Da das Binärprotokoll noch kein echtes "Rename"-Kommando hat,
|
||||||
|
// blockieren wir Dateinamen-Änderungen für den Buzzer vorerst.
|
||||||
|
if (oldName !== newName) {
|
||||||
|
throw new Error("Das Umbenennen von Dateien direkt auf dem Buzzer wird noch nicht unterstützt. Bitte speichere nur die Tags.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Den binären TLV-Block inkl. Footer bauen
|
||||||
|
const newTagsBuffer = buildTagBlock(sysTags, newMetaTags);
|
||||||
|
const tagsBlob = new Blob([newTagsBuffer]);
|
||||||
|
|
||||||
|
// 2. Zielpfad ermitteln
|
||||||
|
const currentFsInfo = get(fsInfo);
|
||||||
|
const basePath = currentFsInfo?.audioPath || "/lfs/a";
|
||||||
|
const fullPath = `${basePath}/${oldName}`;
|
||||||
|
|
||||||
|
console.log(`Sende modifizierte Tags (${tagsBlob.size} Bytes) an ${fullPath}...`);
|
||||||
|
|
||||||
|
// 3. Über das Protokoll an den Buzzer senden
|
||||||
|
await putTags(tagsBlob, fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateFile(
|
export async function updateFile(
|
||||||
oldName: string,
|
oldName: string,
|
||||||
newName: string,
|
newName: string,
|
||||||
@@ -207,8 +237,7 @@ export async function updateFile(
|
|||||||
if (type === "local") {
|
if (type === "local") {
|
||||||
await updateLocalFile(oldName, newName, sysTags, newMetaTags);
|
await updateLocalFile(oldName, newName, sysTags, newMetaTags);
|
||||||
} else {
|
} else {
|
||||||
// TODO: Später hier die BLE-Kommandos (FILE_PUT oder spezifisches Tag-Update) einbauen
|
await updateRemoteFile(oldName, newName, sysTags, newMetaTags);
|
||||||
throw new Error("Das Speichern von Tags auf dem Buzzer ist über das Protokoll noch nicht implementiert.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,13 +253,21 @@ export async function fetchFileTags(
|
|||||||
const record = files.find(f => f.name === filename);
|
const record = files.find(f => f.name === filename);
|
||||||
if (!record) throw new Error(`Datei ${filename} lokal nicht gefunden.`);
|
if (!record) throw new Error(`Datei ${filename} lokal nicht gefunden.`);
|
||||||
|
|
||||||
return await parseLocalFileTags(record.blob, filename);
|
return await parseAudioFileTags(record.blob, filename);
|
||||||
} else {
|
} else {
|
||||||
// TODO: Später hier den BLE-Abruf der letzten Bytes (Footer + TLV) implementieren
|
try {
|
||||||
// Solange das nicht geht, geben wir ein leeres Objekt zurück
|
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();
|
return getEmptyTags();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function calculateLocalAudioCrc(filename: string): Promise<number> {
|
export async function calculateLocalAudioCrc(filename: string): Promise<number> {
|
||||||
const files = await getLocalFiles();
|
const files = await getLocalFiles();
|
||||||
@@ -267,7 +304,7 @@ export async function updateLocalAudioCrc(filename: string): Promise<number> {
|
|||||||
if (!record) throw new Error(`Datei ${filename} nicht gefunden.`);
|
if (!record) throw new Error(`Datei ${filename} nicht gefunden.`);
|
||||||
|
|
||||||
// Tags auslesen
|
// Tags auslesen
|
||||||
const { sysTags, metaTags } = await parseLocalFileTags(record.blob, filename);
|
const { sysTags, metaTags } = await parseAudioFileTags(record.blob, filename);
|
||||||
|
|
||||||
// Neue CRC berechnen
|
// Neue CRC berechnen
|
||||||
const newCrc = await calculateLocalAudioCrc(filename);
|
const newCrc = await calculateLocalAudioCrc(filename);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { buildLSRequest, buildProtocolInfoRequest, buildFSInfoRequest, setLsResolver } from './protocol/parser';
|
import { buildLSRequest, buildProtocolInfoRequest, buildFSInfoRequest, 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';
|
||||||
|
|
||||||
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
|
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
|
||||||
@@ -78,7 +81,7 @@ export async function getFile(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 +93,260 @@ export async function getFile(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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (z.B. alle 100ms)
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - lastUiUpdate > 100) {
|
||||||
|
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 }));
|
||||||
|
|
||||||
|
// 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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,10 @@
|
|||||||
@apply w-7 h-7 p-1;
|
@apply w-7 h-7 p-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buzzer-card .list-menu-icon {
|
||||||
|
@apply w-8 h-8 p-2;
|
||||||
|
}
|
||||||
|
|
||||||
.card-header .btn {
|
.card-header .btn {
|
||||||
@apply border rounded border-transparent hover:bg-slate-200 hover:border-border-card hover:shadow-sm;
|
@apply border rounded border-transparent hover:bg-slate-200 hover:border-border-card hover:shadow-sm;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user