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:
@@ -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,
|
||||||
|
|||||||
Submodule FW/leo_muziekdoos_esp32/lib/ESP8266Audio updated: 818dfd5cb7...c755cd0d2e
Submodule FW/leo_muziekdoos_esp32/lib/PN532 updated: e88576ed93...e789a714fc
@@ -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()
|
||||||
@@ -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.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)
|
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)
|
||||||
{
|
{
|
||||||
|
log_e("Audio: engine not initialized");
|
||||||
|
setAudioStateLocked(false);
|
||||||
|
cleanupPlaybackObjectsLocked(false);
|
||||||
|
audioUnlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (mp3->isRunning())
|
if (mp3->isRunning())
|
||||||
{
|
{
|
||||||
if (!mp3->loop())
|
if (!mp3->loop())
|
||||||
{
|
{
|
||||||
mp3->stop();
|
if (audioRepeat)
|
||||||
|
{
|
||||||
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();
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
}
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
log_v("compare %s(config) with %s(read) = %d",taguid.c_str(), uid.c_str(), result);
|
static String resolvePlayableSongPath(String configuredPath)
|
||||||
if (!result)
|
{
|
||||||
|
if (configuredPath == "")
|
||||||
{
|
{
|
||||||
String filename((const char*)v["audiofile"]);
|
return "";
|
||||||
log_i("Tag found in config");
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String normalized = configuredPath;
|
||||||
|
if (!normalized.startsWith("/"))
|
||||||
|
{
|
||||||
|
normalized = "/" + normalized;
|
||||||
}
|
}
|
||||||
log_e("taguid %s not found",uid.c_str() );
|
|
||||||
return false;
|
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 "";
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 */
|
||||||
}
|
}
|
||||||
|
if (millis() - lastMainLog > 1000)
|
||||||
|
{
|
||||||
log_v("main: looptime = %d", millis() - looptime);
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
+777
-110
@@ -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;
|
|
||||||
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_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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
extern const char kPortalPage[];
|
||||||
@@ -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,7 +110,41 @@ 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))
|
||||||
|
{
|
||||||
|
if (poweronPromptPending)
|
||||||
|
{
|
||||||
|
if (millis() - poweronWaitLogTs > 1000)
|
||||||
|
{
|
||||||
|
log_i("poweringOn2: waiting for prompt to finish before powerinit");
|
||||||
|
poweronWaitLogTs = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
powerstate = powerinit;
|
powerstate = powerinit;
|
||||||
if (CheckBattery())
|
if (CheckBattery())
|
||||||
@@ -106,9 +155,14 @@ void handlePowerState(void)
|
|||||||
// powerstate = lowBatt;
|
// powerstate = lowBatt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
|
if (millis() - poweronHoldLogTs > 1000)
|
||||||
{
|
{
|
||||||
log_i("Release for poweron, hold for %d to OTA", (POWERBUTTONOTADELAY - buttonPower.getPressedFor()));
|
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,12 +224,47 @@ 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))
|
||||||
|
{
|
||||||
|
if (poweroffPromptPending)
|
||||||
|
{
|
||||||
|
if (millis() - poweroffWaitLogTs > 1000)
|
||||||
|
{
|
||||||
|
log_i("poweringOff2: waiting for prompt to finish before off");
|
||||||
|
poweroffWaitLogTs = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
powerstate = off;
|
powerstate = off;
|
||||||
SetLedColor(CRGB::Red, true);
|
SetLedColor(CRGB::Red, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case timeOut:
|
case timeOut:
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user