diff --git a/FW/leo_muziekdoos_esp32/.gitmodules b/FW/leo_muziekdoos_esp32/.gitmodules new file mode 100644 index 0000000..537ffa5 --- /dev/null +++ b/FW/leo_muziekdoos_esp32/.gitmodules @@ -0,0 +1,27 @@ +[submodule "FW/leo_muziekdoos_esp32/lib/ESP8266Audio"] + path = FW/leo_muziekdoos_esp32/lib/ESP8266Audio + url = http://git.oldemans.nl/libs/ESP8266Audio.git + branch = master +[submodule "FW/leo_muziekdoos_esp32/lib/JCButton"] + path = FW/leo_muziekdoos_esp32/lib/JCButton + url = http://git.oldemans.nl/libs/JCButton.git + branch = master +[submodule "FW/leo_muziekdoos_esp32/lib/NDEF"] + path = FW/leo_muziekdoos_esp32/lib/NDEF + url = http://git.oldemans.nl/libs/rfid.NDEF.git + branch = master +[submodule "FW/leo_muziekdoos_esp32/lib/PN532"] + path = FW/leo_muziekdoos_esp32/lib/PN532 + url = http://git.oldemans.nl/libs/rfid.PN532.git + branch = master +[submodule "FW/leo_muziekdoos_esp32/lib/PN532_SPI"] + path = FW/leo_muziekdoos_esp32/lib/PN532_SPI + url = http://git.oldemans.nl/libs/rfid.PN532_SPI.git +[submodule "FW/leo_muziekdoos_esp32/lib/BatterySense"] + path = FW/leo_muziekdoos_esp32/lib/BatterySense + url = http://git.oldemans.nl/libs/BatterySense.git + branch = master +[submodule "FW/leo_muziekdoos_esp32/lib/ADC_ADS1x15"] + path = FW/leo_muziekdoos_esp32/lib/ADC_ADS1x15 + url = http://git.oldemans.nl/libs/ADC_ADS1X15.git + branch = master diff --git a/FW/leo_muziekdoos_esp32/AUDIO_IMPROVEMENTS.md b/FW/leo_muziekdoos_esp32/AUDIO_IMPROVEMENTS.md new file mode 100644 index 0000000..3bfa69b --- /dev/null +++ b/FW/leo_muziekdoos_esp32/AUDIO_IMPROVEMENTS.md @@ -0,0 +1,54 @@ +# Audio Handling Improvement Advice + +## High Impact + +1. Prevent heap growth and fragmentation during song changes. +- Current behavior allocates new audio source objects for each play request. +- Improvement: centralize ownership of playback objects and always stop and release old objects before creating new ones. + +2. Separate end-of-track from decode error. +- Current behavior treats any failed loop step as a reason to restart playback. +- Improvement: classify playback outcomes into Running, Ended, and Error, and only auto-repeat when explicitly enabled. + +3. Remove blocking serial work from decoder callbacks. +- Current behavior prints ID3 data character-by-character and flushes serial from callback context. +- Improvement: keep callback logging minimal, avoid flush in hot paths, and buffer logs where possible. + +4. Model real playback state instead of a single software flag. +- Current behavior uses one flag for amplifier state and decision flow. +- Improvement: expose states such as Idle, Starting, Playing, Stopping, Error, and base transitions on decoder status plus requested state. + +5. Validate song input before playback starts. +- Current behavior starts playback when filename is non-empty. +- Improvement: verify file exists in LittleFS, return reason codes for missing file, invalid path, or decoder start failure. + +## Medium Impact + +1. Introduce an AudioManager command queue. +- Use commands like Play(file), Stop, Pause, SetGain. +- This decouples game logic timing from decoder timing and reduces race-like behavior. + +2. Register callbacks once during audio initialization. +- Avoid repeated callback registration on every play request unless dynamic callback context is required. + +3. Make playback policy configurable. +- Add options such as RepeatMode, ErrorRetryLimit, and RetryBackoffMs in settings. + +4. Add runtime telemetry. +- Track counters for starts, stops, open failures, decode errors, and loop overruns. +- This improves observability and simplifies field debugging. + +## Suggested Implementation Order + +1. Add explicit cleanup path for current playback objects. +2. Introduce a playback result enum and stop auto-restart on all errors. +3. Reduce callback logging overhead. +4. Add explicit AudioManager states and update game transitions. +5. Add configuration flags for repeat and retry policy. + +## Affected Areas in Code + +- src/audio.cpp +- src/audio.h +- src/game.cpp +- src/config.cpp and data/settings.json (for new policy options) diff --git a/FW/leo_muziekdoos_esp32/data/ping_mech_down.mp3 b/FW/leo_muziekdoos_esp32/data/ping_mech_down.mp3 new file mode 100644 index 0000000..196d2c1 Binary files /dev/null and b/FW/leo_muziekdoos_esp32/data/ping_mech_down.mp3 differ diff --git a/FW/leo_muziekdoos_esp32/data/ping_mech_up.mp3 b/FW/leo_muziekdoos_esp32/data/ping_mech_up.mp3 new file mode 100644 index 0000000..2f3808b Binary files /dev/null and b/FW/leo_muziekdoos_esp32/data/ping_mech_up.mp3 differ diff --git a/FW/leo_muziekdoos_esp32/data/settings.json b/FW/leo_muziekdoos_esp32/data/settings.json index 5868e87..a5e2e41 100644 --- a/FW/leo_muziekdoos_esp32/data/settings.json +++ b/FW/leo_muziekdoos_esp32/data/settings.json @@ -23,6 +23,12 @@ }], "AudioGain": 0.5, + "StartupSounds": true, + "WifiMode": "AP", + "WifiStaSSID": "", + "WifiStaPSK": "", + "WifiApSSID": "muziekdoos-setup", + "WifiApPSK": "", "ScanTimeout": 50, "HardwareVersion": 2, "GameTimeout": 20000, diff --git a/FW/leo_muziekdoos_esp32/lib/ESP8266Audio b/FW/leo_muziekdoos_esp32/lib/ESP8266Audio index 818dfd5..c755cd0 160000 --- a/FW/leo_muziekdoos_esp32/lib/ESP8266Audio +++ b/FW/leo_muziekdoos_esp32/lib/ESP8266Audio @@ -1 +1 @@ -Subproject commit 818dfd5cb7b3cd9ae8cf68c4bc719748c914e9d9 +Subproject commit c755cd0d2ed8398986eea8288c8cdb0e524572e0 diff --git a/FW/leo_muziekdoos_esp32/lib/PN532 b/FW/leo_muziekdoos_esp32/lib/PN532 index e88576e..e789a71 160000 --- a/FW/leo_muziekdoos_esp32/lib/PN532 +++ b/FW/leo_muziekdoos_esp32/lib/PN532 @@ -1 +1 @@ -Subproject commit e88576ed9339ef469dbc334cc7404fd6eab9e6eb +Subproject commit e789a714fcc796f94716ccec9068190cd254ebdd diff --git a/FW/leo_muziekdoos_esp32/platformio.ini b/FW/leo_muziekdoos_esp32/platformio.ini index 65e794f..8208ab7 100644 --- a/FW/leo_muziekdoos_esp32/platformio.ini +++ b/FW/leo_muziekdoos_esp32/platformio.ini @@ -13,27 +13,26 @@ build_src_filter = +<*> -<.git/> -<.svn/> - - - - - - +lib_ldf_mode = deep build_flags = -DHARDWARE=2 - -DCORE_DEBUG_LEVEL=3 + -DCORE_DEBUG_LEVEL=4 -DNDEF_DEBUG=1 -fexceptions + -DNO_SPDIF=1 extra_scripts = ./littlefsbuilder.py board_build.filesystem = littlefs monitor_speed = 115200 -#upload_protocol = esptool -upload_protocol = espota -upload_port = muziekdoos.local +upload_protocol = esptool +#upload_protocol = espota +#upload_port = muziekdoos.local diff --git a/FW/leo_muziekdoos_esp32/scripts/generate_mech_pings.py b/FW/leo_muziekdoos_esp32/scripts/generate_mech_pings.py new file mode 100644 index 0000000..11a2417 --- /dev/null +++ b/FW/leo_muziekdoos_esp32/scripts/generate_mech_pings.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Generate short mechanical startup/shutdown ping MP3 files for LittleFS. + +Outputs by default: +- data/ping_mech_up.mp3 +- data/ping_mech_down.mp3 + +Requires: +- pip install lameenc +""" + +from __future__ import annotations + +import argparse +import math +import random +import struct +from pathlib import Path + +import lameenc + + +SAMPLE_RATE = 44100 + + +def damped_ping(t: float, f: float, click_amt: float, rng: random.Random) -> float: + """A bright, metallic-ish pluck with fast decay and a tiny striker click.""" + env1 = math.exp(-t * 11.0) + env2 = math.exp(-t * 17.0) + env3 = math.exp(-t * 24.0) + body = ( + 0.72 * math.sin(2 * math.pi * f * t) * env1 + + 0.23 * math.sin(2 * math.pi * f * 2.05 * t) * env2 + + 0.12 * math.sin(2 * math.pi * f * 3.87 * t) * env3 + ) + click = rng.uniform(-1.0, 1.0) * click_amt * math.exp(-t * 120.0) + return body + click + + +def synth_sequence( + hit_times: list[float], + freqs: list[float], + length_s: float, + fade_start_s: float, + fade_len_s: float, + gain: float, + click_amt: float, + seed: int, +) -> bytes: + rng = random.Random(seed) + n = int(SAMPLE_RATE * length_s) + samples: list[int] = [] + + for i in range(n): + t = i / SAMPLE_RATE + x = 0.0 + for ht, f in zip(hit_times, freqs): + dt = t - ht + if 0.0 <= dt <= 0.22: + x += damped_ping(dt, f, click_amt, rng) + + fade = 1.0 if t < fade_start_s else max(0.0, (length_s - t) / fade_len_s) + x *= gain * fade + x = math.tanh(1.85 * x) + + s = int(max(-1.0, min(1.0, x)) * 32767) + samples.append(s) + + return struct.pack("<" + "h" * len(samples), *samples) + + +def encode_mp3(pcm16: bytes, bitrate_kbps: int) -> bytes: + enc = lameenc.Encoder() + enc.set_in_sample_rate(SAMPLE_RATE) + enc.set_channels(1) + enc.set_bit_rate(bitrate_kbps) + enc.set_quality(2) + out = enc.encode(pcm16) + out += enc.flush() + return out + + +def write_mp3(path: Path, pcm16: bytes, bitrate_kbps: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = encode_mp3(pcm16, bitrate_kbps) + path.write_bytes(data) + print(f"Wrote {path} ({path.stat().st_size} bytes)") + + +def generate_up(out_dir: Path, bitrate_kbps: int, seed: int) -> None: + hit_times = [0.00, 0.18, 0.36, 0.56, 0.78, 1.00] + freqs = [740, 830, 930, 1040, 1160, 1290] + pcm = synth_sequence( + hit_times=hit_times, + freqs=freqs, + length_s=1.25, + fade_start_s=1.10, + fade_len_s=0.15, + gain=0.28, + click_amt=0.18, + seed=seed, + ) + write_mp3(out_dir / "ping_mech_up.mp3", pcm, bitrate_kbps) + + +def generate_down(out_dir: Path, bitrate_kbps: int, seed: int) -> None: + hit_times = [0.00, 0.16, 0.33, 0.51, 0.71, 0.92] + freqs = [1280, 1140, 1010, 900, 800, 710] + pcm = synth_sequence( + hit_times=hit_times, + freqs=freqs, + length_s=1.20, + fade_start_s=1.04, + fade_len_s=0.16, + gain=0.28, + click_amt=0.16, + seed=seed + 1, + ) + write_mp3(out_dir / "ping_mech_down.mp3", pcm, bitrate_kbps) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate mechanical ping startup/shutdown MP3 files") + parser.add_argument( + "--out-dir", + default="data", + help="Output directory for generated MP3 files (default: data)", + ) + parser.add_argument( + "--bitrate", + type=int, + default=128, + help="MP3 bitrate in kbps (default: 128)", + ) + parser.add_argument( + "--seed", + type=int, + default=42, + help="Random seed for click texture reproducibility (default: 42)", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + out_dir = Path(args.out_dir) + generate_up(out_dir, args.bitrate, args.seed) + generate_down(out_dir, args.bitrate, args.seed) + + +if __name__ == "__main__": + main() diff --git a/FW/leo_muziekdoos_esp32/src/audio.cpp b/FW/leo_muziekdoos_esp32/src/audio.cpp index 6b61af3..3719f5b 100644 --- a/FW/leo_muziekdoos_esp32/src/audio.cpp +++ b/FW/leo_muziekdoos_esp32/src/audio.cpp @@ -1,109 +1,34 @@ #include "audio.h" -AudioGeneratorMP3 *mp3; -AudioFileSourceID3 *id3; -AudioFileSourceLittleFS *file; -AudioOutputI2S *out; +#include + +// Forward declaration +void MDCallback(void *cbData, const char *type, bool isUnicode, const char *string); + +AudioGeneratorMP3 *mp3 = nullptr; +AudioFileSourceID3 *id3 = nullptr; +AudioFileSourceLittleFS *file = nullptr; +AudioOutputI2S *out = nullptr; -uint8_t audio_current_Song = 0; String nextAudioFile = ""; -uint8_t n = 0; bool audioState = false; bool audioInitOk = false; +bool audioRepeat = true; +SemaphoreHandle_t audioMutex = nullptr; -const char *waveFile[] = - {"/ringoffire.mp3", - "/Let_it_be.mp3", - "/Billy-Jean.mp3"}; - -// Called when a metadata event occurs (i.e. an ID3 tag, an ICY block, etc. -void MDCallback(void *cbData, const char *type, bool isUnicode, const char *string) +/** + * Set the audio amplifier state and update the internal state flag. + * Drives DAC_SDMODE HIGH (amp on) or LOW (amp off). + * Must only be called while the audio mutex is held. + */ +static void setAudioStateLocked(bool state) { - // (void)cbData; - // log_i("ID3 callback for: %s = '", type); - - (void)cbData; - Serial.printf("ID3 callback for: %s = '", type); - - if (isUnicode) + if (audioState == state) { - string += 2; + return; } - while (*string) - { - char a = *(string++); - if (isUnicode) - { - string++; - } - Serial.printf("%c", a); - } - Serial.printf("'\n"); - Serial.flush(); -} - -// Called when there's a warning or error (like a buffer underflow or decode hiccup) -void StatusCallback(void *cbData, int code, const char *string) -{ - const char *ptr = reinterpret_cast(cbData); - // Note that the string may be in PROGMEM, so copy it to RAM for printf - char s1[64]; - strncpy_P(s1, string, sizeof(s1)); - s1[sizeof(s1) - 1] = 0; - log_i("STATUS(%s) '%d' = '%s'\n", ptr, code, s1); -} - -// void playSong(uint8_t index) -// { -// if (index > AUDIONSONGS) -// return; -// log_i("now playing %s\n", waveFile[index]); -// file = new AudioFileSourceLittleFS(waveFile[index]); -// id3 = new AudioFileSourceID3(file); -// id3->RegisterMetadataCB(MDCallback, (void *)"ID3TAG"); -// mp3->RegisterStatusCB(StatusCallback, (void *)"mp3"); -// mp3->begin(id3, out); -// } - -void playSong(String filename) -{ - if (filename != "") - { - nextAudioFile = filename; - log_i("now playing %s\n", filename.c_str()); - file = new AudioFileSourceLittleFS(filename.c_str()); - id3 = new AudioFileSourceID3(file); - id3->RegisterMetadataCB(MDCallback, (void *)"ID3TAG"); - mp3->RegisterStatusCB(StatusCallback, (void *)"mp3"); - mp3->begin(id3, out); - } - else - { - log_e("no filename specified"); - } -} - -void initAudio() -{ - log_i("init Audio"); - audioLogger = &Serial; - delay(500); - out = new AudioOutputI2S(); - out->SetPinout(I2S_BCLK, I2S_WCLK, I2S_DATA); // bclk, wclk, data - out->SetGain(getFloatParam("AudioGain", AUDIOGAIN)); - pinMode(DAC_SDMODE, OUTPUT); - setAudioState(false); - mp3 = new AudioGeneratorMP3(); - audioInitOk = true; - log_i("init Audio Done"); -} - -void setAudioState(bool state) -{ - if(state == audioState) return; - audioState = state; if (state) { @@ -116,36 +41,357 @@ void setAudioState(bool state) log_i("set Audio state %d", state); } -bool getAudioState(void) +/** + * Release the current ID3 and file source objects, freeing heap memory. + * If stopDecoder is true and the MP3 generator is running it is stopped first. + * Safe to call when id3/file are already nullptr. + * Must only be called while the audio mutex is held. + */ +static void cleanupPlaybackObjectsLocked(bool stopDecoder) { - return audioState; + if (mp3 != nullptr && stopDecoder && mp3->isRunning()) + { + mp3->stop(); + } + + if (id3 != nullptr) + { + id3->close(); + delete id3; + id3 = nullptr; + } + + if (file != nullptr) + { + file->close(); + delete file; + file = nullptr; + } } +/** + * Open 'filename' from LittleFS and start the MP3 decoder. + * Validates the file exists before allocating source objects. + * If repeat is true the track will loop automatically when it ends. + * Cleans up any previous playback objects before starting a new one. + * Returns true on successful decoder start, false on any failure. + * Must only be called while the audio mutex is held. + */ +static bool startPlaybackLocked(const String &filename, bool repeat) +{ + if (filename == "") + { + log_e("Audio: no filename specified"); + setAudioStateLocked(false); + return false; + } + + if (!LittleFS.exists(filename)) + { + log_e("Audio: file does not exist: %s", filename.c_str()); + cleanupPlaybackObjectsLocked(true); + setAudioStateLocked(false); + return false; + } + + cleanupPlaybackObjectsLocked(true); + + audioRepeat = repeat; + nextAudioFile = filename; + log_i("now playing %s\n", filename.c_str()); + + file = new AudioFileSourceLittleFS(filename.c_str()); + id3 = new AudioFileSourceID3(file); + id3->RegisterMetadataCB(MDCallback, (void *)"ID3TAG"); + + if (!mp3->begin(id3, out)) + { + log_e("Audio: failed to start playback for %s", filename.c_str()); + cleanupPlaybackObjectsLocked(false); + setAudioStateLocked(false); + return false; + } + + setAudioStateLocked(true); + return true; +} + +/** + * Acquire the recursive audio mutex with a 10 ms timeout. + * Returns true on success or when the mutex has not been created yet (pre-init safe). + * Returns false if the timeout expires, indicating contention. + */ +static bool audioLock() +{ + if (audioMutex == nullptr) + { + return true; + } + return xSemaphoreTakeRecursive(audioMutex, pdMS_TO_TICKS(10)) == pdTRUE; +} + +/** + * Release the recursive audio mutex. + * No-op when the mutex has not been created yet. + */ +static void audioUnlock() +{ + if (audioMutex != nullptr) + { + xSemaphoreGiveRecursive(audioMutex); + } +} + +/** + * ID3 metadata callback registered with the AudioFileSourceID3 filter. + * Called by the decoder when a tag (title, artist, etc.) is found in the stream. + * Logged at verbose level only to avoid overhead on the audio task. + */ +void MDCallback(void *cbData, const char *type, bool isUnicode, const char *string) +{ + (void)cbData; + (void)string; + log_v("ID3 callback: type=%s unicode=%d", type, isUnicode); +} + +/** + * Decoder status callback registered once on the MP3 generator during init. + * Called by ESP8266Audio on warnings or decode errors such as buffer underflow. + * The string may be in PROGMEM so it is copied to RAM before logging. + */ +void StatusCallback(void *cbData, int code, const char *string) +{ + const char *ptr = reinterpret_cast(cbData); + // Note that the string may be in PROGMEM, so copy it to RAM for printf + char s1[64]; + strncpy_P(s1, string, sizeof(s1)); + s1[sizeof(s1) - 1] = 0; + log_i("STATUS(%s) '%d' = '%s'\n", ptr, code, s1); +} + +/** + * Request playback of an MP3 file from LittleFS. + * Thread-safe: acquires the audio mutex before acting. + * If repeat is true the track loops; if false it plays once and the audio state + * is cleared automatically when the decoder finishes. + * Silently ignored when the audio engine is not yet initialized. + */ +void playSong(String filename, bool repeat) +{ + if (!audioLock()) + { + log_w("Audio: lock timeout in playSong"); + return; + } + + if (mp3 == nullptr || out == nullptr) + { + log_e("Audio: engine not initialized"); + audioUnlock(); + return; + } + + startPlaybackLocked(filename, repeat); + + audioUnlock(); +} + +/** + * Initialize the audio subsystem: mutex, I2S output, and MP3 decoder. + * Reads AudioGain from settings.json; falls back to AUDIOGAIN default if missing + * or out of the 0.0–1.0 range. + * Safe to call multiple times; skips re-initialization if already complete. + * Sets audioInitOk true only when every allocation succeeds. + */ +void initAudio() +{ + log_i("init Audio"); + audioLogger = &Serial; + audioInitOk = false; + + if (audioMutex != nullptr && out != nullptr && mp3 != nullptr) + { + log_w("Audio: already initialized"); + audioInitOk = true; + return; + } + + delay(500); + + if (audioMutex == nullptr) + { + audioMutex = xSemaphoreCreateRecursiveMutex(); + if (audioMutex == nullptr) + { + log_e("Audio: failed to create mutex"); + return; + } + } + + if (out == nullptr) + { + out = new AudioOutputI2S(); + if (out == nullptr) + { + log_e("Audio: failed to allocate AudioOutputI2S"); + return; + } + } + + out->SetPinout(I2S_BCLK, I2S_WCLK, I2S_DATA); // bclk, wclk, data + + float gain = getFloatParam("AudioGain", AUDIOGAIN); + if (gain != gain || gain < 0.0f || gain > 1.0f) + { + log_w("Audio: invalid gain %f, using default %f", gain, (float)AUDIOGAIN); + gain = AUDIOGAIN; + } + out->SetGain(gain); + + pinMode(DAC_SDMODE, OUTPUT); + setAudioStateLocked(false); + + if (mp3 == nullptr) + { + mp3 = new AudioGeneratorMP3(); + if (mp3 == nullptr) + { + log_e("Audio: failed to allocate AudioGeneratorMP3"); + return; + } + } + + mp3->RegisterStatusCB(StatusCallback, (void *)"mp3"); + audioInitOk = true; + log_i("init Audio Done"); +} + +/** + * Public API to enable or disable the audio output. + * Enabling turns the amplifier on; disabling stops any active playback and + * powers the amplifier off. + * Thread-safe: acquires the audio mutex before acting. + */ +void setAudioState(bool state) +{ + if (!audioLock()) + { + log_w("Audio: lock timeout in setAudioState"); + return; + } + + setAudioStateLocked(state); + audioUnlock(); +} + +/** + * Returns the current audio output state (true = enabled/playing). + * Thread-safe: acquires the audio mutex before reading. + * Returns the last known value without blocking when the mutex times out. + */ +bool getAudioState(void) +{ + if (!audioLock()) + { + return audioState; + } + + bool state = audioState; + audioUnlock(); + return state; +} + +void setAudioGain(float gain) +{ + if (gain != gain || gain < 0.0f || gain > 1.0f) + { + log_w("Audio: invalid gain %f", gain); + return; + } + + if (!audioLock()) + { + log_w("Audio: lock timeout in setAudioGain"); + return; + } + + if (out != nullptr) + { + out->SetGain(gain); + log_i("Audio: gain set to %f", gain); + } + audioUnlock(); +} + +/** + * Returns true when initAudio() completed successfully. + * Used by the game state machine to gate the stateInit -> stateIdle transition. + */ bool getAudioInitStatus(void) { return audioInitOk; } +/** + * Audio task body — called repeatedly from the dedicated FreeRTOS audio task. + * Drives the MP3 decoder loop and manages state transitions: + * - Idle (audioState false): ensures decoder and amp are stopped. + * - Playing: calls mp3->loop() each tick; on track end either restarts the + * same file (repeat mode) or cleans up and clears audioState (one-shot). + * Thread-safe: holds the audio mutex for the duration of each call. + */ void handleAudio() { + if (!audioLock()) + { + return; + } + if (!audioState) { - if (mp3->isRunning()) + if (mp3 != nullptr && mp3->isRunning()) { log_w("Audio: stop playback"); - mp3->stop(); } + cleanupPlaybackObjectsLocked(true); + audioUnlock(); + return; } - else + + if (mp3 == nullptr) { - if (mp3->isRunning()) + log_e("Audio: engine not initialized"); + setAudioStateLocked(false); + cleanupPlaybackObjectsLocked(false); + audioUnlock(); + return; + } + + if (mp3->isRunning()) + { + if (!mp3->loop()) { - if (!mp3->loop()) + if (audioRepeat) { - mp3->stop(); log_w("Audio: loop"); - playSong(nextAudioFile); + startPlaybackLocked(nextAudioFile, true); + } + else + { + log_i("Audio: one-shot finished"); + cleanupPlaybackObjectsLocked(true); + setAudioStateLocked(false); } } } + else if (!audioRepeat) + { + // One-shot playback can end due to decoder EOF/error without passing through loop() false path. + // Ensure we release the pending startup/shutdown state machine wait. + log_i("Audio: one-shot ended while not running"); + cleanupPlaybackObjectsLocked(false); + setAudioStateLocked(false); + } + + audioUnlock(); } \ No newline at end of file diff --git a/FW/leo_muziekdoos_esp32/src/audio.h b/FW/leo_muziekdoos_esp32/src/audio.h index 81c4001..766519a 100644 --- a/FW/leo_muziekdoos_esp32/src/audio.h +++ b/FW/leo_muziekdoos_esp32/src/audio.h @@ -16,8 +16,9 @@ void initAudio(void); void handleAudio(void); bool getAudioInitStatus(void); -void playSong(String filename); +void playSong(String filename, bool repeat = true); void setAudioState(bool state); bool getAudioState(void); +void setAudioGain(float gain); diff --git a/FW/leo_muziekdoos_esp32/src/config.cpp b/FW/leo_muziekdoos_esp32/src/config.cpp index 13ac1a1..8ec42da 100644 --- a/FW/leo_muziekdoos_esp32/src/config.cpp +++ b/FW/leo_muziekdoos_esp32/src/config.cpp @@ -1,6 +1,5 @@ #include "config.h" #include -#include "FS.h" #include #include "ArduinoJson.h" @@ -83,6 +82,22 @@ float getFloatParam(String param, int def) return settingsDoc[param]; } +bool GetBoolparam(String param, bool def) +{ + log_i("Get param %s", param.c_str()); + if (param == "") + { + log_e("No param given"); + return def; + } + if (!settingsDoc.containsKey(param)) + { + log_w("param(%s) not found", param.c_str()); + return def; + } + return settingsDoc[param].as(); +} + void loadConfig(const char *fname) { log_i("config: load"); @@ -121,6 +136,31 @@ void loadConfig(const char *fname) log_i("config: load done"); } +bool saveConfig(const DynamicJsonDocument &doc) +{ + File file = LittleFS.open(tagConfigfile, "w"); + if (!file) + { + log_e("config: failed to open settings for write"); + return false; + } + + if (serializeJsonPretty(doc, file) == 0) + { + log_e("config: failed to serialize settings"); + file.close(); + return false; + } + file.close(); + return true; +} + +bool reloadConfig(void) +{ + loadConfig(tagConfigfile); + return configInitOK; +} + void initConfig(void) { log_i("config: init start"); @@ -135,22 +175,34 @@ void handleConfig(void) bool getUIDvalid(String uid) { - JsonArray array = settingsDoc["tags"].as(); - for (JsonVariant v : array) + String song = getConfigSong(uid); + bool valid = song != ""; + if (!valid) { - String taguid((const char*)v["TagUID"]); - uint16_t result = uid.compareTo(taguid); - - log_v("compare %s(config) with %s(read) = %d",taguid.c_str(), uid.c_str(), result); - if (!result) - { - String filename((const char*)v["audiofile"]); - log_i("Tag found in config"); - return true; - } + log_e("taguid %s has no active song", uid.c_str()); } - log_e("taguid %s not found",uid.c_str() ); - return false; + return valid; +} + +static String resolvePlayableSongPath(String configuredPath) +{ + if (configuredPath == "") + { + return ""; + } + + String normalized = configuredPath; + if (!normalized.startsWith("/")) + { + normalized = "/" + normalized; + } + + if (LittleFS.exists(normalized)) + { + return normalized; + } + + return ""; } String getConfigSong(String uid) @@ -165,10 +217,36 @@ String getConfigSong(String uid) if (!result) { String filename((const char*)v["audiofile"]); - log_i("Tag found in config, filename = %s", filename.c_str()); - return filename; + if (filename == "") + { + log_i("Tag found in config but disabled"); + return ""; + } + + String playablePath = resolvePlayableSongPath(filename); + if (playablePath == "") + { + log_e("Tag found in config but file missing: %s", filename.c_str()); + return ""; + } + + log_i("Tag found in config, filename = %s", playablePath.c_str()); + return playablePath; } } - log_e("taguid %s not found",uid.c_str() ); + + String defaultSong = settingsDoc["DefaultAudiofile"] | ""; + if (defaultSong != "") + { + String playableDefault = resolvePlayableSongPath(defaultSong); + if (playableDefault != "") + { + log_i("taguid %s not mapped, using default song %s", uid.c_str(), playableDefault.c_str()); + return playableDefault; + } + log_e("Default song configured but missing: %s", defaultSong.c_str()); + } + + log_e("taguid %s not found and no default song", uid.c_str()); return ""; } \ No newline at end of file diff --git a/FW/leo_muziekdoos_esp32/src/config.h b/FW/leo_muziekdoos_esp32/src/config.h index 1264ce7..03f1031 100644 --- a/FW/leo_muziekdoos_esp32/src/config.h +++ b/FW/leo_muziekdoos_esp32/src/config.h @@ -1,12 +1,16 @@ #pragma once #include "Arduino.h" +#include "ArduinoJson.h" String getConfigSong(String uid); bool getUIDvalid(String uid); String GetWifiPassword(String ssid); int GetIntparam(String param, int def = -1); float getFloatParam( String param, int def = -1); +bool GetBoolparam(String param, bool def = false); +bool reloadConfig(void); +bool saveConfig(const DynamicJsonDocument &doc); void initConfig(void); void handleConfig(void); \ No newline at end of file diff --git a/FW/leo_muziekdoos_esp32/src/led.cpp b/FW/leo_muziekdoos_esp32/src/led.cpp index 495604c..072e6a3 100644 --- a/FW/leo_muziekdoos_esp32/src/led.cpp +++ b/FW/leo_muziekdoos_esp32/src/led.cpp @@ -31,6 +31,16 @@ void SetLedColor(CRGB color, bool blink) setLedBlink(blink); } +void setLedBrightness(uint8_t brightness) +{ + FastLED.setBrightness(brightness); +} + +uint8_t getLedBrightness(void) +{ + return FastLED.getBrightness(); +} + void initLed(void) { FastLED.addLeds(leds, NUM_LEDS); // GRB ordering is typical diff --git a/FW/leo_muziekdoos_esp32/src/led.h b/FW/leo_muziekdoos_esp32/src/led.h index 04ac84d..1bf7444 100644 --- a/FW/leo_muziekdoos_esp32/src/led.h +++ b/FW/leo_muziekdoos_esp32/src/led.h @@ -17,3 +17,5 @@ void handleLed(void); void setLedBlink(bool blink); void SetLedColor(CRGB color); void SetLedColor(CRGB color, bool blink); +void setLedBrightness(uint8_t brightness); +uint8_t getLedBrightness(void); diff --git a/FW/leo_muziekdoos_esp32/src/main.cpp b/FW/leo_muziekdoos_esp32/src/main.cpp index 23d2944..28a8fb1 100644 --- a/FW/leo_muziekdoos_esp32/src/main.cpp +++ b/FW/leo_muziekdoos_esp32/src/main.cpp @@ -11,6 +11,29 @@ #include "led.h" uint32_t looptime = 0; +TaskHandle_t audioTaskHandle = nullptr; +TaskHandle_t ledTaskHandle = nullptr; +uint32_t lastMainLog = 0; + +void audioTask(void *parameter) +{ + (void)parameter; + for (;;) + { + handleAudio(); + vTaskDelay(pdMS_TO_TICKS(1)); + } +} + +void ledTask(void *parameter) +{ + (void)parameter; + for (;;) + { + handleLed(); + vTaskDelay(pdMS_TO_TICKS(10)); + } +} void setup() { @@ -30,6 +53,24 @@ void setup() initSensor(); initLed(); initGame(); + + xTaskCreatePinnedToCore( + audioTask, + "audioTask", + 4096, + nullptr, + 2, + &audioTaskHandle, + 1); + + xTaskCreatePinnedToCore( + ledTask, + "ledTask", + 2048, + nullptr, + 1, + &ledTaskHandle, + 0); } void loop() @@ -38,14 +79,12 @@ void loop() handlePower(); handleBatterySensor(); - handleLed(); if (getPowerState() == POWERSTATES::on) { - handleAudio(); - handleRfid(); handleHallSensor(); handleGame(); + handleRfid(); } else if (getPowerState() == POWERSTATES::overTheAir2) { @@ -55,5 +94,14 @@ void loop() { /* noting */ } - log_v("main: looptime = %d", millis() - looptime); + if (millis() - lastMainLog > 1000) + { + log_v("main: looptime = %d", millis() - looptime); + lastMainLog = millis(); + } + + // Keep main loop at a predictable cadence to prevent rapid state churn + // and maintain deterministic LED/game behavior now that audio runs on a separate task. + // Use FreeRTOS-safe yield instead of blocking delay. + vTaskDelay(pdMS_TO_TICKS(5)); } diff --git a/FW/leo_muziekdoos_esp32/src/ota.cpp b/FW/leo_muziekdoos_esp32/src/ota.cpp index 5368689..f045fbc 100644 --- a/FW/leo_muziekdoos_esp32/src/ota.cpp +++ b/FW/leo_muziekdoos_esp32/src/ota.cpp @@ -1,150 +1,806 @@ #include "ota.h" +#include +#include + +#include "audio.h" +#include "led.h" +#include "ota_webui.h" +#include "rfid.h" + OtaProcess_class ota(100); +static WebServer webServer(80); +static bool webServerStarted = false; +static bool otaCallbacksInstalled = false; +static File uploadFile; + +static const char *kSettingsPath = "/settings.json"; +static const char *kDefaultApSsid = "muziekdoos-setup"; + +static bool loadSettingsDoc(DynamicJsonDocument &doc) +{ + File file = LittleFS.open(kSettingsPath, "r"); + if (!file) + { + log_e("web: failed to open settings for read"); + return false; + } + + DeserializationError err = deserializeJson(doc, file); + file.close(); + if (err) + { + log_e("web: failed to parse settings: %s", err.c_str()); + return false; + } + return true; +} + +static void ensureDefaultSettings(DynamicJsonDocument &doc) +{ + if (!doc.containsKey("StartupSounds")) + { + doc["StartupSounds"] = true; + } + if (!doc.containsKey("WifiMode")) + { + doc["WifiMode"] = "AP"; + } + if (!doc.containsKey("WifiStaSSID")) + { + doc["WifiStaSSID"] = ""; + } + if (!doc.containsKey("WifiStaPSK")) + { + doc["WifiStaPSK"] = ""; + } + if (!doc.containsKey("WifiApSSID")) + { + doc["WifiApSSID"] = kDefaultApSsid; + } + if (!doc.containsKey("WifiApPSK")) + { + doc["WifiApPSK"] = ""; + } + if (!doc.containsKey("AudioGain")) + { + doc["AudioGain"] = AUDIOGAIN; + } + if (!doc.containsKey("Brightness")) + { + doc["Brightness"] = LEDDEFBRIGHT; + } + if (!doc.containsKey("DefaultAudiofile")) + { + doc["DefaultAudiofile"] = ""; + } + if (!doc.containsKey("tags") || !doc["tags"].is()) + { + doc.createNestedArray("tags"); + } +} + +static bool saveSettingsDoc(const DynamicJsonDocument &doc) +{ + if (!saveConfig(doc)) + { + return false; + } + return reloadConfig(); +} + +static String normalizeFilePath(String path) +{ + if (path == "") + { + return ""; + } + if (!path.startsWith("/")) + { + path = "/" + path; + } + if (path.indexOf("..") >= 0) + { + return ""; + } + return path; +} + +static void sendJson(int code, const DynamicJsonDocument &doc) +{ + String out; + serializeJson(doc, out); + webServer.send(code, "application/json", out); +} + +static bool parseRequestJson(DynamicJsonDocument &body) +{ + if (!webServer.hasArg("plain")) + { + return false; + } + DeserializationError err = deserializeJson(body, webServer.arg("plain")); + return !err; +} + +static void applyRuntimeSettings(const DynamicJsonDocument &doc) +{ + if (doc.containsKey("AudioGain")) + { + float gain = doc["AudioGain"].as(); + if (!(gain != gain) && gain >= 0.0f && gain <= 1.0f) + { + setAudioGain(gain); + } + } + + if (doc.containsKey("Brightness")) + { + int brightness = doc["Brightness"].as(); + if (brightness < 0) + { + brightness = 0; + } + if (brightness > 255) + { + brightness = 255; + } + setLedBrightness((uint8_t)brightness); + } +} + +static void configureWifiFromSettings(void) +{ + DynamicJsonDocument settings(4096); + if (!loadSettingsDoc(settings)) + { + return; + } + ensureDefaultSettings(settings); + + String mode = settings["WifiMode"] | "AP"; + String apSsid = settings["WifiApSSID"] | kDefaultApSsid; + String apPsk = settings["WifiApPSK"] | ""; + String staSsid = settings["WifiStaSSID"] | ""; + String staPsk = settings["WifiStaPSK"] | ""; + + if (apSsid == "") + { + apSsid = kDefaultApSsid; + } + + bool enableAp = (mode == "AP" || mode == "AP+STA" || mode == ""); + bool enableSta = (mode == "STA" || mode == "AP+STA") && staSsid != ""; + + WiFi.disconnect(true, true); + delay(50); + + if (enableAp && enableSta) + { + WiFi.mode(WIFI_AP_STA); + } + else if (enableSta) + { + WiFi.mode(WIFI_STA); + } + else + { + WiFi.mode(WIFI_AP); + enableAp = true; + } + + if (enableAp) + { + if (apPsk.length() >= 8) + { + WiFi.softAP(apSsid.c_str(), apPsk.c_str()); + } + else + { + WiFi.softAP(apSsid.c_str()); + } + } + + if (enableSta) + { + WiFi.begin(staSsid.c_str(), staPsk.c_str()); + } + + log_i("web: AP IP=%s STA IP=%s", WiFi.softAPIP().toString().c_str(), WiFi.localIP().toString().c_str()); +} + +static void appendFiles(JsonArray files) +{ + File root = LittleFS.open("/"); + if (!root || !root.isDirectory()) + { + return; + } + + File file = root.openNextFile(); + while (file) + { + if (!file.isDirectory()) + { + JsonObject row = files.createNestedObject(); + // Copy filename into owned memory before iterating to next file. + row["name"] = String(file.name()); + row["size"] = (uint32_t)file.size(); + } + file = root.openNextFile(); + } +} + +static void handleApiStatus(void) +{ + DynamicJsonDocument settings(4096); + if (!loadSettingsDoc(settings)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "settings read failed"; + sendJson(500, err); + return; + } + + ensureDefaultSettings(settings); + + DynamicJsonDocument doc(8192); + doc["ok"] = true; + doc["powerState"] = (int)getPowerState(); + doc["otaState"] = (int)getOtaState(); + doc["audioGain"] = settings["AudioGain"] | AUDIOGAIN; + doc["brightness"] = settings["Brightness"] | LEDDEFBRIGHT; + doc["defaultAudiofile"] = settings["DefaultAudiofile"] | ""; + doc["startupSounds"] = settings["StartupSounds"].as(); + doc["wifiMode"] = settings["WifiMode"] | "AP"; + doc["wifiApSSID"] = settings["WifiApSSID"] | kDefaultApSsid; + doc["wifiStaSSID"] = settings["WifiStaSSID"] | ""; + doc["rfidLastUID"] = getRFIDlastUID(); + doc["rfidLastUIDValid"] = getRFIDlastUIDValid(); + doc["fsTotalBytes"] = LittleFS.totalBytes(); + doc["fsUsedBytes"] = LittleFS.usedBytes(); + doc["apIP"] = WiFi.softAPIP().toString(); + doc["staIP"] = WiFi.localIP().toString(); + doc["staConnected"] = WiFi.status() == WL_CONNECTED; + + JsonArray files = doc.createNestedArray("files"); + appendFiles(files); + + JsonArray tags = doc.createNestedArray("tags"); + JsonArray settingsTags = settings["tags"].as(); + for (JsonVariant v : settingsTags) + { + JsonObject t = tags.createNestedObject(); + t["TagUID"] = v["TagUID"] | ""; + t["audiofile"] = v["audiofile"] | ""; + } + + sendJson(200, doc); +} + +static void handleApiSettings(void) +{ + DynamicJsonDocument body(2048); + if (!parseRequestJson(body)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "invalid json"; + sendJson(400, err); + return; + } + + DynamicJsonDocument settings(4096); + if (!loadSettingsDoc(settings)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "settings read failed"; + sendJson(500, err); + return; + } + ensureDefaultSettings(settings); + + if (body.containsKey("audioGain")) + { + float gain = body["audioGain"].as(); + if (!(gain != gain) && gain >= 0.0f && gain <= 1.0f) + { + settings["AudioGain"] = gain; + } + } + + if (body.containsKey("brightness")) + { + int brightness = body["brightness"].as(); + if (brightness < 0) + { + brightness = 0; + } + if (brightness > 255) + { + brightness = 255; + } + settings["Brightness"] = brightness; + } + + if (body.containsKey("startupSounds")) + { + settings["StartupSounds"] = body["startupSounds"].as(); + } + + if (body.containsKey("defaultAudiofile")) + { + String defFileRaw = body["defaultAudiofile"] | ""; + String defFile = normalizeFilePath(defFileRaw); + if (defFileRaw == "") + { + settings["DefaultAudiofile"] = ""; + } + else if (defFile != "" && LittleFS.exists(defFile)) + { + settings["DefaultAudiofile"] = defFile; + } + else + { + DynamicJsonDocument err(160); + err["ok"] = false; + err["error"] = "default audiofile not found"; + sendJson(404, err); + return; + } + } + + if (body.containsKey("wifiMode")) + { + String mode = body["wifiMode"] | "AP"; + if (mode == "AP" || mode == "STA" || mode == "AP+STA") + { + settings["WifiMode"] = mode; + } + } + + if (body.containsKey("wifiApSSID")) + { + String apSsid = body["wifiApSSID"] | ""; + settings["WifiApSSID"] = apSsid == "" ? kDefaultApSsid : apSsid; + } + + if (body.containsKey("wifiApPSK")) + { + settings["WifiApPSK"] = body["wifiApPSK"] | ""; + } + + if (body.containsKey("wifiStaSSID")) + { + settings["WifiStaSSID"] = body["wifiStaSSID"] | ""; + } + + if (body.containsKey("wifiStaPSK")) + { + settings["WifiStaPSK"] = body["wifiStaPSK"] | ""; + } + + if (!saveSettingsDoc(settings)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "settings save failed"; + sendJson(500, err); + return; + } + + applyRuntimeSettings(settings); + + DynamicJsonDocument ok(128); + ok["ok"] = true; + sendJson(200, ok); +} + +static void handleApiWifiApply(void) +{ + configureWifiFromSettings(); + DynamicJsonDocument ok(256); + ok["ok"] = true; + ok["apIP"] = WiFi.softAPIP().toString(); + ok["staIP"] = WiFi.localIP().toString(); + ok["staConnected"] = WiFi.status() == WL_CONNECTED; + sendJson(200, ok); +} + +static void handleApiRfid(void) +{ + DynamicJsonDocument doc(256); + doc["ok"] = true; + doc["uid"] = getRFIDlastUID(); + doc["uidValid"] = getRFIDlastUIDValid(); + doc["lastTagTime"] = getLastTagTime(); + sendJson(200, doc); +} + +static void handleApiRfidClear(void) +{ + clearRFIDlastUID(); + DynamicJsonDocument doc(64); + doc["ok"] = true; + sendJson(200, doc); +} + +static void handleApiTagAssign(void) +{ + DynamicJsonDocument body(1024); + if (!parseRequestJson(body)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "invalid json"; + sendJson(400, err); + return; + } + + String uid = body["uid"] | ""; + if (uid == "") + { + uid = getRFIDlastUID(); + } + String audiofileRaw = body["audiofile"] | ""; + String audiofile = normalizeFilePath(audiofileRaw); + bool disableTag = (audiofileRaw == "" || audiofileRaw == "none"); + + if (uid == "") + { + DynamicJsonDocument err(160); + err["ok"] = false; + err["error"] = "uid required"; + sendJson(400, err); + return; + } + + if (!disableTag && audiofile == "") + { + DynamicJsonDocument err(160); + err["ok"] = false; + err["error"] = "invalid audiofile path"; + sendJson(400, err); + return; + } + + if (!disableTag && !LittleFS.exists(audiofile)) + { + DynamicJsonDocument err(160); + err["ok"] = false; + err["error"] = "audiofile not found"; + sendJson(404, err); + return; + } + + DynamicJsonDocument settings(4096); + if (!loadSettingsDoc(settings)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "settings read failed"; + sendJson(500, err); + return; + } + ensureDefaultSettings(settings); + + JsonArray tags = settings["tags"].as(); + bool updated = false; + for (JsonVariant v : tags) + { + String existing = v["TagUID"] | ""; + if (existing == uid) + { + v["audiofile"] = disableTag ? "" : audiofile; + updated = true; + break; + } + } + if (!updated) + { + JsonObject tag = tags.createNestedObject(); + tag["TagUID"] = uid; + tag["audiofile"] = disableTag ? "" : audiofile; + } + + if (!saveSettingsDoc(settings)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "settings save failed"; + sendJson(500, err); + return; + } + + DynamicJsonDocument ok(128); + ok["ok"] = true; + sendJson(200, ok); +} + +static void handleApiTagDelete(void) +{ + DynamicJsonDocument body(512); + if (!parseRequestJson(body)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "invalid json"; + sendJson(400, err); + return; + } + + String uid = body["uid"] | ""; + if (uid == "") + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "uid required"; + sendJson(400, err); + return; + } + + DynamicJsonDocument settings(4096); + if (!loadSettingsDoc(settings)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "settings read failed"; + sendJson(500, err); + return; + } + ensureDefaultSettings(settings); + + JsonArray tags = settings["tags"].as(); + bool removed = false; + for (size_t i = 0; i < tags.size(); ++i) + { + String existing = tags[i]["TagUID"] | ""; + if (existing == uid) + { + tags.remove(i); + removed = true; + break; + } + } + + if (!removed) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "tag not found"; + sendJson(404, err); + return; + } + + if (!saveSettingsDoc(settings)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "settings save failed"; + sendJson(500, err); + return; + } + + DynamicJsonDocument ok(64); + ok["ok"] = true; + sendJson(200, ok); +} + +static void handleApiFileDelete(void) +{ + DynamicJsonDocument body(512); + if (!parseRequestJson(body)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "invalid json"; + sendJson(400, err); + return; + } + + String path = normalizeFilePath(body["path"] | ""); + if (path == "" || path == kSettingsPath) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "invalid path"; + sendJson(400, err); + return; + } + + if (!LittleFS.exists(path) || !LittleFS.remove(path)) + { + DynamicJsonDocument err(128); + err["ok"] = false; + err["error"] = "delete failed"; + sendJson(500, err); + return; + } + + DynamicJsonDocument ok(64); + ok["ok"] = true; + sendJson(200, ok); +} + +static void handleApiFileUploadDone(void) +{ + if (uploadFile) + { + uploadFile.close(); + } + DynamicJsonDocument ok(64); + ok["ok"] = true; + sendJson(200, ok); +} + +static void handleApiFileUploadData(void) +{ + HTTPUpload &upload = webServer.upload(); + + if (upload.status == UPLOAD_FILE_START) + { + String filename = normalizeFilePath(upload.filename); + if (filename == "" || filename == kSettingsPath) + { + return; + } + uploadFile = LittleFS.open(filename, "w"); + } + else if (upload.status == UPLOAD_FILE_WRITE) + { + if (uploadFile) + { + uploadFile.write(upload.buf, upload.currentSize); + } + } + else if (upload.status == UPLOAD_FILE_END) + { + if (uploadFile) + { + uploadFile.close(); + } + } +} + +static void startWebServer(void) +{ + if (webServerStarted) + { + return; + } + + webServer.on("/", HTTP_GET, []() { + webServer.send(200, "text/html", kPortalPage); + }); + + webServer.on("/api/status", HTTP_GET, handleApiStatus); + webServer.on("/api/settings", HTTP_POST, handleApiSettings); + webServer.on("/api/wifi/apply", HTTP_POST, handleApiWifiApply); + webServer.on("/api/rfid", HTTP_GET, handleApiRfid); + webServer.on("/api/rfid/clear", HTTP_POST, handleApiRfidClear); + webServer.on("/api/tag/assign", HTTP_POST, handleApiTagAssign); + webServer.on("/api/tag/delete", HTTP_POST, handleApiTagDelete); + webServer.on("/api/files/delete", HTTP_POST, handleApiFileDelete); + webServer.on("/api/files/upload", HTTP_POST, handleApiFileUploadDone, handleApiFileUploadData); + + webServer.onNotFound([]() { + DynamicJsonDocument err(96); + err["ok"] = false; + err["error"] = "not found"; + sendJson(404, err); + }); + + webServer.begin(); + webServerStarted = true; +} + +static void stopWebServer(void) +{ + if (!webServerStarted) + { + return; + } + webServer.stop(); + webServerStarted = false; +} + +static void installOtaCallbacks(void) +{ + if (otaCallbacksInstalled) + { + return; + } + + ArduinoOTA.setHostname("muziekdoos"); + ArduinoOTA + .onStart([]() { + String type; + ota.m_otaState = otaStart; + if (ArduinoOTA.getCommand() == U_FLASH) + { + type = "sketch"; + } + else + { + type = "filesystem"; + LittleFS.end(); + } + Serial.println("Start updating " + type); + }) + .onEnd([]() { + log_i("End"); + ota.m_otaState = otaDone; + }) + .onProgress([](unsigned int progress, unsigned int total) { + log_i("Progress: %u%%\r", (progress / (total / 100))); + ota.m_otaState = otaBusy; + PowerKeepAlive(); + }) + .onError([](ota_error_t error) { + log_e("Error[%u]: ", error); + ota.m_otaState = otaError; + if (error == OTA_AUTH_ERROR) + log_e("Auth Failed"); + else if (error == OTA_BEGIN_ERROR) + log_e("Begin Failed"); + else if (error == OTA_CONNECT_ERROR) + log_e("Connect Failed"); + else if (error == OTA_RECEIVE_ERROR) + log_e("Receive Failed"); + else if (error == OTA_END_ERROR) + log_e("End Failed"); + }); + + otaCallbacksInstalled = true; +} + bool OtaProcess_class::initialize(void) { if (m_newState) { log_i("Otastate = initialize"); m_newState = false; - m_otaState = otaScan; + m_otaState = otaInit; } switch (m_otaState) { - - case otaScan: - { - log_i("Otastate = initialize(scan)"); - - int n = WiFi.scanNetworks(); - if (n == 0) - { - log_e("no networks found"); - m_otaState = otaError; - } - else - { - log_i(" %d wifi networks found", n); - String tmppsk = ""; - for (int i = 0; i < n; ++i) - { - tmppsk = GetWifiPassword(WiFi.SSID(i)); - if(tmppsk != "" && m_ssid == "") - { - m_ssid = WiFi.SSID(i); - m_psk = tmppsk; - } - else{ - log_w("using fallback SSID %s", SECRET_SSID); - m_ssid = SECRET_SSID; - m_psk = SECRET_PASS; - } - log_i("[%d] %s %s (%d) [%s]", i, WiFi.SSID(i), (WiFi.encryptionType(i) == WIFI_AUTH_OPEN) ? " " : "*", WiFi.RSSI(i), (tmppsk != "")? "OK": "Not congigured"); - } - } - if(m_ssid != "") - { - m_otaState = otaInit; - log_i("Otastate = initialize(scan): done"); - } - else - { - m_otaState = otaError; - log_e("Otastate = initialize(scan): NOT CONFIGURED"); - } - - } - break; - case otaInit: { log_i("Otastate = initialize(init)"); - WiFi.begin(m_ssid.c_str(), m_psk.c_str()); - m_otaState = otaConnect; - log_i("Otastate = initialize(init):done"); - } - break; - - case otaConnect: - { - uint32_t timeTemp = millis(); - if (timeTemp - m_lastconnectTime > WIFICONNECTINTERVAL) - { - log_i("Otastate = initialize(connect)"); - if (WiFi.status() != WL_CONNECTED) - { - log_e("Connection Failed! Retry..."); - } - else - { - m_otaState = otaSetup; - } - m_lastconnectTime = timeTemp; - } + configureWifiFromSettings(); + m_otaState = otaSetup; } break; case otaSetup: { log_i("Otastate = initialize(setup)"); - // Port defaults to 3232 - // ArduinoOTA.setPort(3232); - // Hostname defaults to esp3232-[MAC] - ArduinoOTA.setHostname("muziekdoos"); - // No authentication by default - // ArduinoOTA.setPassword("admin"); - // Password can be set with it's md5 value as well - // MD5(admin) = 21232f297a57a5a743894a0e4a801fc3 - // ArduinoOTA.setPasswordHash("21232f297a57a5a743894a0e4a801fc3"); - ArduinoOTA - .onStart([]() - { - String type; - ota.m_otaState = otaStart; - if (ArduinoOTA.getCommand() == U_FLASH) - { - type = "sketch"; - } - else // U_SPIFFS - { - type = "filesystem"; - LittleFS.end(); - } - // NOTE: if updating SPIFFS this would be the place to unmount SPIFFS using SPIFFS.end() - Serial.println("Start updating " + type); }) - .onEnd([]() - { log_i("End"); ota.m_otaState = otaDone; }) - .onProgress([](unsigned int progress, unsigned int total) - { log_i("Progress: %u%%\r", (progress / (total / 100))); ota.m_otaState = otaBusy; PowerKeepAlive();}) - .onError([](ota_error_t error) - { - log_e("Error[%u]: ", error); - ota.m_otaState = otaError; - if (error == OTA_AUTH_ERROR) log_e("Auth Failed"); - else if (error == OTA_BEGIN_ERROR) log_e("Begin Failed"); - else if (error == OTA_CONNECT_ERROR) log_e("Connect Failed"); - else if (error == OTA_RECEIVE_ERROR) log_e("Receive Failed"); - else if (error == OTA_END_ERROR) log_e("End Failed"); }); - + installOtaCallbacks(); m_otaState = otaStart; } break; + case otaStart: { log_i("Otastate = initialize(start)"); ArduinoOTA.begin(); - log_i("Ota ready, IPaddress:%s", WiFi.localIP().toString()); - m_otaState = otaInitDone; + startWebServer(); + setRFIDscanState(true); setLedBlink(true); + m_otaState = otaInitDone; } break; + case otaInitDone: { setProcessState(processIdle); return true; } + default: break; } @@ -158,9 +814,15 @@ void OtaProcess_class::idle(void) log_i("Otastate = Idle"); m_newState = false; } - if (m_otaState == otaInitDone) + + if (m_otaState == otaInitDone || m_otaState == otaBusy) { ArduinoOTA.handle(); + if (webServerStarted) + { + webServer.handleClient(); + } + handleRfid(); } } @@ -182,6 +844,8 @@ void OtaProcess_class::disabled(void) log_i("Otastate = disabled"); m_newState = false; } + stopWebServer(); + setRFIDscanState(false); } void OtaProcess_class::halted(void) @@ -202,6 +866,9 @@ void OtaProcess_class::stopped(void) m_newState = false; } + stopWebServer(); + setRFIDscanState(false); + if (WiFi.getMode() != WIFI_MODE_NULL) { WiFi.mode(WIFI_MODE_NULL); @@ -220,7 +887,7 @@ void otaDisable(void) void initOta(void) { - /* noting */ + /* nothing */ } OTASTATES getOtaState(void) @@ -231,4 +898,4 @@ OTASTATES getOtaState(void) void handleOta(void) { ota.run(); -} \ No newline at end of file +} diff --git a/FW/leo_muziekdoos_esp32/src/ota.h b/FW/leo_muziekdoos_esp32/src/ota.h index 7262811..a56987c 100644 --- a/FW/leo_muziekdoos_esp32/src/ota.h +++ b/FW/leo_muziekdoos_esp32/src/ota.h @@ -5,6 +5,7 @@ #include "config.h" #include "power.h" +#include #include "ArduinoOTA.h" #include "JC_Button.h" #include "LittleFS.h" diff --git a/FW/leo_muziekdoos_esp32/src/ota_webui.cpp b/FW/leo_muziekdoos_esp32/src/ota_webui.cpp new file mode 100644 index 0000000..e6d3f6b --- /dev/null +++ b/FW/leo_muziekdoos_esp32/src/ota_webui.cpp @@ -0,0 +1,344 @@ +#include "ota_webui.h" + +const char kPortalPage[] PROGMEM = R"HTML( + + + + + +Muziekdoos OTA Config + + + +

Muziekdoos OTA Config

+
+
Loading...
+
+
Storage: --
+
+
+
+
+
+ +
+

Settings

+ + + + + +
+ +
+

WiFi

+ +
+ + +
+
+ + +
+ + +
+ +
+

Audio Files

+ + +
FileSize
+
+ +
+

RFID Cards

+
+ + +
+ + + +
+
Tag editor: change UID, choose an audio file, or select None to disable a tag.
+
UIDFile
+
+ + + + +)HTML"; diff --git a/FW/leo_muziekdoos_esp32/src/ota_webui.h b/FW/leo_muziekdoos_esp32/src/ota_webui.h new file mode 100644 index 0000000..deb96f3 --- /dev/null +++ b/FW/leo_muziekdoos_esp32/src/ota_webui.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +extern const char kPortalPage[]; diff --git a/FW/leo_muziekdoos_esp32/src/power.cpp b/FW/leo_muziekdoos_esp32/src/power.cpp index a2c9895..c92a004 100644 --- a/FW/leo_muziekdoos_esp32/src/power.cpp +++ b/FW/leo_muziekdoos_esp32/src/power.cpp @@ -1,6 +1,13 @@ #include "power.h" bool powerbutton_released = true; +bool poweronPromptPlayed = false; +bool poweronPromptPending = false; +bool poweroffPromptPlayed = false; +bool poweroffPromptPending = false; +uint32_t poweronWaitLogTs = 0; +uint32_t poweroffWaitLogTs = 0; +uint32_t poweronHoldLogTs = 0; uint32_t PowerLastKeepAlive = 0; uint32_t PowerOtaLongPressTime = 0; @@ -9,6 +16,11 @@ uint32_t powerstate_timer = 0; POWERSTATES powerstate = off; POWERSTATES lastState = off; +static bool startupSoundsEnabled() +{ + return GetBoolparam("StartupSounds", true); +} + Button buttonPower(PWR_BTN, 250UL, 1U, 0); extern OtaProcess_class ota; @@ -37,7 +49,6 @@ void PowerKeepAlive(void) void powerOn(void) { digitalWrite(PWR_HOLD, HIGH); - delay(200); } void powerOff(void) @@ -59,6 +70,10 @@ void handlePowerState(void) { if (buttonread) { + poweronPromptPlayed = false; + poweronPromptPending = false; + poweroffPromptPlayed = false; + poweroffPromptPending = false; powerstate = poweringOn; } powerOff(); @@ -95,20 +110,59 @@ void handlePowerState(void) case poweringOn2: { powerOn(); + if (!poweronPromptPlayed) + { + if (startupSoundsEnabled()) + { + log_i("poweringOn2: trigger prompt (audioInit=%d)", getAudioInitStatus()); + setAudioState(true); + playSong("/ping_mech_up.mp3", false); + poweronPromptPending = true; + log_i("poweringOn2: prompt sound started"); + } + else + { + poweronPromptPending = false; + log_i("poweringOn2: startup sound disabled"); + } + poweronPromptPlayed = true; + } + + if (poweronPromptPending && !getAudioState()) + { + poweronPromptPending = false; + log_i("poweringOn2: prompt finished"); + } + if (buttonPower.releasedFor(200)) { - powerstate = powerinit; - if (CheckBattery()) + if (poweronPromptPending) { - log_w("poweringOn: Lowbat"); - SetLedColor(CRGB::Red, true); + if (millis() - poweronWaitLogTs > 1000) + { + log_i("poweringOn2: waiting for prompt to finish before powerinit"); + poweronWaitLogTs = millis(); + } + } + else + { + powerstate = powerinit; + if (CheckBattery()) + { + log_w("poweringOn: Lowbat"); + SetLedColor(CRGB::Red, true); - // powerstate = lowBatt; + // powerstate = lowBatt; + } } } else { - log_i("Release for poweron, hold for %d to OTA", (POWERBUTTONOTADELAY - buttonPower.getPressedFor())); + if (millis() - poweronHoldLogTs > 1000) + { + log_i("Release for poweron, hold for %d to OTA", (POWERBUTTONOTADELAY - buttonPower.getPressedFor())); + poweronHoldLogTs = millis(); + } } if (buttonPower.pressedFor(POWERBUTTONOTADELAY)) { @@ -153,13 +207,16 @@ void handlePowerState(void) if (buttonPower.pressedFor(POWERBUTTONDELAY)) { powerstate = poweringOff2; - setAudioState(false); + poweroffPromptPlayed = false; + poweroffPromptPending = false; SetLedColor(CRGB::Red, true); log_w("poweringoff: 3/3 ==> powerOff"); } else { + poweroffPromptPlayed = false; + poweroffPromptPending = false; powerstate = lastState; SetLedColor(CRGB::Green); } @@ -167,10 +224,45 @@ void handlePowerState(void) break; case poweringOff2: { + if (!poweroffPromptPlayed) + { + if (startupSoundsEnabled()) + { + log_i("poweringOff2: trigger prompt (audioInit=%d)", getAudioInitStatus()); + setAudioState(true); + playSong("/ping_mech_down.mp3", false); + poweroffPromptPending = true; + log_i("poweringOff2: prompt sound started"); + } + else + { + poweroffPromptPending = false; + log_i("poweringOff2: shutdown sound disabled"); + } + poweroffPromptPlayed = true; + } + + if (poweroffPromptPending && !getAudioState()) + { + poweroffPromptPending = false; + log_i("poweringOff2: prompt finished"); + } + if (buttonPower.releasedFor(200)) { - powerstate = off; - SetLedColor(CRGB::Red, true); + if (poweroffPromptPending) + { + if (millis() - poweroffWaitLogTs > 1000) + { + log_i("poweringOff2: waiting for prompt to finish before off"); + poweroffWaitLogTs = millis(); + } + } + else + { + powerstate = off; + SetLedColor(CRGB::Red, true); + } } } break; diff --git a/FW/leo_muziekdoos_esp32/src/rfid.cpp b/FW/leo_muziekdoos_esp32/src/rfid.cpp index 282572c..d14b056 100644 --- a/FW/leo_muziekdoos_esp32/src/rfid.cpp +++ b/FW/leo_muziekdoos_esp32/src/rfid.cpp @@ -1,7 +1,7 @@ #include "rfid.h" PN532_SPI pn532spi(SPI, NFC_SS, NFC_SCK, NFC_MISO, NFC_MOSI); -NfcAdapter nfc = NfcAdapter(pn532spi); +PN532 nfc(pn532spi); uint32_t lastRFID = 0; uint32_t lastRFIDlog = 0; @@ -19,8 +19,33 @@ bool RfidScanActive = false; void initRfid() { log_i("RFID init:"); // shows in serial that it is ready to read - nfc.begin(true); + nfc.begin(); + uint32_t versiondata = nfc.getFirmwareVersion(); + if (!versiondata) + { + log_e("Didn't find PN53x board"); + RfidinitOK = false; + return; + } + + log_i("Found chip PN5%X", (versiondata >> 24) & 0xFF); + log_i("Firmware ver. %d.%d", (versiondata >> 16) & 0xFF, (versiondata >> 8) & 0xFF); + if (!nfc.SAMConfig()) + { + // Some PN532 library revisions can report SAMConfig failure even when basic polling still works. + // Keep running and rely on runtime scan diagnostics. + log_w("SAMConfig failed, continuing with polling diagnostics"); + } + else + { + log_i("SAMConfig OK"); + } + + // Keep trying for passive targets to avoid missing cards due to short retry windows. + nfc.setPassiveActivationRetries(0xFF); + scantimeout = GetIntparam("ScanTimeout", RFIDTIMEOUT ); + log_i("RFID scan timeout = %lu ms", scantimeout); RfidinitOK = true; log_i("RFID init: OK"); // shows in serial that it is ready to read } @@ -31,17 +56,72 @@ void handleRfid() uint32_t timeNow = millis(); if (timeNow - lastRFID > RFIDINTERVAL && RfidScanActive) { - if(timeNow - lastRFIDlog > RFIDLOGINTERVAL) + bool doDiagLog = (timeNow - lastRFIDlog > RFIDLOGINTERVAL); + if (doDiagLog) { log_i("scanning"); lastRFIDlog = timeNow; + + // Retry SAM setup occasionally; some modules recover after power/radio settling. + if (nfc.SAMConfig()) + { + log_i("SAMConfig retry: OK"); + } + else + { + log_w("SAMConfig retry: failed"); + } } - if (nfc.tagPresent(RFIDTIMEOUT)) + uint8_t uid[7] = {0}; + uint8_t uidLength = 0; + bool tagFound = nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLength, scantimeout, true); + + if (!tagFound && doDiagLog) { - NfcTag tag = nfc.read(); - lastUid = tag.getUidString(); + uint8_t uid50[7] = {0}; + uint8_t len50 = 0; + uint8_t uid500[7] = {0}; + uint8_t len500 = 0; + uint8_t uid2000[7] = {0}; + uint8_t len2000 = 0; + + bool found50 = nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid50, &len50, 50, true); + bool found500 = nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid500, &len500, 500, true); + bool found2000 = nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid2000, &len2000, 2000, true); + + log_w("No tag in normal poll; probe result: 50ms=%d(len=%u), 500ms=%d(len=%u), 2000ms=%d(len=%u)", + found50, len50, found500, len500, found2000, len2000); + + if (found500) + { + memcpy(uid, uid500, sizeof(uid)); + uidLength = len500; + tagFound = true; + } + else if (found2000) + { + memcpy(uid, uid2000, sizeof(uid)); + uidLength = len2000; + tagFound = true; + } + } + + if (tagFound) + { + lastUid = ""; + for (uint8_t i = 0; i < uidLength; i++) + { + char hex[4]; + snprintf(hex, sizeof(hex), "%02X", uid[i]); + if (i > 0) + { + lastUid += " "; + } + lastUid += hex; + } lastTagTime = millis(); + log_d("RAW UID from NFC: '%s' (length: %d)", lastUid.c_str(), lastUid.length()); log_i("found tag %s",lastUid.c_str()); } lastRFID = timeNow; @@ -67,7 +147,8 @@ bool getRFIDlastUIDValid(void) { if(lastUid == "") { - return false; + // If no tag is present, allow default playback selection from config. + return getConfigSong("") != ""; } return (getUIDvalid(lastUid)); } diff --git a/FW/leo_muziekdoos_esp32/src/storage.cpp b/FW/leo_muziekdoos_esp32/src/storage.cpp index c472cfb..fa32f57 100644 --- a/FW/leo_muziekdoos_esp32/src/storage.cpp +++ b/FW/leo_muziekdoos_esp32/src/storage.cpp @@ -2,7 +2,6 @@ #include "storage.h" #include -#include "FS.h" #if defined ESP_ARDUINO_VERSION_VAL #if (ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(2, 0, 0))