From 2ca46790799f589a22a719ce5b64aa4b929661d1 Mon Sep 17 00:00:00 2001 From: Willem Oldemans <> Date: Mon, 1 Jun 2026 16:16:46 +0200 Subject: [PATCH] bug: NFC broken HW/SW? implement webui (in OTA mode) implemented startup and shutdown sound moved audio playback (and led) to seperate task for better audio latency/stabililty --- FW/leo_muziekdoos_esp32/.gitmodules | 27 + FW/leo_muziekdoos_esp32/AUDIO_IMPROVEMENTS.md | 54 ++ .../data/ping_mech_down.mp3 | Bin 0 -> 19644 bytes FW/leo_muziekdoos_esp32/data/ping_mech_up.mp3 | Bin 0 -> 20480 bytes FW/leo_muziekdoos_esp32/data/settings.json | 6 + FW/leo_muziekdoos_esp32/lib/ESP8266Audio | 2 +- FW/leo_muziekdoos_esp32/lib/PN532 | 2 +- FW/leo_muziekdoos_esp32/platformio.ini | 19 +- .../scripts/generate_mech_pings.py | 152 +++ FW/leo_muziekdoos_esp32/src/audio.cpp | 452 +++++++-- FW/leo_muziekdoos_esp32/src/audio.h | 3 +- FW/leo_muziekdoos_esp32/src/config.cpp | 114 ++- FW/leo_muziekdoos_esp32/src/config.h | 4 + FW/leo_muziekdoos_esp32/src/led.cpp | 10 + FW/leo_muziekdoos_esp32/src/led.h | 2 + FW/leo_muziekdoos_esp32/src/main.cpp | 56 +- FW/leo_muziekdoos_esp32/src/ota.cpp | 891 +++++++++++++++--- FW/leo_muziekdoos_esp32/src/ota.h | 1 + FW/leo_muziekdoos_esp32/src/ota_webui.cpp | 344 +++++++ FW/leo_muziekdoos_esp32/src/ota_webui.h | 5 + FW/leo_muziekdoos_esp32/src/power.cpp | 112 ++- FW/leo_muziekdoos_esp32/src/rfid.cpp | 95 +- FW/leo_muziekdoos_esp32/src/storage.cpp | 1 - 23 files changed, 2084 insertions(+), 268 deletions(-) create mode 100644 FW/leo_muziekdoos_esp32/.gitmodules create mode 100644 FW/leo_muziekdoos_esp32/AUDIO_IMPROVEMENTS.md create mode 100644 FW/leo_muziekdoos_esp32/data/ping_mech_down.mp3 create mode 100644 FW/leo_muziekdoos_esp32/data/ping_mech_up.mp3 create mode 100644 FW/leo_muziekdoos_esp32/scripts/generate_mech_pings.py create mode 100644 FW/leo_muziekdoos_esp32/src/ota_webui.cpp create mode 100644 FW/leo_muziekdoos_esp32/src/ota_webui.h 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 0000000000000000000000000000000000000000..196d2c19c3463742891b77b8da9ea37b8921cb7f GIT binary patch literal 19644 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 XNds{4AE}0nAoZg$G=d_VNUs6__XOV9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2f3808b5f8dad2cfd2717498e2c0a0253dfa7ee1 GIT binary patch literal 20480 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 BN_7AL literal 0 HcmV?d00001 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))