diff --git a/README.md b/README.md index 55d8a4e..34fa4cf 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ Display + 3 Taster, Oberfläche mit LVGL. - 160×128-TFT-Display mit LVGL-Oberfläche (Liste, Statuszeile, Aktivitätsanzeige) - Alphabetisch sortierte Image-Liste - Manuelles Neuladen der Liste per Tastenkombination (z.B. nach SD-Kartenwechsel) +- Auswerfen: erneutes Drücken auf das gemountete Image wirft es aus + (der Host sieht ein leeres Laufwerk, ohne dass man den Floppinator abziehen muss) +- Autostart-Image: ein Image mit `-` vor dem Dateinamen ist direkt nach dem + Einschalten gemountet - Aktivitäts-LED (Onboard-LED) leuchtet bei Lese-/Schreibzugriffen - Debug-Ausgaben über USB-Seriell, zentral abschaltbar @@ -160,6 +164,7 @@ GRAM und sichtbarem Glas). Falls das auftritt: Werte wie `(0, 0)`, |-----------------------------------|------------------------------| | In der Liste navigieren | Zurück / Weiter | | Fokussiertes Image mounten | Auswahl | +| Gemountetes Image auswerfen | Auswahl auf dem bereits gemounteten (grün markierten) Image | | Image-Liste neu laden (z.B. nach SD-Kartenwechsel) | Zurück + Weiter gleichzeitig | Das SD-Modul hat keinen Card-Detect-Pin — ein Kartenwechsel im laufenden @@ -168,6 +173,44 @@ automatischer Erkennung per periodischem Polling haben sich als unzuverlässig erwiesen). Nach einem Kartenwechsel einfach kurz Zurück+Weiter drücken. +### Auswerfen + +Drückt man **Auswahl** auf dem Image, das gerade gemountet ist (in der +Liste mit grünem Balken links markiert, in der Statuszeile mit Haken), wird es ausgeworfen. +Der Host sieht dann ein Laufwerk ohne Medium, als hätte man die Diskette +gezogen. Das ist z.B. am Ende der MS-DOS-Installation nützlich, wenn +verlangt wird, alle Disketten zu entfernen. Ein anderes Image mountet man +weiterhin direkt per Auswahl, ein vorheriges Auswerfen ist dafür nicht nötig. + +Das Laufwerk bleibt leer, bis wieder ein Image gemountet wird oder der +Floppinator neu startet (dann greift ggf. das Autostart-Image, siehe unten). + +### Autostart-Image + +Ein Image kann so markiert werden, dass es direkt nach dem Einschalten +gemountet ist, und zwar schon bevor der Host das Laufwerk zum ersten Mal +sieht. Das hilft, wenn der Host sofort von Diskette booten will und keine +Zeit bleibt, von Hand ein Image zu wählen. + +Die Markierung ist ein **`-` vor dem Dateinamen**: + +``` +-msdos622-boot.img -> Autostart-Image, erscheint in der Liste als "msdos622-boot.img" +msdos622-boot.img -> normales Image +``` + +- In der Liste und in der Statuszeile wird der Name ohne `-` angezeigt, + das Autostart-Image trägt ein Power-Symbol statt des Datei-Symbols. Die + Sortierung bleibt alphabetisch nach dem Namen ohne `-`. +- Markieren und Entfernen der Markierung geschieht durch Umbenennen der + Datei am PC. In der Shell braucht es dafür `--`, weil der Name sonst wie + eine Option aussieht: `mv -- msdos622-boot.img -msdos622-boot.img` +- Sind mehrere Images markiert, wird das alphabetisch erste gemountet. +- Der Autostart greift nur beim Einschalten bzw. Neustart. Nach dem + Neuladen der Liste per Zurück+Weiter startet der Floppinator wie bisher + ohne Medium. +- Der Dateiname darf inklusive `-` höchstens 31 Zeichen lang sein. + ## Bekannte Einschränkungen - Kein automatisches Remounten des zuletzt gewählten Images nach einem diff --git a/floppinator/floppinator.ino b/floppinator/floppinator.ino index ff32a0e..a3d4c16 100644 --- a/floppinator/floppinator.ino +++ b/floppinator/floppinator.ino @@ -54,7 +54,7 @@ #define ACTIVITY_LED_PIN LED_BUILTIN -#define DEBUG_ENABLED true +#define DEBUG_ENABLED false #if DEBUG_ENABLED #define DBG_PRINT(...) do { if (Serial) { Serial.print(__VA_ARGS__); } } while (0) @@ -64,7 +64,8 @@ #define DBG_PRINTLN(...) do {} while (0) #endif -#define MAX_IMAGES 16 +#define MAX_IMAGES 64 +#define AUTOSTART_PREFIX '-' // Dateiname beginnt mit '-' = Autostart-Image #define SCREEN_W 160 #define SCREEN_H 128 @@ -128,6 +129,21 @@ bool buttonPressed(Button &b) return false; } +// --- Autostart-Markierung --- +// Ein fuehrendes '-' im Dateinamen markiert das Image, das direkt nach dem +// Einschalten gemountet wird. In der Oberflaeche und beim Sortieren wird der +// Name ohne '-' verwendet, geoeffnet wird die Datei mit ihrem echten Namen. + +inline bool isAutostartName(const char *name) +{ + return name[0] == AUTOSTART_PREFIX; +} + +inline const char *displayName(const char *name) +{ + return isAutostartName(name) ? name + 1 : name; +} + // --- LVGL: Display-Flush + Tasten-Eingabegeraet --- static lv_display_t *lvDisplay; @@ -283,6 +299,14 @@ void buildUi() lv_obj_set_style_border_width(imageList, 0, 0); lv_obj_set_style_pad_all(imageList, 2, 0); lv_obj_set_style_pad_row(imageList, 2, 0); + // Flache Liste ohne Theme-Rundungen/Schatten; schmale Scrollleiste + lv_obj_set_style_radius(imageList, 0, 0); + lv_obj_set_style_bg_opa(imageList, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_width(imageList, 0, 0); + lv_obj_set_style_outline_width(imageList, 0, 0); + lv_obj_set_style_width(imageList, 3, LV_PART_SCROLLBAR); + lv_obj_set_style_bg_color(imageList, lv_color_hex(0x606060), LV_PART_SCROLLBAR); + lv_obj_set_style_bg_opa(imageList, LV_OPA_COVER, LV_PART_SCROLLBAR); // Fusszeile lv_obj_t *footer = lv_obj_create(scr); @@ -312,7 +336,11 @@ void listButtonEventCb(lv_event_t *e) lv_obj_t *btn = (lv_obj_t *)lv_event_get_target(e); int idx = (int)(uintptr_t)lv_obj_get_user_data(btn); - if (idx != mountedIndex) + if (idx == mountedIndex) + { + ejectImage(); // erneutes Druecken auf das gemountete Image = auswerfen + } + else { mountImage(idx); } @@ -324,7 +352,7 @@ void refreshStatusBar() if (mountedIndex >= 0) { - snprintf(buf, sizeof(buf), LV_SYMBOL_OK " %s", images[mountedIndex].name); + snprintf(buf, sizeof(buf), LV_SYMBOL_OK " %s", displayName(images[mountedIndex].name)); } else { @@ -348,9 +376,45 @@ void refreshStatusBar() lv_obj_set_style_bg_color(listButtons[i], lv_color_hex(0x202020), 0); lv_obj_set_style_bg_opa(listButtons[i], LV_OPA_COVER, 0); } + + // Gruener Balken links markiert das gemountete Image, auch wenn es fokussiert (blau) ist + lv_coord_t barW = ((int8_t)i == mountedIndex) ? 3 : 0; + lv_obj_set_style_border_color(listButtons[i], lv_color_hex(0x2ECC71), 0); + lv_obj_set_style_border_color(listButtons[i], lv_color_hex(0x2ECC71), LV_STATE_FOCUSED); + lv_obj_set_style_border_opa(listButtons[i], LV_OPA_COVER, 0); + lv_obj_set_style_border_opa(listButtons[i], LV_OPA_COVER, LV_STATE_FOCUSED); + lv_obj_set_style_border_width(listButtons[i], barW, 0); + lv_obj_set_style_border_width(listButtons[i], barW, LV_STATE_FOCUSED); } } +// Einheitlicher Look der Listenzeilen: kompakt (16 px Text + 2x5 px Innenabstand +// = 26 px, drei Zeilen fuellen die 86 px der Liste exakt), Fokus blau, +// kein Theme-Outline. Der gruene Balken fuer "gemountet" kommt in refreshStatusBar(). +void styleListButton(lv_obj_t *btn) +{ + lv_obj_set_style_radius(btn, 3, 0); + lv_obj_set_style_pad_ver(btn, 5, 0); + lv_obj_set_style_pad_hor(btn, 6, 0); + lv_obj_set_style_pad_column(btn, 6, 0); + lv_obj_set_style_shadow_width(btn, 0, 0); + lv_obj_set_style_text_color(btn, lv_color_hex(0xDDDDDD), 0); + + lv_obj_set_style_bg_color(btn, lv_color_hex(0x2A5DB0), LV_STATE_FOCUSED); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, LV_STATE_FOCUSED); + lv_obj_set_style_text_color(btn, lv_color_hex(0xFFFFFF), LV_STATE_FOCUSED); + + lv_obj_set_style_outline_width(btn, 0, 0); + lv_obj_set_style_outline_width(btn, 0, LV_STATE_FOCUSED); + lv_obj_set_style_outline_width(btn, 0, LV_STATE_FOCUS_KEY); + + // Nur der linke Rand wird je nach Mount-Status benutzt + lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_LEFT, 0); + lv_obj_set_style_border_side(btn, LV_BORDER_SIDE_LEFT, LV_STATE_FOCUSED); + lv_obj_set_style_border_width(btn, 0, 0); + lv_obj_set_style_border_width(btn, 0, LV_STATE_FOCUSED); +} + void rebuildImageList() { lv_obj_clean(imageList); @@ -361,13 +425,16 @@ void rebuildImageList() if (imageCount == 0) { - lv_list_add_text(imageList, "Keine Images gefunden"); + lv_obj_t *hint = lv_list_add_text(imageList, "Keine Images gefunden"); + lv_obj_set_style_text_color(hint, lv_color_hex(0x888888), 0); return; } for (uint8_t i = 0; i < imageCount; i++) { - lv_obj_t *btn = lv_list_add_btn(imageList, LV_SYMBOL_FILE, images[i].name); + const char *icon = isAutostartName(images[i].name) ? LV_SYMBOL_POWER : LV_SYMBOL_FILE; + lv_obj_t *btn = lv_list_add_btn(imageList, icon, displayName(images[i].name)); + styleListButton(btn); lv_obj_set_user_data(btn, (void *)(uintptr_t)i); lv_obj_add_event_cb(btn, listButtonEventCb, LV_EVENT_CLICKED, NULL); lv_group_add_obj(lvGroup, btn); @@ -437,7 +504,7 @@ void sortImages() FloppyImage key = images[i]; int8_t j = i - 1; - while (j >= 0 && strcasecmp(images[j].name, key.name) > 0) + while (j >= 0 && strcasecmp(displayName(images[j].name), displayName(key.name)) > 0) { images[j + 1] = images[j]; j--; @@ -480,11 +547,14 @@ void rescanSdCard() refreshStatusBar(); } -void mountImage(int8_t idx) +// Oeffnet das Image und meldet es dem USB-MSC als Medium (ohne USB-Replug). +// Wird direkt von setup() fuer das Autostart-Image benutzt, damit der Host +// schon beim ersten Enumerieren ein Medium sieht. +bool openImage(int8_t idx) { if (idx < 0 || idx >= imageCount) { - return; + return false; } String fname = String(images[idx].name); @@ -497,10 +567,6 @@ void mountImage(int8_t idx) DBG_PRINT(fname); DBG_PRINTLN("'"); - usb_msc.setUnitReady(false); - TinyUSBDevice.detach(); - delay(200); - if (currentFile) { currentFile.close(); @@ -514,8 +580,8 @@ void mountImage(int8_t idx) if (!ok) { - TinyUSBDevice.attach(); - return; + mountedIndex = -1; + return false; } currentFilename = fname; @@ -527,12 +593,67 @@ void mountImage(int8_t idx) usb_msc.setCapacity(numSectors, 512); usb_msc.setUnitReady(true); + return true; +} + +void mountImage(int8_t idx) +{ + if (idx < 0 || idx >= imageCount) + { + return; + } + + usb_msc.setUnitReady(false); + TinyUSBDevice.detach(); + delay(200); + + openImage(idx); TinyUSBDevice.attach(); refreshStatusBar(); } +// Wirft das gemountete Image aus: der Host sieht ein Laufwerk ohne Medium, +// so als haette man die Diskette gezogen (gleiche Replug-Mechanik wie beim Mounten). +void ejectImage() +{ + if (mountedIndex < 0) + { + return; + } + + DBG_PRINTLN("Auswerfen..."); + + usb_msc.setUnitReady(false); + TinyUSBDevice.detach(); + delay(200); + + if (currentFile) + { + currentFile.close(); + } + currentFilename = ""; + mountedIndex = -1; + + TinyUSBDevice.attach(); + + refreshStatusBar(); +} + +// Index des Autostart-Images (erstes markiertes in sortierter Reihenfolge) oder -1 +int8_t findAutostartImage() +{ + for (uint8_t i = 0; i < imageCount; i++) + { + if (isAutostartName(images[i].name)) + { + return (int8_t)i; + } + } + return -1; +} + void setup() { Serial.begin(115200); @@ -604,6 +725,16 @@ void setup() usb_msc.setReadWriteCallback(msc_read_cb, msc_write_cb, msc_flush_cb); usb_msc.setCapacity(2880, 512); usb_msc.setUnitReady(false); + + // Autostart-Image (Dateiname mit '-') schon vor dem ersten Enumerieren + // einlegen, damit der Host direkt beim Einschalten ein Medium sieht. + int8_t autostartIdx = findAutostartImage(); + if (autostartIdx >= 0 && openImage(autostartIdx)) + { + lv_group_focus_obj(listButtons[autostartIdx]); + refreshStatusBar(); + } + usb_msc.begin(); DBG_PRINTLN("=== Setup abgeschlossen ==="); }