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
This commit is contained in:
Willem Oldemans
2026-06-01 16:16:46 +02:00
parent 1d7b8fb492
commit 2ca4679079
23 changed files with 2084 additions and 268 deletions
+27
View File
@@ -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
@@ -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)
Binary file not shown.
Binary file not shown.
@@ -23,6 +23,12 @@
}], }],
"AudioGain": 0.5, "AudioGain": 0.5,
"StartupSounds": true,
"WifiMode": "AP",
"WifiStaSSID": "",
"WifiStaPSK": "",
"WifiApSSID": "muziekdoos-setup",
"WifiApPSK": "",
"ScanTimeout": 50, "ScanTimeout": 50,
"HardwareVersion": 2, "HardwareVersion": 2,
"GameTimeout": 20000, "GameTimeout": 20000,
+9 -10
View File
@@ -13,27 +13,26 @@ build_src_filter = +<*> -<.git/> -<.svn/> -<example/> -<examples/> -<test/> -<te
[env:esp32-pico] [env:esp32-pico]
platform = espressif32 platform = espressif32@6.4.0
#board = m5stack-atom #board = m5stack-atom
board = pico32 board = pico32
framework = arduino framework = arduino
lib_deps = lib_deps =
bblanchon/ArduinoJson@^6.20.0 bblanchon/ArduinoJson@^6.20.0
fastled/FastLED@^3.5.0 fastled/FastLED@^3.5.0
#robtillaart/AS5600 @ ^0.3.4 robtillaart/AS5600@^0.3.6
robtillaart/AS5600 @ ^0.3.6 build_src_filter = ${env.build_src_filter} -<lib/ESP8266Audio/src/AudioOutputSPDIF.cpp> -<lib/ESP8266Audio/src/AudioFileSourceHTTPStream.cpp> -<lib/ESP8266Audio/src/AudioFileSourceICYStream.cpp>
LITTLEFS lib_ldf_mode = deep
build_src_filter = ${env.build_src_filter}
lib_ldf_mode = deep+
build_flags = build_flags =
-DHARDWARE=2 -DHARDWARE=2
-DCORE_DEBUG_LEVEL=3 -DCORE_DEBUG_LEVEL=4
-DNDEF_DEBUG=1 -DNDEF_DEBUG=1
-fexceptions -fexceptions
-DNO_SPDIF=1
extra_scripts = ./littlefsbuilder.py extra_scripts = ./littlefsbuilder.py
board_build.filesystem = littlefs board_build.filesystem = littlefs
monitor_speed = 115200 monitor_speed = 115200
#upload_protocol = esptool upload_protocol = esptool
upload_protocol = espota #upload_protocol = espota
upload_port = muziekdoos.local #upload_port = muziekdoos.local
@@ -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()
+349 -103
View File
@@ -1,109 +1,34 @@
#include "audio.h" #include "audio.h"
AudioGeneratorMP3 *mp3; #include <freertos/semphr.h>
AudioFileSourceID3 *id3;
AudioFileSourceLittleFS *file; // Forward declaration
AudioOutputI2S *out; 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 = ""; String nextAudioFile = "";
uint8_t n = 0;
bool audioState = false; bool audioState = false;
bool audioInitOk = false; bool audioInitOk = false;
bool audioRepeat = true;
SemaphoreHandle_t audioMutex = nullptr;
const char *waveFile[] = /**
{"/ringoffire.mp3", * Set the audio amplifier state and update the internal state flag.
"/Let_it_be.mp3", * Drives DAC_SDMODE HIGH (amp on) or LOW (amp off).
"/Billy-Jean.mp3"}; * Must only be called while the audio mutex is held.
*/
// Called when a metadata event occurs (i.e. an ID3 tag, an ICY block, etc. static void setAudioStateLocked(bool state)
void MDCallback(void *cbData, const char *type, bool isUnicode, const char *string)
{ {
// (void)cbData; if (audioState == state)
// log_i("ID3 callback for: %s = '", type);
(void)cbData;
Serial.printf("ID3 callback for: %s = '", type);
if (isUnicode)
{ {
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<const char *>(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; audioState = state;
if (state) if (state)
{ {
@@ -116,36 +41,357 @@ void setAudioState(bool state)
log_i("set Audio state %d", 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<const char *>(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.01.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) bool getAudioInitStatus(void)
{ {
return audioInitOk; 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() void handleAudio()
{ {
if (!audioLock())
{
return;
}
if (!audioState) if (!audioState)
{ {
if (mp3->isRunning()) if (mp3 != nullptr && mp3->isRunning())
{ {
log_w("Audio: stop playback"); 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"); 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();
} }
+2 -1
View File
@@ -16,8 +16,9 @@ void initAudio(void);
void handleAudio(void); void handleAudio(void);
bool getAudioInitStatus(void); bool getAudioInitStatus(void);
void playSong(String filename); void playSong(String filename, bool repeat = true);
void setAudioState(bool state); void setAudioState(bool state);
bool getAudioState(void); bool getAudioState(void);
void setAudioGain(float gain);
+96 -18
View File
@@ -1,6 +1,5 @@
#include "config.h" #include "config.h"
#include <vector> #include <vector>
#include "FS.h"
#include <LittleFS.h> #include <LittleFS.h>
#include "ArduinoJson.h" #include "ArduinoJson.h"
@@ -83,6 +82,22 @@ float getFloatParam(String param, int def)
return settingsDoc[param]; 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<bool>();
}
void loadConfig(const char *fname) void loadConfig(const char *fname)
{ {
log_i("config: load"); log_i("config: load");
@@ -121,6 +136,31 @@ void loadConfig(const char *fname)
log_i("config: load done"); 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) void initConfig(void)
{ {
log_i("config: init start"); log_i("config: init start");
@@ -135,22 +175,34 @@ void handleConfig(void)
bool getUIDvalid(String uid) bool getUIDvalid(String uid)
{ {
JsonArray array = settingsDoc["tags"].as<JsonArray>(); String song = getConfigSong(uid);
for (JsonVariant v : array) bool valid = song != "";
if (!valid)
{ {
String taguid((const char*)v["TagUID"]); log_e("taguid %s has no active song", uid.c_str());
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 not found",uid.c_str() ); return valid;
return false; }
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) String getConfigSong(String uid)
@@ -165,10 +217,36 @@ String getConfigSong(String uid)
if (!result) if (!result)
{ {
String filename((const char*)v["audiofile"]); String filename((const char*)v["audiofile"]);
log_i("Tag found in config, filename = %s", filename.c_str()); if (filename == "")
return 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 ""; return "";
} }
+4
View File
@@ -1,12 +1,16 @@
#pragma once #pragma once
#include "Arduino.h" #include "Arduino.h"
#include "ArduinoJson.h"
String getConfigSong(String uid); String getConfigSong(String uid);
bool getUIDvalid(String uid); bool getUIDvalid(String uid);
String GetWifiPassword(String ssid); String GetWifiPassword(String ssid);
int GetIntparam(String param, int def = -1); int GetIntparam(String param, int def = -1);
float getFloatParam( 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 initConfig(void);
void handleConfig(void); void handleConfig(void);
+10
View File
@@ -31,6 +31,16 @@ void SetLedColor(CRGB color, bool blink)
setLedBlink(blink); setLedBlink(blink);
} }
void setLedBrightness(uint8_t brightness)
{
FastLED.setBrightness(brightness);
}
uint8_t getLedBrightness(void)
{
return FastLED.getBrightness();
}
void initLed(void) void initLed(void)
{ {
FastLED.addLeds<SK6812, LED_PIN, GRB>(leds, NUM_LEDS); // GRB ordering is typical FastLED.addLeds<SK6812, LED_PIN, GRB>(leds, NUM_LEDS); // GRB ordering is typical
+2
View File
@@ -17,3 +17,5 @@ void handleLed(void);
void setLedBlink(bool blink); void setLedBlink(bool blink);
void SetLedColor(CRGB color); void SetLedColor(CRGB color);
void SetLedColor(CRGB color, bool blink); void SetLedColor(CRGB color, bool blink);
void setLedBrightness(uint8_t brightness);
uint8_t getLedBrightness(void);
+52 -4
View File
@@ -11,6 +11,29 @@
#include "led.h" #include "led.h"
uint32_t looptime = 0; 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() void setup()
{ {
@@ -30,6 +53,24 @@ void setup()
initSensor(); initSensor();
initLed(); initLed();
initGame(); initGame();
xTaskCreatePinnedToCore(
audioTask,
"audioTask",
4096,
nullptr,
2,
&audioTaskHandle,
1);
xTaskCreatePinnedToCore(
ledTask,
"ledTask",
2048,
nullptr,
1,
&ledTaskHandle,
0);
} }
void loop() void loop()
@@ -38,14 +79,12 @@ void loop()
handlePower(); handlePower();
handleBatterySensor(); handleBatterySensor();
handleLed();
if (getPowerState() == POWERSTATES::on) if (getPowerState() == POWERSTATES::on)
{ {
handleAudio();
handleRfid();
handleHallSensor(); handleHallSensor();
handleGame(); handleGame();
handleRfid();
} }
else if (getPowerState() == POWERSTATES::overTheAir2) else if (getPowerState() == POWERSTATES::overTheAir2)
{ {
@@ -55,5 +94,14 @@ void loop()
{ {
/* noting */ /* 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));
} }
+778 -111
View File
@@ -1,150 +1,806 @@
#include "ota.h" #include "ota.h"
#include <ArduinoJson.h>
#include <WebServer.h>
#include "audio.h"
#include "led.h"
#include "ota_webui.h"
#include "rfid.h"
OtaProcess_class ota(100); 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<JsonArray>())
{
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<float>();
if (!(gain != gain) && gain >= 0.0f && gain <= 1.0f)
{
setAudioGain(gain);
}
}
if (doc.containsKey("Brightness"))
{
int brightness = doc["Brightness"].as<int>();
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<bool>();
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<JsonArray>();
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<float>();
if (!(gain != gain) && gain >= 0.0f && gain <= 1.0f)
{
settings["AudioGain"] = gain;
}
}
if (body.containsKey("brightness"))
{
int brightness = body["brightness"].as<int>();
if (brightness < 0)
{
brightness = 0;
}
if (brightness > 255)
{
brightness = 255;
}
settings["Brightness"] = brightness;
}
if (body.containsKey("startupSounds"))
{
settings["StartupSounds"] = body["startupSounds"].as<bool>();
}
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<JsonArray>();
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<JsonArray>();
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) bool OtaProcess_class::initialize(void)
{ {
if (m_newState) if (m_newState)
{ {
log_i("Otastate = initialize"); log_i("Otastate = initialize");
m_newState = false; m_newState = false;
m_otaState = otaScan; m_otaState = otaInit;
} }
switch (m_otaState) 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: case otaInit:
{ {
log_i("Otastate = initialize(init)"); log_i("Otastate = initialize(init)");
WiFi.begin(m_ssid.c_str(), m_psk.c_str()); configureWifiFromSettings();
m_otaState = otaConnect; m_otaState = otaSetup;
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;
}
} }
break; break;
case otaSetup: case otaSetup:
{ {
log_i("Otastate = initialize(setup)"); log_i("Otastate = initialize(setup)");
// Port defaults to 3232 installOtaCallbacks();
// 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"); });
m_otaState = otaStart; m_otaState = otaStart;
} }
break; break;
case otaStart: case otaStart:
{ {
log_i("Otastate = initialize(start)"); log_i("Otastate = initialize(start)");
ArduinoOTA.begin(); ArduinoOTA.begin();
log_i("Ota ready, IPaddress:%s", WiFi.localIP().toString()); startWebServer();
m_otaState = otaInitDone; setRFIDscanState(true);
setLedBlink(true); setLedBlink(true);
m_otaState = otaInitDone;
} }
break; break;
case otaInitDone: case otaInitDone:
{ {
setProcessState(processIdle); setProcessState(processIdle);
return true; return true;
} }
default: default:
break; break;
} }
@@ -158,9 +814,15 @@ void OtaProcess_class::idle(void)
log_i("Otastate = Idle"); log_i("Otastate = Idle");
m_newState = false; m_newState = false;
} }
if (m_otaState == otaInitDone)
if (m_otaState == otaInitDone || m_otaState == otaBusy)
{ {
ArduinoOTA.handle(); ArduinoOTA.handle();
if (webServerStarted)
{
webServer.handleClient();
}
handleRfid();
} }
} }
@@ -182,6 +844,8 @@ void OtaProcess_class::disabled(void)
log_i("Otastate = disabled"); log_i("Otastate = disabled");
m_newState = false; m_newState = false;
} }
stopWebServer();
setRFIDscanState(false);
} }
void OtaProcess_class::halted(void) void OtaProcess_class::halted(void)
@@ -202,6 +866,9 @@ void OtaProcess_class::stopped(void)
m_newState = false; m_newState = false;
} }
stopWebServer();
setRFIDscanState(false);
if (WiFi.getMode() != WIFI_MODE_NULL) if (WiFi.getMode() != WIFI_MODE_NULL)
{ {
WiFi.mode(WIFI_MODE_NULL); WiFi.mode(WIFI_MODE_NULL);
@@ -220,7 +887,7 @@ void otaDisable(void)
void initOta(void) void initOta(void)
{ {
/* noting */ /* nothing */
} }
OTASTATES getOtaState(void) OTASTATES getOtaState(void)
+1
View File
@@ -5,6 +5,7 @@
#include "config.h" #include "config.h"
#include "power.h" #include "power.h"
#include <WiFi.h>
#include "ArduinoOTA.h" #include "ArduinoOTA.h"
#include "JC_Button.h" #include "JC_Button.h"
#include "LittleFS.h" #include "LittleFS.h"
+344
View File
@@ -0,0 +1,344 @@
#include "ota_webui.h"
const char kPortalPage[] PROGMEM = R"HTML(
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Muziekdoos OTA Config</title>
<style>
body { font-family: Verdana, Arial, sans-serif; margin: 16px; background: #f5f8fb; color: #1f2937; }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; margin-bottom: 12px; }
h1 { font-size: 22px; }
h2 { font-size: 16px; margin-top: 0; }
label { display: block; font-size: 13px; margin-top: 8px; }
input, select, button { width: 100%; box-sizing: border-box; margin-top: 4px; padding: 8px; }
button { border: 0; border-radius: 7px; background: #1f6feb; color: #fff; font-weight: 600; }
button.warn { background: #b91c1c; }
table { width: 100%; border-collapse: collapse; }
th, td { border-top: 1px solid #e5e7eb; padding: 6px; font-size: 13px; text-align: left; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.mono { font-family: monospace; }
.nfc-card { padding: 16px; }
.nfc-help { font-size: 12px; color: #4b5563; margin-top: 6px; }
.nfc-card table input, .nfc-card table select { margin-top: 0; padding: 7px; }
.nfc-card td { vertical-align: middle; }
.btn-row { display: flex; gap: 6px; }
.btn-row button { margin-top: 0; }
.storage-wrap { margin-top: 10px; }
.storage-label { font-size: 12px; color: #374151; margin-bottom: 4px; }
.storage-track { width: 100%; height: 10px; border-radius: 999px; background: #e5e7eb; overflow: hidden; }
.storage-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #10b981, #f59e0b, #ef4444); transition: width 120ms ease-out; }
@media(max-width: 760px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h1>Muziekdoos OTA Config</h1>
<div class="card">
<div id="meta" class="mono">Loading...</div>
<div class="storage-wrap">
<div id="storageLabel" class="storage-label">Storage: --</div>
<div class="storage-track">
<div id="storageFill" class="storage-fill"></div>
</div>
</div>
</div>
<div class="card">
<h2>Settings</h2>
<label>Audio gain (0.0 - 1.0)<input id="audioGain" type="number" min="0" max="1" step="0.01"></label>
<label>LED brightness (0 - 255)<input id="brightness" type="number" min="0" max="255" step="1"></label>
<label>Default playback for unknown tags
<select id="defaultAudiofile"></select>
</label>
<label><input id="startupSounds" type="checkbox" style="width:auto"> Enable startup/shutdown sounds</label>
<button onclick="saveSettings()">Save settings</button>
</div>
<div class="card">
<h2>WiFi</h2>
<label>Mode
<select id="wifiMode">
<option>AP</option>
<option>STA</option>
<option>AP+STA</option>
</select>
</label>
<div class="row">
<label>AP SSID<input id="wifiApSSID" type="text"></label>
<label>AP Password (empty = open)<input id="wifiApPSK" type="text"></label>
</div>
<div class="row">
<label>STA SSID<input id="wifiStaSSID" type="text"></label>
<label>STA Password<input id="wifiStaPSK" type="text"></label>
</div>
<button onclick="saveWifi()">Save WiFi settings</button>
<button onclick="applyWifi()">Apply WiFi now</button>
</div>
<div class="card">
<h2>Audio Files</h2>
<input id="fileUpload" type="file">
<button onclick="uploadFile()">Upload file</button>
<table><thead><tr><th>File</th><th>Size</th><th></th></tr></thead><tbody id="files"></tbody></table>
</div>
<div class="card nfc-card">
<h2>RFID Cards</h2>
<div class="row">
<label>UID (empty = last scanned)<input id="uid" type="text" placeholder="AA BB CC DD"></label>
<label>Audio file path<input id="audiofile" type="text" placeholder="/song.mp3"></label>
</div>
<button onclick="assignTag()">Assign tag</button>
<button onclick="refreshRfid()">Read last UID</button>
<button class="warn" onclick="clearRfid()">Clear last UID</button>
<div id="rfidState" class="mono"></div>
<div class="nfc-help">Tag editor: change UID, choose an audio file, or select None to disable a tag.</div>
<table><thead><tr><th>UID</th><th>File</th><th></th></tr></thead><tbody id="tags"></tbody></table>
</div>
<script>
async function api(path, method='GET', body) {
const res = await fetch(path, {
method,
headers: {'Content-Type': 'application/json'},
body: body ? JSON.stringify(body) : undefined
});
return res.json();
}
function normalizePathValue(name) {
if (!name) return '';
return name.startsWith('/') ? name : ('/' + name);
}
async function refresh() {
const s = await api('/api/status');
if (!s.ok) {
document.getElementById('meta').textContent = 'Status load failed';
return;
}
document.getElementById('meta').textContent = `FS ${s.fsUsedBytes}/${s.fsTotalBytes} bytes | AP ${s.apIP} | STA ${s.staConnected ? ('up ' + s.staIP) : 'down'}`;
const used = Number(s.fsUsedBytes || 0);
const total = Number(s.fsTotalBytes || 0);
const free = Math.max(total - used, 0);
const percent = total > 0 ? Math.min(100, Math.max(0, (used * 100) / total)) : 0;
document.getElementById('storageLabel').textContent = `Storage: used ${used} B | free ${free} B (${percent.toFixed(1)}% used)`;
document.getElementById('storageFill').style.width = `${percent.toFixed(1)}%`;
document.getElementById('audioGain').value = s.audioGain;
document.getElementById('brightness').value = s.brightness;
document.getElementById('startupSounds').checked = !!s.startupSounds;
document.getElementById('wifiMode').value = s.wifiMode || 'AP';
document.getElementById('wifiApSSID').value = s.wifiApSSID || '';
document.getElementById('wifiStaSSID').value = s.wifiStaSSID || '';
const fileNames = (s.files || []).map(f => f.name || '').filter(n => n);
const configuredNames = [];
if (s.defaultAudiofile) {
configuredNames.push(s.defaultAudiofile);
}
(s.tags || []).forEach(t => {
if (t.audiofile) {
configuredNames.push(t.audiofile);
}
});
// Canonicalize by leading slash to avoid duplicates like song.mp3 and /song.mp3.
const optionsByCanonical = new Map();
fileNames.forEach(name => {
const canonical = normalizePathValue(name);
if (canonical && !optionsByCanonical.has(canonical)) {
optionsByCanonical.set(canonical, name);
}
});
// Settings values override labels so UI reflects configured text when available.
configuredNames.forEach(name => {
const canonical = normalizePathValue(name);
if (canonical) {
optionsByCanonical.set(canonical, name);
}
});
const defaultSelect = document.getElementById('defaultAudiofile');
defaultSelect.innerHTML = '';
const defaultNone = document.createElement('option');
defaultNone.value = '';
defaultNone.textContent = 'None';
defaultSelect.appendChild(defaultNone);
optionsByCanonical.forEach((label, canonical) => {
const opt = document.createElement('option');
opt.value = canonical;
opt.textContent = label;
defaultSelect.appendChild(opt);
});
defaultSelect.value = normalizePathValue(s.defaultAudiofile || '');
const files = document.getElementById('files');
files.innerHTML = '';
(s.files || []).forEach(f => {
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.className = 'mono';
tdName.textContent = f.name || '';
const tdSize = document.createElement('td');
tdSize.textContent = String(f.size || 0);
const tdAction = document.createElement('td');
const btn = document.createElement('button');
btn.className = 'warn';
btn.textContent = 'Delete';
btn.addEventListener('click', () => deleteFile(f.name || ''));
tdAction.appendChild(btn);
tr.appendChild(tdName);
tr.appendChild(tdSize);
tr.appendChild(tdAction);
files.appendChild(tr);
});
const tags = document.getElementById('tags');
tags.innerHTML = '';
(s.tags || []).forEach(t => {
const tr = document.createElement('tr');
const tdUid = document.createElement('td');
const uidInput = document.createElement('input');
uidInput.type = 'text';
uidInput.className = 'mono';
uidInput.value = t.TagUID || '';
tdUid.appendChild(uidInput);
const tdFile = document.createElement('td');
const fileSelect = document.createElement('select');
const noneOpt = document.createElement('option');
noneOpt.value = '';
noneOpt.textContent = 'None (disabled)';
fileSelect.appendChild(noneOpt);
optionsByCanonical.forEach((label, canonical) => {
const opt = document.createElement('option');
opt.value = canonical;
opt.textContent = label;
fileSelect.appendChild(opt);
});
fileSelect.value = normalizePathValue(t.audiofile || '');
tdFile.appendChild(fileSelect);
const tdAction = document.createElement('td');
const btnRow = document.createElement('div');
btnRow.className = 'btn-row';
const saveBtn = document.createElement('button');
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', async () => {
await saveTagRow(t.TagUID || '', uidInput.value.trim(), fileSelect.value || '');
});
const delBtn = document.createElement('button');
delBtn.className = 'warn';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', async () => {
await deleteTag(t.TagUID || '');
});
btnRow.appendChild(saveBtn);
btnRow.appendChild(delBtn);
tdAction.appendChild(btnRow);
tr.appendChild(tdUid);
tr.appendChild(tdFile);
tr.appendChild(tdAction);
tags.appendChild(tr);
});
document.getElementById('rfidState').textContent = `Last UID: ${s.rfidLastUID || '(none)'} | Known: ${s.rfidLastUIDValid}`;
}
async function saveSettings() {
await api('/api/settings', 'POST', {
audioGain: parseFloat(document.getElementById('audioGain').value),
brightness: parseInt(document.getElementById('brightness').value, 10),
defaultAudiofile: document.getElementById('defaultAudiofile').value,
startupSounds: document.getElementById('startupSounds').checked
});
await refresh();
}
async function saveWifi() {
await api('/api/settings', 'POST', {
wifiMode: document.getElementById('wifiMode').value,
wifiApSSID: document.getElementById('wifiApSSID').value,
wifiApPSK: document.getElementById('wifiApPSK').value,
wifiStaSSID: document.getElementById('wifiStaSSID').value,
wifiStaPSK: document.getElementById('wifiStaPSK').value
});
await refresh();
}
async function applyWifi() {
await api('/api/wifi/apply', 'POST', {});
setTimeout(refresh, 1200);
}
async function deleteFile(path) {
await api('/api/files/delete', 'POST', { path });
await refresh();
}
async function uploadFile() {
const input = document.getElementById('fileUpload');
if (!input.files.length) return;
const fd = new FormData();
fd.append('file', input.files[0]);
await fetch('/api/files/upload', { method: 'POST', body: fd });
input.value = '';
await refresh();
}
async function assignTag() {
await api('/api/tag/assign', 'POST', {
uid: document.getElementById('uid').value.trim(),
audiofile: document.getElementById('audiofile').value.trim()
});
await refresh();
}
async function saveTagRow(originalUid, newUid, audiofile) {
if (!newUid) return;
if (originalUid && originalUid !== newUid) {
await api('/api/tag/delete', 'POST', { uid: originalUid });
}
await api('/api/tag/assign', 'POST', {
uid: newUid,
audiofile: audiofile
});
await refresh();
}
async function deleteTag(uid) {
await api('/api/tag/delete', 'POST', { uid });
await refresh();
}
async function refreshRfid() {
const r = await api('/api/rfid');
document.getElementById('uid').value = r.uid || '';
await refresh();
}
async function clearRfid() {
await api('/api/rfid/clear', 'POST', {});
await refresh();
}
refresh();
</script>
</body>
</html>
)HTML";
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <Arduino.h>
extern const char kPortalPage[];
+102 -10
View File
@@ -1,6 +1,13 @@
#include "power.h" #include "power.h"
bool powerbutton_released = true; 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 PowerLastKeepAlive = 0;
uint32_t PowerOtaLongPressTime = 0; uint32_t PowerOtaLongPressTime = 0;
@@ -9,6 +16,11 @@ uint32_t powerstate_timer = 0;
POWERSTATES powerstate = off; POWERSTATES powerstate = off;
POWERSTATES lastState = off; POWERSTATES lastState = off;
static bool startupSoundsEnabled()
{
return GetBoolparam("StartupSounds", true);
}
Button buttonPower(PWR_BTN, 250UL, 1U, 0); Button buttonPower(PWR_BTN, 250UL, 1U, 0);
extern OtaProcess_class ota; extern OtaProcess_class ota;
@@ -37,7 +49,6 @@ void PowerKeepAlive(void)
void powerOn(void) void powerOn(void)
{ {
digitalWrite(PWR_HOLD, HIGH); digitalWrite(PWR_HOLD, HIGH);
delay(200);
} }
void powerOff(void) void powerOff(void)
@@ -59,6 +70,10 @@ void handlePowerState(void)
{ {
if (buttonread) if (buttonread)
{ {
poweronPromptPlayed = false;
poweronPromptPending = false;
poweroffPromptPlayed = false;
poweroffPromptPending = false;
powerstate = poweringOn; powerstate = poweringOn;
} }
powerOff(); powerOff();
@@ -95,20 +110,59 @@ void handlePowerState(void)
case poweringOn2: case poweringOn2:
{ {
powerOn(); 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)) if (buttonPower.releasedFor(200))
{ {
powerstate = powerinit; if (poweronPromptPending)
if (CheckBattery())
{ {
log_w("poweringOn: Lowbat"); if (millis() - poweronWaitLogTs > 1000)
SetLedColor(CRGB::Red, true); {
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 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)) if (buttonPower.pressedFor(POWERBUTTONOTADELAY))
{ {
@@ -153,13 +207,16 @@ void handlePowerState(void)
if (buttonPower.pressedFor(POWERBUTTONDELAY)) if (buttonPower.pressedFor(POWERBUTTONDELAY))
{ {
powerstate = poweringOff2; powerstate = poweringOff2;
setAudioState(false); poweroffPromptPlayed = false;
poweroffPromptPending = false;
SetLedColor(CRGB::Red, true); SetLedColor(CRGB::Red, true);
log_w("poweringoff: 3/3 ==> powerOff"); log_w("poweringoff: 3/3 ==> powerOff");
} }
else else
{ {
poweroffPromptPlayed = false;
poweroffPromptPending = false;
powerstate = lastState; powerstate = lastState;
SetLedColor(CRGB::Green); SetLedColor(CRGB::Green);
} }
@@ -167,10 +224,45 @@ void handlePowerState(void)
break; break;
case poweringOff2: 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)) if (buttonPower.releasedFor(200))
{ {
powerstate = off; if (poweroffPromptPending)
SetLedColor(CRGB::Red, true); {
if (millis() - poweroffWaitLogTs > 1000)
{
log_i("poweringOff2: waiting for prompt to finish before off");
poweroffWaitLogTs = millis();
}
}
else
{
powerstate = off;
SetLedColor(CRGB::Red, true);
}
} }
} }
break; break;
+88 -7
View File
@@ -1,7 +1,7 @@
#include "rfid.h" #include "rfid.h"
PN532_SPI pn532spi(SPI, NFC_SS, NFC_SCK, NFC_MISO, NFC_MOSI); 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 lastRFID = 0;
uint32_t lastRFIDlog = 0; uint32_t lastRFIDlog = 0;
@@ -19,8 +19,33 @@ bool RfidScanActive = false;
void initRfid() void initRfid()
{ {
log_i("RFID init:"); // shows in serial that it is ready to read 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 ); scantimeout = GetIntparam("ScanTimeout", RFIDTIMEOUT );
log_i("RFID scan timeout = %lu ms", scantimeout);
RfidinitOK = true; RfidinitOK = true;
log_i("RFID init: OK"); // shows in serial that it is ready to read log_i("RFID init: OK"); // shows in serial that it is ready to read
} }
@@ -31,17 +56,72 @@ void handleRfid()
uint32_t timeNow = millis(); uint32_t timeNow = millis();
if (timeNow - lastRFID > RFIDINTERVAL && RfidScanActive) if (timeNow - lastRFID > RFIDINTERVAL && RfidScanActive)
{ {
if(timeNow - lastRFIDlog > RFIDLOGINTERVAL) bool doDiagLog = (timeNow - lastRFIDlog > RFIDLOGINTERVAL);
if (doDiagLog)
{ {
log_i("scanning"); log_i("scanning");
lastRFIDlog = timeNow; 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(); uint8_t uid50[7] = {0};
lastUid = tag.getUidString(); 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(); lastTagTime = millis();
log_d("RAW UID from NFC: '%s' (length: %d)", lastUid.c_str(), lastUid.length());
log_i("found tag %s",lastUid.c_str()); log_i("found tag %s",lastUid.c_str());
} }
lastRFID = timeNow; lastRFID = timeNow;
@@ -67,7 +147,8 @@ bool getRFIDlastUIDValid(void)
{ {
if(lastUid == "") if(lastUid == "")
{ {
return false; // If no tag is present, allow default playback selection from config.
return getConfigSong("") != "";
} }
return (getUIDvalid(lastUid)); return (getUIDvalid(lastUid));
} }
-1
View File
@@ -2,7 +2,6 @@
#include "storage.h" #include "storage.h"
#include <Arduino.h> #include <Arduino.h>
#include "FS.h"
#if defined ESP_ARDUINO_VERSION_VAL #if defined ESP_ARDUINO_VERSION_VAL
#if (ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(2, 0, 0)) #if (ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(2, 0, 0))