style: clang-format-18 all firmware sources (full sweep)
Previous commit only formatted 3 files; CI checks all 27 files in src/, include/, test/. Format the full set to clear firmware-lint. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
d7aad0665a
commit
defc5eba2d
@@ -1,22 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "voice_controller.h"
|
||||
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include "voice_controller.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure C++ utility functions — no Arduino dependencies.
|
||||
// Extracted here so they can be compiled and tested on native (Unity).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Human-readable idle summary for the LCD / UI.
|
||||
inline std::string FwIdleSummary(const MediaSnapshot& media) {
|
||||
auto mode_label = [](MediaMode m) -> const char* {
|
||||
inline std::string FwIdleSummary(const MediaSnapshot &media) {
|
||||
auto mode_label = [](MediaMode m) -> const char * {
|
||||
switch (m) {
|
||||
case MediaMode::kMp3: return "MP3";
|
||||
case MediaMode::kRadio: return "Radio";
|
||||
default: return "Idle";
|
||||
case MediaMode::kMp3:
|
||||
return "MP3";
|
||||
case MediaMode::kRadio:
|
||||
return "Radio";
|
||||
default:
|
||||
return "Idle";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,16 +36,15 @@ inline std::string FwIdleSummary(const MediaSnapshot& media) {
|
||||
}
|
||||
|
||||
/// True if a playback_started event should be emitted after applying an intent.
|
||||
inline bool FwShouldPublishPlaybackStarted(const MediaSnapshot& before,
|
||||
const MediaSnapshot& after,
|
||||
const VoiceIntent& intent) {
|
||||
if (!after.playing) return false;
|
||||
inline bool FwShouldPublishPlaybackStarted(const MediaSnapshot &before, const MediaSnapshot &after,
|
||||
const VoiceIntent &intent) {
|
||||
if (!after.playing)
|
||||
return false;
|
||||
|
||||
if (intent.type == "play" || intent.type == "switch_mode" ||
|
||||
intent.type == "select_station" || intent.type == "next" ||
|
||||
intent.type == "previous") {
|
||||
return !before.playing || before.station != after.station ||
|
||||
before.track != after.track || before.mode != after.mode;
|
||||
if (intent.type == "play" || intent.type == "switch_mode" || intent.type == "select_station" ||
|
||||
intent.type == "next" || intent.type == "previous") {
|
||||
return !before.playing || before.station != after.station || before.track != after.track ||
|
||||
before.mode != after.mode;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -50,37 +52,44 @@ inline bool FwShouldPublishPlaybackStarted(const MediaSnapshot& before,
|
||||
|
||||
/// Compare semver strings "MAJOR.MINOR.PATCH".
|
||||
/// Returns -1 / 0 / +1 (like strcmp).
|
||||
inline int FwCompareVersions(const std::string& a, const std::string& b) {
|
||||
auto parse = [](const std::string& v, int out[3]) {
|
||||
inline int FwCompareVersions(const std::string &a, const std::string &b) {
|
||||
auto parse = [](const std::string &v, int out[3]) {
|
||||
int i = 0;
|
||||
std::istringstream ss(v);
|
||||
std::string tok;
|
||||
while (std::getline(ss, tok, '.') && i < 3) {
|
||||
try { out[i++] = std::stoi(tok); } catch (...) { out[i++] = 0; }
|
||||
try {
|
||||
out[i++] = std::stoi(tok);
|
||||
} catch (...) {
|
||||
out[i++] = 0;
|
||||
}
|
||||
}
|
||||
while (i < 3) out[i++] = 0;
|
||||
while (i < 3)
|
||||
out[i++] = 0;
|
||||
};
|
||||
int va[3] = {}, vb[3] = {};
|
||||
parse(a, va);
|
||||
parse(b, vb);
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
if (va[i] < vb[i]) return -1;
|
||||
if (va[i] > vb[i]) return +1;
|
||||
if (va[i] < vb[i])
|
||||
return -1;
|
||||
if (va[i] > vb[i])
|
||||
return +1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Minimal WAV header validation — checks RIFF/WAVE magic.
|
||||
inline bool FwIsValidWavHeader(const uint8_t* data, size_t len) {
|
||||
if (!data || len < 12) return false;
|
||||
inline bool FwIsValidWavHeader(const uint8_t *data, size_t len) {
|
||||
if (!data || len < 12)
|
||||
return false;
|
||||
// Bytes 0-3: "RIFF", bytes 8-11: "WAVE"
|
||||
return data[0] == 'R' && data[1] == 'I' && data[2] == 'F' &&
|
||||
data[3] == 'F' && data[8] == 'W' && data[9] == 'A' &&
|
||||
data[10] == 'V' && data[11] == 'E';
|
||||
return data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && data[8] == 'W' &&
|
||||
data[9] == 'A' && data[10] == 'V' && data[11] == 'E';
|
||||
}
|
||||
|
||||
/// Validates that a backend URL starts with http:// or https://.
|
||||
inline bool FwIsValidBackendUrl(const std::string& url) {
|
||||
inline bool FwIsValidBackendUrl(const std::string &url) {
|
||||
return url.substr(0, 7) == "http://" || url.substr(0, 8) == "https://";
|
||||
}
|
||||
|
||||
@@ -91,50 +100,52 @@ inline bool FwIsValidBackendUrl(const std::string& url) {
|
||||
/// Network entry produced by WifiScanner::Scan().
|
||||
struct WifiNetwork {
|
||||
std::string ssid;
|
||||
int rssi = -100;
|
||||
bool open = false;
|
||||
int rssi = -100;
|
||||
bool open = false;
|
||||
int channel = 0;
|
||||
};
|
||||
|
||||
/// Map RSSI (dBm) to a 0–100 quality score.
|
||||
/// -50 dBm → 100, -100 dBm → 0, clamped.
|
||||
inline int FwRssiQuality(int rssi) {
|
||||
if (rssi >= -50) return 100;
|
||||
if (rssi <= -100) return 0;
|
||||
return (rssi + 100) * 2; // linear in [-100, -50]
|
||||
if (rssi >= -50)
|
||||
return 100;
|
||||
if (rssi <= -100)
|
||||
return 0;
|
||||
return (rssi + 100) * 2; // linear in [-100, -50]
|
||||
}
|
||||
|
||||
/// Serialize a list of networks to JSON.
|
||||
/// Output: {"networks":[{"ssid":"...","rssi":-62,"open":false,"channel":6,"quality":76}],"count":N,"duration_ms":T}
|
||||
inline std::string FwWifiToJson(const std::vector<WifiNetwork>& nets,
|
||||
uint32_t duration_ms) {
|
||||
/// Output:
|
||||
/// {"networks":[{"ssid":"...","rssi":-62,"open":false,"channel":6,"quality":76}],"count":N,"duration_ms":T}
|
||||
inline std::string FwWifiToJson(const std::vector<WifiNetwork> &nets, uint32_t duration_ms) {
|
||||
std::ostringstream j;
|
||||
j << "{\"networks\":[";
|
||||
for (size_t i = 0; i < nets.size(); ++i) {
|
||||
const auto& n = nets[i];
|
||||
if (i > 0) j << ",";
|
||||
const auto &n = nets[i];
|
||||
if (i > 0)
|
||||
j << ",";
|
||||
// Escape backslash and double-quote in SSID (minimal safe escaping).
|
||||
std::string ssid_esc;
|
||||
ssid_esc.reserve(n.ssid.size());
|
||||
for (char c : n.ssid) {
|
||||
if (c == '"') { ssid_esc += "\\\""; }
|
||||
else if (c == '\\') { ssid_esc += "\\\\"; }
|
||||
else { ssid_esc += c; }
|
||||
if (c == '"') {
|
||||
ssid_esc += "\\\"";
|
||||
} else if (c == '\\') {
|
||||
ssid_esc += "\\\\";
|
||||
} else {
|
||||
ssid_esc += c;
|
||||
}
|
||||
}
|
||||
j << "{\"ssid\":\"" << ssid_esc << "\""
|
||||
<< ",\"rssi\":" << n.rssi
|
||||
<< ",\"open\":" << (n.open ? "true" : "false")
|
||||
<< ",\"channel\":" << n.channel
|
||||
<< ",\"quality\":" << FwRssiQuality(n.rssi)
|
||||
<< "}";
|
||||
j << "{\"ssid\":\"" << ssid_esc << "\"" << ",\"rssi\":" << n.rssi
|
||||
<< ",\"open\":" << (n.open ? "true" : "false") << ",\"channel\":" << n.channel
|
||||
<< ",\"quality\":" << FwRssiQuality(n.rssi) << "}";
|
||||
}
|
||||
j << "],\"count\":" << nets.size()
|
||||
<< ",\"duration_ms\":" << duration_ms
|
||||
<< "}";
|
||||
j << "],\"count\":" << nets.size() << ",\"duration_ms\":" << duration_ms << "}";
|
||||
return j.str();
|
||||
}
|
||||
|
||||
/// Comparator: sort networks by RSSI descending (best signal first).
|
||||
inline bool FwNetworkBetterSignal(const WifiNetwork& a, const WifiNetwork& b) {
|
||||
inline bool FwNetworkBetterSignal(const WifiNetwork &a, const WifiNetwork &b) {
|
||||
return a.rssi > b.rssi;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "voice_controller.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "voice_controller.h"
|
||||
|
||||
/// HTTP client that talks to the Mascarade /device/v1/* endpoints.
|
||||
class HttpBackend : public BackendClient {
|
||||
public:
|
||||
public:
|
||||
/// @param base_url e.g. "http://192.168.1.42:8000"
|
||||
explicit HttpBackend(const std::string& base_url);
|
||||
explicit HttpBackend(const std::string &base_url);
|
||||
|
||||
bool SendPlayerEvent(const std::string& device_id,
|
||||
const std::string& event_name,
|
||||
const MediaSnapshot& media,
|
||||
const std::string& detail) override;
|
||||
bool SendPlayerEvent(const std::string &device_id, const std::string &event_name,
|
||||
const MediaSnapshot &media, const std::string &detail) override;
|
||||
|
||||
VoiceSessionResponse SubmitVoiceSession(
|
||||
const std::string& device_id,
|
||||
const MediaSnapshot& media,
|
||||
const std::vector<uint8_t>& wav_data) override;
|
||||
VoiceSessionResponse SubmitVoiceSession(const std::string &device_id, const MediaSnapshot &media,
|
||||
const std::vector<uint8_t> &wav_data) override;
|
||||
|
||||
bool DownloadReplyAudio(const std::string& audio_url,
|
||||
std::vector<uint8_t>* wav_data) override;
|
||||
bool DownloadReplyAudio(const std::string &audio_url, std::vector<uint8_t> *wav_data) override;
|
||||
|
||||
private:
|
||||
private:
|
||||
std::string base_url_;
|
||||
};
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include <driver/i2s_std.h>
|
||||
#include <vector>
|
||||
|
||||
/// I2S microphone capture only (ICS-43434 on the Waveshare board).
|
||||
/// Uses the new I2S channel driver (ESP-IDF 5.x) to avoid conflicts
|
||||
/// with the ESP32-audioI2S library (RadioPlayer) which also uses it.
|
||||
class I2sMic {
|
||||
public:
|
||||
public:
|
||||
/// Initialise I2S1 for mic capture.
|
||||
/// Default pins for Waveshare ESP32-S3-LCD-1.85: SCK=15, WS=2, SD=39
|
||||
bool Begin(int sck = 15, int ws = 2, int sd = 39, int sample_rate = 16000);
|
||||
|
||||
/// Record duration_ms of audio → complete WAV buffer (44-byte header + PCM).
|
||||
bool Capture(std::vector<uint8_t>& wav_out, uint32_t duration_ms);
|
||||
bool Capture(std::vector<uint8_t> &wav_out, uint32_t duration_ms);
|
||||
|
||||
void End();
|
||||
|
||||
int sample_rate() const { return sample_rate_; }
|
||||
|
||||
private:
|
||||
static void WriteWavHeader(uint8_t* dest, uint32_t pcm_bytes, int sr);
|
||||
private:
|
||||
static void WriteWavHeader(uint8_t *dest, uint32_t pcm_bytes, int sr);
|
||||
int sample_rate_ = 16000;
|
||||
bool installed_ = false;
|
||||
i2s_chan_handle_t rx_handle_ = nullptr;
|
||||
|
||||
@@ -6,22 +6,18 @@
|
||||
/// LCD UI renderer for the Waveshare 1.85" round display (ST77916 QSPI).
|
||||
/// Uses ESP32_Display_Panel for hardware abstraction.
|
||||
class LcdUi : public UiRenderer {
|
||||
public:
|
||||
public:
|
||||
/// Initialise the display panel and backlight.
|
||||
bool Begin();
|
||||
|
||||
/// UiRenderer implementation — draw current state on the round LCD.
|
||||
void Render(const MediaSnapshot& media,
|
||||
VoicePhase phase,
|
||||
const std::string& headline,
|
||||
const std::string& summary,
|
||||
bool show_ring) override;
|
||||
void Render(const MediaSnapshot &media, VoicePhase phase, const std::string &headline,
|
||||
const std::string &summary, bool show_ring) override;
|
||||
|
||||
/// Show WiFi status screen (connecting, AP mode, etc.).
|
||||
void RenderWifi(WifiManager::State state, const std::string& info,
|
||||
const std::string& ap_ssid = "",
|
||||
const std::string& ap_pass = "",
|
||||
const std::string& ap_ip = "");
|
||||
void RenderWifi(WifiManager::State state, const std::string &info,
|
||||
const std::string &ap_ssid = "", const std::string &ap_pass = "",
|
||||
const std::string &ap_ip = "");
|
||||
|
||||
/// Set backlight brightness (0–100).
|
||||
void SetBrightness(uint8_t level);
|
||||
@@ -29,10 +25,10 @@ class LcdUi : public UiRenderer {
|
||||
/// Push current framebuffer to LCD.
|
||||
void Flush();
|
||||
|
||||
private:
|
||||
private:
|
||||
void DrawBackground();
|
||||
void DrawCenteredText(int y, const char* text, uint16_t color, int size);
|
||||
void DrawStatusBar(const MediaSnapshot& media);
|
||||
void DrawCenteredText(int y, const char *text, uint16_t color, int size);
|
||||
void DrawStatusBar(const MediaSnapshot &media);
|
||||
void DrawVoiceRing(VoicePhase phase);
|
||||
|
||||
bool initialized_ = false;
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
/// Result of an OTA check.
|
||||
struct OtaCheckResult {
|
||||
enum class Status {
|
||||
kUpToDate, ///< Already on latest version.
|
||||
kUpToDate, ///< Already on latest version.
|
||||
kUpdateAvailable, ///< Newer firmware found.
|
||||
kCheckFailed, ///< Could not reach backend.
|
||||
kFlashFailed, ///< Download or flash error.
|
||||
kFlashOk, ///< Flash successful — pending reboot.
|
||||
kCheckFailed, ///< Could not reach backend.
|
||||
kFlashFailed, ///< Download or flash error.
|
||||
kFlashOk, ///< Flash successful — pending reboot.
|
||||
};
|
||||
|
||||
Status status = Status::kCheckFailed;
|
||||
@@ -33,11 +33,11 @@ struct OtaCheckResult {
|
||||
/// Response: {"latest": "1.0.1", "url": "http://...", "notes": "..."}
|
||||
/// or {"latest": "1.0.0"} when up-to-date
|
||||
class OtaManager {
|
||||
public:
|
||||
explicit OtaManager(const std::string& current_version);
|
||||
public:
|
||||
explicit OtaManager(const std::string ¤t_version);
|
||||
|
||||
void SetBackendUrl(const std::string& url) { backend_url_ = url; }
|
||||
void SetDeviceId(const std::string& id) { device_id_ = id; }
|
||||
void SetBackendUrl(const std::string &url) { backend_url_ = url; }
|
||||
void SetDeviceId(const std::string &id) { device_id_ = id; }
|
||||
|
||||
/// Check the backend for a newer version and flash if found.
|
||||
/// Blocks during download (typically 30–120s on good WiFi).
|
||||
@@ -46,11 +46,11 @@ class OtaManager {
|
||||
/// Convenience: just check version without flashing.
|
||||
OtaCheckResult CheckOnly();
|
||||
|
||||
const std::string& current_version() const { return current_version_; }
|
||||
const std::string ¤t_version() const { return current_version_; }
|
||||
|
||||
private:
|
||||
private:
|
||||
OtaCheckResult FetchLatestInfo();
|
||||
bool FlashFromUrl(const std::string& url);
|
||||
bool FlashFromUrl(const std::string &url);
|
||||
|
||||
std::string current_version_;
|
||||
std::string backend_url_ = "http://192.168.1.42:8000";
|
||||
|
||||
@@ -57,73 +57,61 @@ struct VoiceSessionResponse {
|
||||
};
|
||||
|
||||
class BackendClient {
|
||||
public:
|
||||
public:
|
||||
virtual ~BackendClient() = default;
|
||||
|
||||
virtual bool SendPlayerEvent(const std::string& device_id,
|
||||
const std::string& event_name,
|
||||
const MediaSnapshot& media,
|
||||
const std::string& detail) = 0;
|
||||
virtual bool SendPlayerEvent(const std::string &device_id, const std::string &event_name,
|
||||
const MediaSnapshot &media, const std::string &detail) = 0;
|
||||
|
||||
virtual VoiceSessionResponse SubmitVoiceSession(
|
||||
const std::string& device_id,
|
||||
const MediaSnapshot& media,
|
||||
const std::vector<uint8_t>& wav_data) = 0;
|
||||
virtual VoiceSessionResponse SubmitVoiceSession(const std::string &device_id,
|
||||
const MediaSnapshot &media,
|
||||
const std::vector<uint8_t> &wav_data) = 0;
|
||||
|
||||
virtual bool DownloadReplyAudio(const std::string& audio_url,
|
||||
std::vector<uint8_t>* wav_data) = 0;
|
||||
virtual bool DownloadReplyAudio(const std::string &audio_url, std::vector<uint8_t> *wav_data) = 0;
|
||||
};
|
||||
|
||||
class MediaController {
|
||||
public:
|
||||
public:
|
||||
virtual ~MediaController() = default;
|
||||
|
||||
virtual MediaSnapshot Snapshot() const = 0;
|
||||
virtual void ApplyIntent(const VoiceIntent& intent) = 0;
|
||||
virtual void ApplyIntent(const VoiceIntent &intent) = 0;
|
||||
virtual void PrepareForReply(PlayerAction action) = 0;
|
||||
virtual void RestoreAfterReply(bool resume_media_after_tts) = 0;
|
||||
virtual bool PlayReplyAudio(const std::vector<uint8_t>& wav_data) = 0;
|
||||
virtual bool PlayReplyAudio(const std::vector<uint8_t> &wav_data) = 0;
|
||||
};
|
||||
|
||||
class UiRenderer {
|
||||
public:
|
||||
public:
|
||||
virtual ~UiRenderer() = default;
|
||||
|
||||
virtual void Render(const MediaSnapshot& media,
|
||||
VoicePhase phase,
|
||||
const std::string& headline,
|
||||
const std::string& summary,
|
||||
bool show_ring) = 0;
|
||||
virtual void Render(const MediaSnapshot &media, VoicePhase phase, const std::string &headline,
|
||||
const std::string &summary, bool show_ring) = 0;
|
||||
};
|
||||
|
||||
class VoiceController {
|
||||
public:
|
||||
VoiceController(std::string device_id,
|
||||
BackendClient& backend,
|
||||
MediaController& media,
|
||||
UiRenderer& ui);
|
||||
public:
|
||||
VoiceController(std::string device_id, BackendClient &backend, MediaController &media,
|
||||
UiRenderer &ui);
|
||||
|
||||
void Boot();
|
||||
bool BeginPushToTalk();
|
||||
bool CompletePushToTalk(const std::vector<uint8_t>& wav_data);
|
||||
bool CompletePushToTalk(const std::vector<uint8_t> &wav_data);
|
||||
|
||||
VoicePhase phase() const { return phase_; }
|
||||
const VoiceSessionResponse& last_response() const { return last_response_; }
|
||||
const VoiceSessionResponse &last_response() const { return last_response_; }
|
||||
|
||||
private:
|
||||
bool Fail(const std::string& error_text);
|
||||
void RenderState(const std::string& headline,
|
||||
const std::string& summary,
|
||||
bool show_ring);
|
||||
static std::string IdleSummary(const MediaSnapshot& media);
|
||||
static bool ShouldPublishPlaybackStarted(const MediaSnapshot& before,
|
||||
const MediaSnapshot& after,
|
||||
const VoiceIntent& intent);
|
||||
private:
|
||||
bool Fail(const std::string &error_text);
|
||||
void RenderState(const std::string &headline, const std::string &summary, bool show_ring);
|
||||
static std::string IdleSummary(const MediaSnapshot &media);
|
||||
static bool ShouldPublishPlaybackStarted(const MediaSnapshot &before, const MediaSnapshot &after,
|
||||
const VoiceIntent &intent);
|
||||
|
||||
std::string device_id_;
|
||||
BackendClient& backend_;
|
||||
MediaController& media_;
|
||||
UiRenderer& ui_;
|
||||
BackendClient &backend_;
|
||||
MediaController &media_;
|
||||
UiRenderer &ui_;
|
||||
VoicePhase phase_ = VoicePhase::kIdle;
|
||||
VoiceSessionResponse last_response_;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
|
||||
/// WiFi connection manager with AP fallback and captive portal.
|
||||
///
|
||||
@@ -14,7 +14,7 @@
|
||||
///
|
||||
/// NVS keys: "wifi_ssid", "wifi_pass", "backend_url"
|
||||
class WifiManager {
|
||||
public:
|
||||
public:
|
||||
enum class State {
|
||||
kIdle,
|
||||
kConnecting,
|
||||
@@ -26,13 +26,13 @@ class WifiManager {
|
||||
struct ScanResult {
|
||||
std::string ssid;
|
||||
int rssi;
|
||||
bool open; // no encryption
|
||||
bool open; // no encryption
|
||||
};
|
||||
|
||||
using OnStateChange = std::function<void(State state, const std::string& info)>;
|
||||
using OnStateChange = std::function<void(State state, const std::string &info)>;
|
||||
|
||||
/// AP mode settings.
|
||||
void SetApCredentials(const std::string& ssid, const std::string& password);
|
||||
void SetApCredentials(const std::string &ssid, const std::string &password);
|
||||
|
||||
/// Register callback for state changes (for LCD updates).
|
||||
void SetOnStateChange(OnStateChange cb) { on_state_change_ = cb; }
|
||||
@@ -68,15 +68,14 @@ class WifiManager {
|
||||
/// Scan available networks (blocking, ~2s).
|
||||
std::vector<ScanResult> Scan();
|
||||
|
||||
private:
|
||||
private:
|
||||
void LoadCredentials();
|
||||
void SaveCredentials(const std::string& ssid, const std::string& pass,
|
||||
const std::string& backend);
|
||||
bool TryConnect(const std::string& ssid, const std::string& pass,
|
||||
uint32_t timeout_ms = 12000);
|
||||
void SaveCredentials(const std::string &ssid, const std::string &pass,
|
||||
const std::string &backend);
|
||||
bool TryConnect(const std::string &ssid, const std::string &pass, uint32_t timeout_ms = 12000);
|
||||
void SetupApWebServer();
|
||||
void StopApWebServer();
|
||||
void SetState(State s, const std::string& info = "");
|
||||
void SetState(State s, const std::string &info = "");
|
||||
|
||||
State state_ = State::kIdle;
|
||||
OnStateChange on_state_change_;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "firmware_utils.h" // WifiNetwork, FwRssiQuality, FwWifiToJson
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "firmware_utils.h" // WifiNetwork, FwRssiQuality, FwWifiToJson
|
||||
|
||||
/// Lightweight WiFi scanner.
|
||||
///
|
||||
/// Usage:
|
||||
@@ -16,18 +16,18 @@
|
||||
/// Pure utility functions (RssiQuality, ToJson, SortByRssi) live in
|
||||
/// firmware_utils.h and are tested natively via Unity.
|
||||
class WifiScanner {
|
||||
public:
|
||||
public:
|
||||
/// Scan available networks, block up to timeout_ms.
|
||||
/// Returns networks sorted by RSSI descending.
|
||||
std::vector<WifiNetwork> Scan(uint32_t timeout_ms = 4000);
|
||||
|
||||
/// JSON string from the last Scan() call.
|
||||
const std::string& last_json() const { return last_json_; }
|
||||
const std::string &last_json() const { return last_json_; }
|
||||
|
||||
/// Duration of the last Scan() call in ms.
|
||||
uint32_t last_duration_ms() const { return last_duration_ms_; }
|
||||
|
||||
private:
|
||||
private:
|
||||
std::string last_json_;
|
||||
uint32_t last_duration_ms_ = 0;
|
||||
};
|
||||
|
||||
+128
-128
@@ -6,132 +6,132 @@
|
||||
|
||||
extern const uint8_t font8x8_basic[128][8];
|
||||
const uint8_t font8x8_basic[128][8] = {
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x00
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x01
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x02
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x03
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x04
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x05
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x06
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x07
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x08
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x09
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x0A
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x0B
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x0C
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x0D
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x0E
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x0F
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x10
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x11
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x12
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x13
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x14
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x15
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x16
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x17
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x18
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x19
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x1A
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x1B
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x1C
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x1D
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x1E
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x1F
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // ' '
|
||||
{ 0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00 }, // '!'
|
||||
{ 0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // '"'
|
||||
{ 0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00 }, // '#'
|
||||
{ 0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00 }, // '$'
|
||||
{ 0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00 }, // '%'
|
||||
{ 0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00 }, // '&'
|
||||
{ 0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00 }, // '\''
|
||||
{ 0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00 }, // '('
|
||||
{ 0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00 }, // ')'
|
||||
{ 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00 }, // '*'
|
||||
{ 0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00 }, // '+'
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06 }, // ','
|
||||
{ 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00 }, // '-'
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00 }, // '.'
|
||||
{ 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00 }, // '/'
|
||||
{ 0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00 }, // '0'
|
||||
{ 0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00 }, // '1'
|
||||
{ 0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00 }, // '2'
|
||||
{ 0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00 }, // '3'
|
||||
{ 0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00 }, // '4'
|
||||
{ 0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00 }, // '5'
|
||||
{ 0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00 }, // '6'
|
||||
{ 0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00 }, // '7'
|
||||
{ 0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00 }, // '8'
|
||||
{ 0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00 }, // '9'
|
||||
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00 }, // ':'
|
||||
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06 }, // ';'
|
||||
{ 0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00 }, // '<'
|
||||
{ 0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00 }, // '='
|
||||
{ 0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00 }, // '>'
|
||||
{ 0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00 }, // '?'
|
||||
{ 0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00 }, // '@'
|
||||
{ 0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00 }, // 'A'
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00 }, // 'B'
|
||||
{ 0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00 }, // 'C'
|
||||
{ 0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00 }, // 'D'
|
||||
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00 }, // 'E'
|
||||
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00 }, // 'F'
|
||||
{ 0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00 }, // 'G'
|
||||
{ 0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00 }, // 'H'
|
||||
{ 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00 }, // 'I'
|
||||
{ 0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00 }, // 'J'
|
||||
{ 0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00 }, // 'K'
|
||||
{ 0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00 }, // 'L'
|
||||
{ 0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00 }, // 'M'
|
||||
{ 0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00 }, // 'N'
|
||||
{ 0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00 }, // 'O'
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00 }, // 'P'
|
||||
{ 0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00 }, // 'Q'
|
||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00 }, // 'R'
|
||||
{ 0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00 }, // 'S'
|
||||
{ 0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00 }, // 'T'
|
||||
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00 }, // 'U'
|
||||
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00 }, // 'V'
|
||||
{ 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00 }, // 'W'
|
||||
{ 0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00 }, // 'X'
|
||||
{ 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00 }, // 'Y'
|
||||
{ 0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00 }, // 'Z'
|
||||
{ 0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00 }, // '['
|
||||
{ 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00 }, // '\\'
|
||||
{ 0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00 }, // ']'
|
||||
{ 0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00 }, // '^'
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF }, // '_'
|
||||
{ 0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00 }, // '`'
|
||||
{ 0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00 }, // 'a'
|
||||
{ 0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00 }, // 'b'
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00 }, // 'c'
|
||||
{ 0x38, 0x30, 0x30, 0x3E, 0x33, 0x33, 0x6E, 0x00 }, // 'd'
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x3F, 0x03, 0x1E, 0x00 }, // 'e'
|
||||
{ 0x1C, 0x36, 0x06, 0x0F, 0x06, 0x06, 0x0F, 0x00 }, // 'f'
|
||||
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F }, // 'g'
|
||||
{ 0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00 }, // 'h'
|
||||
{ 0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00 }, // 'i'
|
||||
{ 0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E }, // 'j'
|
||||
{ 0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00 }, // 'k'
|
||||
{ 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00 }, // 'l'
|
||||
{ 0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00 }, // 'm'
|
||||
{ 0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00 }, // 'n'
|
||||
{ 0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00 }, // 'o'
|
||||
{ 0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F }, // 'p'
|
||||
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78 }, // 'q'
|
||||
{ 0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00 }, // 'r'
|
||||
{ 0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00 }, // 's'
|
||||
{ 0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00 }, // 't'
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00 }, // 'u'
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00 }, // 'v'
|
||||
{ 0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00 }, // 'w'
|
||||
{ 0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00 }, // 'x'
|
||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F }, // 'y'
|
||||
{ 0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00 }, // 'z'
|
||||
{ 0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00 }, // '{'
|
||||
{ 0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00 }, // '|'
|
||||
{ 0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00 }, // '}'
|
||||
{ 0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // '~'
|
||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }, // 0x7F
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x00
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x01
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x02
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x03
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x04
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x05
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x06
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x07
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x08
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x09
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x0A
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x0B
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x0C
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x0D
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x0E
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x0F
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x10
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x11
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x12
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x13
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x14
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x15
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x16
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x17
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x18
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x19
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x1A
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x1B
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x1C
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x1D
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x1E
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x1F
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // ' '
|
||||
{0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00}, // '!'
|
||||
{0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // '"'
|
||||
{0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00}, // '#'
|
||||
{0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00}, // '$'
|
||||
{0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00}, // '%'
|
||||
{0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00}, // '&'
|
||||
{0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, // '\''
|
||||
{0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00}, // '('
|
||||
{0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00}, // ')'
|
||||
{0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00}, // '*'
|
||||
{0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00}, // '+'
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // ','
|
||||
{0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00}, // '-'
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // '.'
|
||||
{0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00}, // '/'
|
||||
{0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00}, // '0'
|
||||
{0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00}, // '1'
|
||||
{0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00}, // '2'
|
||||
{0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00}, // '3'
|
||||
{0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00}, // '4'
|
||||
{0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00}, // '5'
|
||||
{0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00}, // '6'
|
||||
{0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00}, // '7'
|
||||
{0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00}, // '8'
|
||||
{0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00}, // '9'
|
||||
{0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // ':'
|
||||
{0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // ';'
|
||||
{0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00}, // '<'
|
||||
{0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00}, // '='
|
||||
{0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00}, // '>'
|
||||
{0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00}, // '?'
|
||||
{0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00}, // '@'
|
||||
{0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00}, // 'A'
|
||||
{0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00}, // 'B'
|
||||
{0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00}, // 'C'
|
||||
{0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00}, // 'D'
|
||||
{0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00}, // 'E'
|
||||
{0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00}, // 'F'
|
||||
{0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00}, // 'G'
|
||||
{0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00}, // 'H'
|
||||
{0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // 'I'
|
||||
{0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00}, // 'J'
|
||||
{0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00}, // 'K'
|
||||
{0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00}, // 'L'
|
||||
{0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00}, // 'M'
|
||||
{0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00}, // 'N'
|
||||
{0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00}, // 'O'
|
||||
{0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00}, // 'P'
|
||||
{0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00}, // 'Q'
|
||||
{0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00}, // 'R'
|
||||
{0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00}, // 'S'
|
||||
{0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // 'T'
|
||||
{0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00}, // 'U'
|
||||
{0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // 'V'
|
||||
{0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00}, // 'W'
|
||||
{0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00}, // 'X'
|
||||
{0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00}, // 'Y'
|
||||
{0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00}, // 'Z'
|
||||
{0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00}, // '['
|
||||
{0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00}, // '\\'
|
||||
{0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00}, // ']'
|
||||
{0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00}, // '^'
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, // '_'
|
||||
{0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00}, // '`'
|
||||
{0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00}, // 'a'
|
||||
{0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00}, // 'b'
|
||||
{0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00}, // 'c'
|
||||
{0x38, 0x30, 0x30, 0x3E, 0x33, 0x33, 0x6E, 0x00}, // 'd'
|
||||
{0x00, 0x00, 0x1E, 0x33, 0x3F, 0x03, 0x1E, 0x00}, // 'e'
|
||||
{0x1C, 0x36, 0x06, 0x0F, 0x06, 0x06, 0x0F, 0x00}, // 'f'
|
||||
{0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // 'g'
|
||||
{0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00}, // 'h'
|
||||
{0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // 'i'
|
||||
{0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E}, // 'j'
|
||||
{0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00}, // 'k'
|
||||
{0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // 'l'
|
||||
{0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00}, // 'm'
|
||||
{0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00}, // 'n'
|
||||
{0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00}, // 'o'
|
||||
{0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F}, // 'p'
|
||||
{0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78}, // 'q'
|
||||
{0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00}, // 'r'
|
||||
{0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00}, // 's'
|
||||
{0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00}, // 't'
|
||||
{0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00}, // 'u'
|
||||
{0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // 'v'
|
||||
{0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00}, // 'w'
|
||||
{0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00}, // 'x'
|
||||
{0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // 'y'
|
||||
{0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00}, // 'z'
|
||||
{0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00}, // '{'
|
||||
{0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00}, // '|'
|
||||
{0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00}, // '}'
|
||||
{0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // '~'
|
||||
{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 0x7F
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
#include "http_backend.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
// ArduinoJson for parsing responses
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
HttpBackend::HttpBackend(const std::string& base_url) : base_url_(base_url) {
|
||||
HttpBackend::HttpBackend(const std::string &base_url) : base_url_(base_url) {
|
||||
// Strip trailing slash.
|
||||
while (!base_url_.empty() && base_url_.back() == '/') {
|
||||
base_url_.pop_back();
|
||||
@@ -17,10 +18,8 @@ HttpBackend::HttpBackend(const std::string& base_url) : base_url_(base_url) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /device/v1/player/event (JSON body)
|
||||
// ---------------------------------------------------------------------------
|
||||
bool HttpBackend::SendPlayerEvent(const std::string& device_id,
|
||||
const std::string& event_name,
|
||||
const MediaSnapshot& media,
|
||||
const std::string& detail) {
|
||||
bool HttpBackend::SendPlayerEvent(const std::string &device_id, const std::string &event_name,
|
||||
const MediaSnapshot &media, const std::string &detail) {
|
||||
HTTPClient http;
|
||||
const std::string url = base_url_ + "/device/v1/player/event";
|
||||
http.begin(url.c_str());
|
||||
@@ -29,22 +28,33 @@ bool HttpBackend::SendPlayerEvent(const std::string& device_id,
|
||||
|
||||
JsonDocument doc;
|
||||
doc["device_id"] = device_id;
|
||||
doc["event"] = event_name;
|
||||
doc["detail"] = detail;
|
||||
doc["event"] = event_name;
|
||||
doc["detail"] = detail;
|
||||
|
||||
// Media state fields
|
||||
switch (media.mode) {
|
||||
case MediaMode::kMp3: doc["mode"] = "mp3"; break;
|
||||
case MediaMode::kRadio: doc["mode"] = "radio"; break;
|
||||
default: doc["mode"] = "idle"; break;
|
||||
case MediaMode::kMp3:
|
||||
doc["mode"] = "mp3";
|
||||
break;
|
||||
case MediaMode::kRadio:
|
||||
doc["mode"] = "radio";
|
||||
break;
|
||||
default:
|
||||
doc["mode"] = "idle";
|
||||
break;
|
||||
}
|
||||
doc["playing"] = media.playing;
|
||||
doc["volume"] = media.volume;
|
||||
if (!media.station.empty()) doc["station"] = media.station;
|
||||
if (!media.track.empty()) doc["track"] = media.track;
|
||||
if (media.battery_pct >= 0) doc["battery_pct"] = media.battery_pct;
|
||||
if (!media.wifi_ssid.empty()) doc["wifi_ssid"] = media.wifi_ssid;
|
||||
if (media.wifi_rssi != 0) doc["wifi_rssi"] = media.wifi_rssi;
|
||||
doc["playing"] = media.playing;
|
||||
doc["volume"] = media.volume;
|
||||
if (!media.station.empty())
|
||||
doc["station"] = media.station;
|
||||
if (!media.track.empty())
|
||||
doc["track"] = media.track;
|
||||
if (media.battery_pct >= 0)
|
||||
doc["battery_pct"] = media.battery_pct;
|
||||
if (!media.wifi_ssid.empty())
|
||||
doc["wifi_ssid"] = media.wifi_ssid;
|
||||
if (media.wifi_rssi != 0)
|
||||
doc["wifi_rssi"] = media.wifi_rssi;
|
||||
|
||||
String body;
|
||||
serializeJson(doc, body);
|
||||
@@ -64,30 +74,40 @@ bool HttpBackend::SendPlayerEvent(const std::string& device_id,
|
||||
// POST /device/v1/voice/session (multipart/form-data)
|
||||
// Fields: device_id, mode, current_media (JSON), audio (WAV file)
|
||||
// ---------------------------------------------------------------------------
|
||||
VoiceSessionResponse HttpBackend::SubmitVoiceSession(
|
||||
const std::string& device_id,
|
||||
const MediaSnapshot& media,
|
||||
const std::vector<uint8_t>& wav_data) {
|
||||
VoiceSessionResponse HttpBackend::SubmitVoiceSession(const std::string &device_id,
|
||||
const MediaSnapshot &media,
|
||||
const std::vector<uint8_t> &wav_data) {
|
||||
|
||||
VoiceSessionResponse result;
|
||||
|
||||
// Build current_media JSON string.
|
||||
JsonDocument media_doc;
|
||||
switch (media.mode) {
|
||||
case MediaMode::kMp3: media_doc["mode"] = "mp3"; break;
|
||||
case MediaMode::kRadio: media_doc["mode"] = "radio"; break;
|
||||
default: media_doc["mode"] = "idle"; break;
|
||||
case MediaMode::kMp3:
|
||||
media_doc["mode"] = "mp3";
|
||||
break;
|
||||
case MediaMode::kRadio:
|
||||
media_doc["mode"] = "radio";
|
||||
break;
|
||||
default:
|
||||
media_doc["mode"] = "idle";
|
||||
break;
|
||||
}
|
||||
media_doc["playing"] = media.playing;
|
||||
media_doc["volume"] = media.volume;
|
||||
if (!media.station.empty()) media_doc["station"] = media.station;
|
||||
if (!media.track.empty()) media_doc["track"] = media.track;
|
||||
if (media.battery_pct >= 0) media_doc["battery_pct"] = media.battery_pct;
|
||||
if (!media.wifi_ssid.empty()) media_doc["wifi_ssid"] = media.wifi_ssid;
|
||||
if (media.wifi_rssi != 0) media_doc["wifi_rssi"] = media.wifi_rssi;
|
||||
media_doc["volume"] = media.volume;
|
||||
if (!media.station.empty())
|
||||
media_doc["station"] = media.station;
|
||||
if (!media.track.empty())
|
||||
media_doc["track"] = media.track;
|
||||
if (media.battery_pct >= 0)
|
||||
media_doc["battery_pct"] = media.battery_pct;
|
||||
if (!media.wifi_ssid.empty())
|
||||
media_doc["wifi_ssid"] = media.wifi_ssid;
|
||||
if (media.wifi_rssi != 0)
|
||||
media_doc["wifi_rssi"] = media.wifi_rssi;
|
||||
|
||||
JsonArray stations = media_doc["available_stations"].to<JsonArray>();
|
||||
for (const auto& s : media.available_stations) {
|
||||
for (const auto &s : media.available_stations) {
|
||||
stations.add(s);
|
||||
}
|
||||
|
||||
@@ -95,9 +115,11 @@ VoiceSessionResponse HttpBackend::SubmitVoiceSession(
|
||||
serializeJson(media_doc, media_json);
|
||||
|
||||
// Mode string.
|
||||
const char* mode_str = "idle";
|
||||
if (media.mode == MediaMode::kMp3) mode_str = "mp3";
|
||||
if (media.mode == MediaMode::kRadio) mode_str = "radio";
|
||||
const char *mode_str = "idle";
|
||||
if (media.mode == MediaMode::kMp3)
|
||||
mode_str = "mp3";
|
||||
if (media.mode == MediaMode::kRadio)
|
||||
mode_str = "radio";
|
||||
|
||||
// Build multipart body manually (ESP32 HTTPClient has no multipart helper).
|
||||
const String boundary = "----KillLife" + String(millis());
|
||||
@@ -129,7 +151,7 @@ VoiceSessionResponse HttpBackend::SubmitVoiceSession(
|
||||
http.begin(tcp, url.c_str());
|
||||
http.addHeader("Content-Type", "multipart/form-data; boundary=" + boundary);
|
||||
http.addHeader("Content-Length", String((unsigned long)total_len));
|
||||
http.setTimeout(30000); // STT + LLM + TTS can take a while.
|
||||
http.setTimeout(30000); // STT + LLM + TTS can take a while.
|
||||
|
||||
// sendRequest allows streaming the body in parts.
|
||||
// We must use the low-level approach.
|
||||
@@ -142,11 +164,9 @@ VoiceSessionResponse HttpBackend::SubmitVoiceSession(
|
||||
// For ESP32 with PSRAM this is fine up to ~256 KB.
|
||||
std::vector<uint8_t> body;
|
||||
body.reserve(total_len);
|
||||
body.insert(body.end(), (uint8_t*)head.c_str(),
|
||||
(uint8_t*)head.c_str() + head.length());
|
||||
body.insert(body.end(), (uint8_t *)head.c_str(), (uint8_t *)head.c_str() + head.length());
|
||||
body.insert(body.end(), wav_data.begin(), wav_data.end());
|
||||
body.insert(body.end(), (uint8_t*)tail.c_str(),
|
||||
(uint8_t*)tail.c_str() + tail.length());
|
||||
body.insert(body.end(), (uint8_t *)tail.c_str(), (uint8_t *)tail.c_str() + tail.length());
|
||||
|
||||
int code = http.POST(body.data(), body.size());
|
||||
|
||||
@@ -171,15 +191,15 @@ VoiceSessionResponse HttpBackend::SubmitVoiceSession(
|
||||
return result;
|
||||
}
|
||||
|
||||
result.ok = resp_doc["ok"] | false;
|
||||
result.ok = resp_doc["ok"] | false;
|
||||
result.session_id = resp_doc["session_id"] | "";
|
||||
result.transcript = resp_doc["transcript"] | "";
|
||||
result.reply_text = resp_doc["reply_text"] | "";
|
||||
result.provider = resp_doc["provider"] | "none";
|
||||
result.error = resp_doc["error"] | "";
|
||||
result.provider = resp_doc["provider"] | "none";
|
||||
result.error = resp_doc["error"] | "";
|
||||
|
||||
// Reply audio URL.
|
||||
const char* audio_url = resp_doc["reply_audio_url"];
|
||||
const char *audio_url = resp_doc["reply_audio_url"];
|
||||
if (audio_url) {
|
||||
result.reply_audio_url = std::string(audio_url);
|
||||
}
|
||||
@@ -187,32 +207,33 @@ VoiceSessionResponse HttpBackend::SubmitVoiceSession(
|
||||
// Intent.
|
||||
JsonObject intent = resp_doc["intent"];
|
||||
if (intent) {
|
||||
result.intent.type = intent["type"] | "none";
|
||||
result.intent.type = intent["type"] | "none";
|
||||
result.intent.target = intent["target"] | "";
|
||||
result.intent.value = intent["value"] | "";
|
||||
result.intent.value = intent["value"] | "";
|
||||
result.intent.spoken_confirmation = intent["spoken_confirmation"] | "";
|
||||
result.intent.resume_media_after_tts = intent["resume_media_after_tts"] | false;
|
||||
}
|
||||
|
||||
// Player action.
|
||||
const char* pa = resp_doc["player_action"];
|
||||
const char *pa = resp_doc["player_action"];
|
||||
if (pa) {
|
||||
std::string pa_str(pa);
|
||||
if (pa_str == "duck") result.player_action = PlayerAction::kDuck;
|
||||
else if (pa_str == "stop_resume") result.player_action = PlayerAction::kStopResume;
|
||||
else result.player_action = PlayerAction::kNone;
|
||||
if (pa_str == "duck")
|
||||
result.player_action = PlayerAction::kDuck;
|
||||
else if (pa_str == "stop_resume")
|
||||
result.player_action = PlayerAction::kStopResume;
|
||||
else
|
||||
result.player_action = PlayerAction::kNone;
|
||||
}
|
||||
|
||||
Serial.printf("[http] voice session OK — transcript: %s\n",
|
||||
result.transcript.c_str());
|
||||
Serial.printf("[http] voice session OK — transcript: %s\n", result.transcript.c_str());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET reply audio WAV
|
||||
// ---------------------------------------------------------------------------
|
||||
bool HttpBackend::DownloadReplyAudio(const std::string& audio_url,
|
||||
std::vector<uint8_t>* wav_data) {
|
||||
bool HttpBackend::DownloadReplyAudio(const std::string &audio_url, std::vector<uint8_t> *wav_data) {
|
||||
HTTPClient http;
|
||||
http.begin(audio_url.c_str());
|
||||
http.setTimeout(10000);
|
||||
@@ -225,7 +246,7 @@ bool HttpBackend::DownloadReplyAudio(const std::string& audio_url,
|
||||
}
|
||||
|
||||
int len = http.getSize();
|
||||
WiFiClient* stream = http.getStreamPtr();
|
||||
WiFiClient *stream = http.getStreamPtr();
|
||||
if (!stream) {
|
||||
http.end();
|
||||
return false;
|
||||
@@ -244,14 +265,15 @@ bool HttpBackend::DownloadReplyAudio(const std::string& audio_url,
|
||||
continue;
|
||||
}
|
||||
size_t to_read = avail;
|
||||
if (to_read > sizeof(buf)) to_read = sizeof(buf);
|
||||
if (to_read > sizeof(buf))
|
||||
to_read = sizeof(buf);
|
||||
size_t read = stream->readBytes(buf, to_read);
|
||||
wav_data->insert(wav_data->end(), buf, buf + read);
|
||||
if (len > 0) len -= read;
|
||||
if (len > 0)
|
||||
len -= read;
|
||||
}
|
||||
|
||||
http.end();
|
||||
Serial.printf("[http] downloaded %u bytes reply audio\n",
|
||||
(unsigned)wav_data->size());
|
||||
return wav_data->size() > 44; // At least a WAV header.
|
||||
Serial.printf("[http] downloaded %u bytes reply audio\n", (unsigned)wav_data->size());
|
||||
return wav_data->size() > 44; // At least a WAV header.
|
||||
}
|
||||
|
||||
+54
-45
@@ -1,10 +1,11 @@
|
||||
#include "i2s_mic.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
static constexpr int kDmaBufCount = 8;
|
||||
static constexpr int kDmaBufLen = 1024;
|
||||
static constexpr int kDmaBufLen = 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
bool I2sMic::Begin(int sck, int ws, int sd, int sample_rate) {
|
||||
@@ -29,26 +30,28 @@ bool I2sMic::Begin(int sck, int ws, int sd, int sample_rate) {
|
||||
|
||||
// Standard mode config — mono left channel, 16-bit
|
||||
i2s_std_config_t std_cfg = {
|
||||
.clk_cfg = {
|
||||
.sample_rate_hz = (uint32_t)sample_rate_,
|
||||
.clk_src = I2S_CLK_SRC_DEFAULT,
|
||||
.ext_clk_freq_hz = 0,
|
||||
.mclk_multiple = I2S_MCLK_MULTIPLE_256,
|
||||
},
|
||||
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,
|
||||
I2S_SLOT_MODE_MONO),
|
||||
.gpio_cfg = {
|
||||
.mclk = I2S_GPIO_UNUSED,
|
||||
.bclk = (gpio_num_t)sck,
|
||||
.ws = (gpio_num_t)ws,
|
||||
.dout = I2S_GPIO_UNUSED,
|
||||
.din = (gpio_num_t)sd,
|
||||
.invert_flags = {
|
||||
.mclk_inv = false,
|
||||
.bclk_inv = false,
|
||||
.ws_inv = false,
|
||||
.clk_cfg =
|
||||
{
|
||||
.sample_rate_hz = (uint32_t)sample_rate_,
|
||||
.clk_src = I2S_CLK_SRC_DEFAULT,
|
||||
.ext_clk_freq_hz = 0,
|
||||
.mclk_multiple = I2S_MCLK_MULTIPLE_256,
|
||||
},
|
||||
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
|
||||
.gpio_cfg =
|
||||
{
|
||||
.mclk = I2S_GPIO_UNUSED,
|
||||
.bclk = (gpio_num_t)sck,
|
||||
.ws = (gpio_num_t)ws,
|
||||
.dout = I2S_GPIO_UNUSED,
|
||||
.din = (gpio_num_t)sd,
|
||||
.invert_flags =
|
||||
{
|
||||
.mclk_inv = false,
|
||||
.bclk_inv = false,
|
||||
.ws_inv = false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
err = i2s_channel_init_std_mode(rx_handle_, &std_cfg);
|
||||
@@ -68,23 +71,24 @@ bool I2sMic::Begin(int sck, int ws, int sd, int sample_rate) {
|
||||
}
|
||||
|
||||
installed_ = true;
|
||||
Serial.printf("[mic] ready — SCK=%d WS=%d SD=%d @ %d Hz (new driver)\n",
|
||||
sck, ws, sd, sample_rate_);
|
||||
Serial.printf("[mic] ready — SCK=%d WS=%d SD=%d @ %d Hz (new driver)\n", sck, ws, sd,
|
||||
sample_rate_);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
bool I2sMic::Capture(std::vector<uint8_t>& wav_out, uint32_t duration_ms) {
|
||||
if (!installed_ || !rx_handle_) return false;
|
||||
bool I2sMic::Capture(std::vector<uint8_t> &wav_out, uint32_t duration_ms) {
|
||||
if (!installed_ || !rx_handle_)
|
||||
return false;
|
||||
|
||||
const uint32_t bytes_per_sec = sample_rate_ * sizeof(int16_t);
|
||||
const uint32_t pcm_bytes = (bytes_per_sec * duration_ms) / 1000;
|
||||
const uint32_t pcm_bytes = (bytes_per_sec * duration_ms) / 1000;
|
||||
|
||||
wav_out.resize(44 + pcm_bytes);
|
||||
WriteWavHeader(wav_out.data(), pcm_bytes, sample_rate_);
|
||||
|
||||
size_t total = 0;
|
||||
uint8_t* dest = wav_out.data() + 44;
|
||||
uint8_t *dest = wav_out.data() + 44;
|
||||
|
||||
while (total < pcm_bytes) {
|
||||
size_t chunk = pcm_bytes - total;
|
||||
@@ -92,14 +96,13 @@ bool I2sMic::Capture(std::vector<uint8_t>& wav_out, uint32_t duration_ms) {
|
||||
chunk = kDmaBufLen * sizeof(int16_t);
|
||||
|
||||
size_t rd = 0;
|
||||
esp_err_t err = i2s_channel_read(rx_handle_, dest + total, chunk, &rd,
|
||||
portMAX_DELAY);
|
||||
if (err != ESP_OK) return false;
|
||||
esp_err_t err = i2s_channel_read(rx_handle_, dest + total, chunk, &rd, portMAX_DELAY);
|
||||
if (err != ESP_OK)
|
||||
return false;
|
||||
total += rd;
|
||||
}
|
||||
|
||||
Serial.printf("[mic] captured %u bytes (%u ms)\n",
|
||||
(unsigned)total, (unsigned)duration_ms);
|
||||
Serial.printf("[mic] captured %u bytes (%u ms)\n", (unsigned)total, (unsigned)duration_ms);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -114,20 +117,26 @@ void I2sMic::End() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void I2sMic::WriteWavHeader(uint8_t* h, uint32_t pcm_bytes, int sr) {
|
||||
const uint32_t file_size = 36 + pcm_bytes;
|
||||
const uint16_t channels = 1;
|
||||
const uint16_t bits = 16;
|
||||
const uint32_t byte_rate = sr * channels * (bits / 8);
|
||||
void I2sMic::WriteWavHeader(uint8_t *h, uint32_t pcm_bytes, int sr) {
|
||||
const uint32_t file_size = 36 + pcm_bytes;
|
||||
const uint16_t channels = 1;
|
||||
const uint16_t bits = 16;
|
||||
const uint32_t byte_rate = sr * channels * (bits / 8);
|
||||
const uint16_t block_align = channels * (bits / 8);
|
||||
const uint32_t fmt_size = 16;
|
||||
const uint16_t pcm_fmt = 1;
|
||||
const uint32_t fmt_size = 16;
|
||||
const uint16_t pcm_fmt = 1;
|
||||
|
||||
memcpy(h + 0, "RIFF", 4); memcpy(h + 4, &file_size, 4);
|
||||
memcpy(h + 8, "WAVE", 4); memcpy(h + 12, "fmt ", 4);
|
||||
memcpy(h + 16, &fmt_size, 4); memcpy(h + 20, &pcm_fmt, 2);
|
||||
memcpy(h + 22, &channels, 2); memcpy(h + 24, &sr, 4);
|
||||
memcpy(h + 28, &byte_rate, 4); memcpy(h + 32, &block_align, 2);
|
||||
memcpy(h + 34, &bits, 2); memcpy(h + 36, "data", 4);
|
||||
memcpy(h + 0, "RIFF", 4);
|
||||
memcpy(h + 4, &file_size, 4);
|
||||
memcpy(h + 8, "WAVE", 4);
|
||||
memcpy(h + 12, "fmt ", 4);
|
||||
memcpy(h + 16, &fmt_size, 4);
|
||||
memcpy(h + 20, &pcm_fmt, 2);
|
||||
memcpy(h + 22, &channels, 2);
|
||||
memcpy(h + 24, &sr, 4);
|
||||
memcpy(h + 28, &byte_rate, 4);
|
||||
memcpy(h + 32, &block_align, 2);
|
||||
memcpy(h + 34, &bits, 2);
|
||||
memcpy(h + 36, "data", 4);
|
||||
memcpy(h + 40, &pcm_bytes, 4);
|
||||
}
|
||||
|
||||
+130
-97
@@ -1,6 +1,7 @@
|
||||
#include "lcd_ui.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <esp_display_panel.hpp>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -12,26 +13,26 @@
|
||||
using Board = esp_panel::board::Board;
|
||||
using LCD = esp_panel::drivers::LCD;
|
||||
|
||||
static Board* panel = nullptr;
|
||||
static LCD* lcd = nullptr;
|
||||
static Board *panel = nullptr;
|
||||
static LCD *lcd = nullptr;
|
||||
|
||||
static constexpr int kWidth = 360;
|
||||
static constexpr int kWidth = 360;
|
||||
static constexpr int kHeight = 360;
|
||||
static constexpr int kCenter = 180;
|
||||
|
||||
// Simple 16-bit RGB565 colors
|
||||
static constexpr uint16_t kBlack = 0x0000;
|
||||
static constexpr uint16_t kWhite = 0xFFFF;
|
||||
static constexpr uint16_t kGreen = 0x07E0;
|
||||
static constexpr uint16_t kRed = 0xF800;
|
||||
static constexpr uint16_t kBlue = 0x001F;
|
||||
static constexpr uint16_t kCyan = 0x07FF;
|
||||
static constexpr uint16_t kYellow = 0xFFE0;
|
||||
static constexpr uint16_t kBlack = 0x0000;
|
||||
static constexpr uint16_t kWhite = 0xFFFF;
|
||||
static constexpr uint16_t kGreen = 0x07E0;
|
||||
static constexpr uint16_t kRed = 0xF800;
|
||||
static constexpr uint16_t kBlue = 0x001F;
|
||||
static constexpr uint16_t kCyan = 0x07FF;
|
||||
static constexpr uint16_t kYellow = 0xFFE0;
|
||||
static constexpr uint16_t kDarkGray = 0x4208;
|
||||
static constexpr uint16_t kOrange = 0xFD20;
|
||||
static constexpr uint16_t kOrange = 0xFD20;
|
||||
|
||||
// Framebuffer — 360×360×2 = 259 KB, fits in PSRAM.
|
||||
static uint16_t* fb = nullptr;
|
||||
static uint16_t *fb = nullptr;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
static inline void fbPixel(int x, int y, uint16_t color) {
|
||||
@@ -41,7 +42,8 @@ static inline void fbPixel(int x, int y, uint16_t color) {
|
||||
}
|
||||
|
||||
static void fbFill(uint16_t color) {
|
||||
for (int i = 0; i < kWidth * kHeight; i++) fb[i] = color;
|
||||
for (int i = 0; i < kWidth * kHeight; i++)
|
||||
fb[i] = color;
|
||||
}
|
||||
|
||||
static void fbFillRect(int x0, int y0, int w, int h, uint16_t color) {
|
||||
@@ -53,16 +55,18 @@ static void fbFillRect(int x0, int y0, int w, int h, uint16_t color) {
|
||||
}
|
||||
|
||||
static void fbHLine(int x0, int y, int w, uint16_t color) {
|
||||
for (int x = x0; x < x0 + w; x++) fbPixel(x, y, color);
|
||||
for (int x = x0; x < x0 + w; x++)
|
||||
fbPixel(x, y, color);
|
||||
}
|
||||
|
||||
// Simple 8×8 font character renderer (built-in).
|
||||
// For a real product, use LVGL or a proper font engine.
|
||||
// This provides basic text for status display.
|
||||
extern const uint8_t font8x8_basic[128][8]; // from font8x8.cpp
|
||||
extern const uint8_t font8x8_basic[128][8]; // from font8x8.cpp
|
||||
|
||||
static void fbChar(int x0, int y0, char ch, uint16_t color, int scale) {
|
||||
if (ch < 0 || ch > 127) ch = '?';
|
||||
if (ch < 0 || ch > 127)
|
||||
ch = '?';
|
||||
for (int row = 0; row < 8; row++) {
|
||||
uint8_t bits = font8x8_basic[(int)ch][row];
|
||||
for (int col = 0; col < 8; col++) {
|
||||
@@ -75,21 +79,30 @@ static void fbChar(int x0, int y0, char ch, uint16_t color, int scale) {
|
||||
}
|
||||
}
|
||||
|
||||
static void fbString(int x0, int y0, const char* str, uint16_t color, int scale) {
|
||||
static void fbString(int x0, int y0, const char *str, uint16_t color, int scale) {
|
||||
int x = x0;
|
||||
while (*str) {
|
||||
if (*str == '\n') { y0 += 8 * scale + 2; x = x0; str++; continue; }
|
||||
if (*str == '\n') {
|
||||
y0 += 8 * scale + 2;
|
||||
x = x0;
|
||||
str++;
|
||||
continue;
|
||||
}
|
||||
fbChar(x, y0, *str, color, scale);
|
||||
x += 8 * scale;
|
||||
str++;
|
||||
}
|
||||
}
|
||||
|
||||
static int textPixelWidth(const char* str, int scale) {
|
||||
static int textPixelWidth(const char *str, int scale) {
|
||||
int maxw = 0, w = 0;
|
||||
while (*str) {
|
||||
if (*str == '\n') { if (w > maxw) maxw = w; w = 0; }
|
||||
else w += 8 * scale;
|
||||
if (*str == '\n') {
|
||||
if (w > maxw)
|
||||
maxw = w;
|
||||
w = 0;
|
||||
} else
|
||||
w += 8 * scale;
|
||||
str++;
|
||||
}
|
||||
return w > maxw ? w : maxw;
|
||||
@@ -99,12 +112,20 @@ static int textPixelWidth(const char* str, int scale) {
|
||||
static void fbCircle(int cx, int cy, int r, uint16_t color) {
|
||||
int x = 0, y = r, d = 3 - 2 * r;
|
||||
while (x <= y) {
|
||||
fbPixel(cx + x, cy + y, color); fbPixel(cx - x, cy + y, color);
|
||||
fbPixel(cx + x, cy - y, color); fbPixel(cx - x, cy - y, color);
|
||||
fbPixel(cx + y, cy + x, color); fbPixel(cx - y, cy + x, color);
|
||||
fbPixel(cx + y, cy - x, color); fbPixel(cx - y, cy - x, color);
|
||||
if (d < 0) d += 4 * x + 6;
|
||||
else { d += 4 * (x - y) + 10; y--; }
|
||||
fbPixel(cx + x, cy + y, color);
|
||||
fbPixel(cx - x, cy + y, color);
|
||||
fbPixel(cx + x, cy - y, color);
|
||||
fbPixel(cx - x, cy - y, color);
|
||||
fbPixel(cx + y, cy + x, color);
|
||||
fbPixel(cx - y, cy + x, color);
|
||||
fbPixel(cx + y, cy - x, color);
|
||||
fbPixel(cx - y, cy - x, color);
|
||||
if (d < 0)
|
||||
d += 4 * x + 6;
|
||||
else {
|
||||
d += 4 * (x - y) + 10;
|
||||
y--;
|
||||
}
|
||||
x++;
|
||||
}
|
||||
}
|
||||
@@ -124,19 +145,20 @@ bool LcdUi::Begin() {
|
||||
}
|
||||
|
||||
// Allocate framebuffer in PSRAM.
|
||||
fb = (uint16_t*)ps_malloc(kWidth * kHeight * sizeof(uint16_t));
|
||||
fb = (uint16_t *)ps_malloc(kWidth * kHeight * sizeof(uint16_t));
|
||||
if (!fb) {
|
||||
Serial.println("[lcd] FAILED — cannot allocate framebuffer");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Backlight on.
|
||||
auto* bl = panel->getBacklight();
|
||||
if (bl) bl->setBrightness(80); // 0–100
|
||||
auto *bl = panel->getBacklight();
|
||||
if (bl)
|
||||
bl->setBrightness(80); // 0–100
|
||||
|
||||
// Clear to black.
|
||||
fbFill(kBlack);
|
||||
lcd->drawBitmap(0, 0, kWidth, kHeight, (const uint8_t*)fb);
|
||||
lcd->drawBitmap(0, 0, kWidth, kHeight, (const uint8_t *)fb);
|
||||
|
||||
initialized_ = true;
|
||||
Serial.println("[lcd] ready — 360x360 ST77916 QSPI");
|
||||
@@ -145,21 +167,19 @@ bool LcdUi::Begin() {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void LcdUi::SetBrightness(uint8_t level) {
|
||||
if (!panel) return;
|
||||
auto* bl = panel->getBacklight();
|
||||
if (bl) bl->setBrightness(level);
|
||||
if (!panel)
|
||||
return;
|
||||
auto *bl = panel->getBacklight();
|
||||
if (bl)
|
||||
bl->setBrightness(level);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void LcdUi::Render(const MediaSnapshot& media,
|
||||
VoicePhase phase,
|
||||
const std::string& headline,
|
||||
const std::string& summary,
|
||||
bool show_ring) {
|
||||
void LcdUi::Render(const MediaSnapshot &media, VoicePhase phase, const std::string &headline,
|
||||
const std::string &summary, bool show_ring) {
|
||||
if (!initialized_) {
|
||||
// Fallback to serial.
|
||||
Serial.printf("[ui] [%d] %s — %s\n", (int)phase,
|
||||
headline.c_str(), summary.c_str());
|
||||
Serial.printf("[ui] [%d] %s — %s\n", (int)phase, headline.c_str(), summary.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,58 +202,58 @@ void LcdUi::Render(const MediaSnapshot& media,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void LcdUi::RenderWifi(WifiManager::State state, const std::string& info,
|
||||
const std::string& ap_ssid,
|
||||
const std::string& ap_pass,
|
||||
const std::string& ap_ip) {
|
||||
if (!initialized_) return;
|
||||
void LcdUi::RenderWifi(WifiManager::State state, const std::string &info,
|
||||
const std::string &ap_ssid, const std::string &ap_pass,
|
||||
const std::string &ap_ip) {
|
||||
if (!initialized_)
|
||||
return;
|
||||
|
||||
DrawBackground();
|
||||
|
||||
switch (state) {
|
||||
case WifiManager::State::kConnecting:
|
||||
// Pulsing cyan ring.
|
||||
for (int r = kCenter - 8; r <= kCenter - 4; r++)
|
||||
fbCircle(kCenter, kCenter, r, kCyan);
|
||||
DrawCenteredText(kCenter - 40, "WiFi", kWhite, 3);
|
||||
DrawCenteredText(kCenter, "Connexion...", kCyan, 2);
|
||||
if (!info.empty()) {
|
||||
DrawCenteredText(kCenter + 30, info.c_str(), kDarkGray, 2);
|
||||
}
|
||||
break;
|
||||
case WifiManager::State::kConnecting:
|
||||
// Pulsing cyan ring.
|
||||
for (int r = kCenter - 8; r <= kCenter - 4; r++)
|
||||
fbCircle(kCenter, kCenter, r, kCyan);
|
||||
DrawCenteredText(kCenter - 40, "WiFi", kWhite, 3);
|
||||
DrawCenteredText(kCenter, "Connexion...", kCyan, 2);
|
||||
if (!info.empty()) {
|
||||
DrawCenteredText(kCenter + 30, info.c_str(), kDarkGray, 2);
|
||||
}
|
||||
break;
|
||||
|
||||
case WifiManager::State::kApMode:
|
||||
// Orange ring for AP mode.
|
||||
for (int r = kCenter - 8; r <= kCenter - 4; r++)
|
||||
fbCircle(kCenter, kCenter, r, kOrange);
|
||||
DrawCenteredText(60, "MODE CONFIG", kOrange, 2);
|
||||
DrawCenteredText(kCenter - 50, "WiFi:", kWhite, 2);
|
||||
DrawCenteredText(kCenter - 25, ap_ssid.c_str(), kCyan, 2);
|
||||
DrawCenteredText(kCenter + 5, "Pass:", kWhite, 2);
|
||||
DrawCenteredText(kCenter + 30, ap_pass.c_str(), kCyan, 2);
|
||||
DrawCenteredText(kCenter + 65, "http://", kDarkGray, 1);
|
||||
DrawCenteredText(kCenter + 80, ap_ip.c_str(), kGreen, 2);
|
||||
DrawCenteredText(kHeight - 50, "Connectez-vous au", kDarkGray, 1);
|
||||
DrawCenteredText(kHeight - 38, "WiFi ci-dessus", kDarkGray, 1);
|
||||
break;
|
||||
case WifiManager::State::kApMode:
|
||||
// Orange ring for AP mode.
|
||||
for (int r = kCenter - 8; r <= kCenter - 4; r++)
|
||||
fbCircle(kCenter, kCenter, r, kOrange);
|
||||
DrawCenteredText(60, "MODE CONFIG", kOrange, 2);
|
||||
DrawCenteredText(kCenter - 50, "WiFi:", kWhite, 2);
|
||||
DrawCenteredText(kCenter - 25, ap_ssid.c_str(), kCyan, 2);
|
||||
DrawCenteredText(kCenter + 5, "Pass:", kWhite, 2);
|
||||
DrawCenteredText(kCenter + 30, ap_pass.c_str(), kCyan, 2);
|
||||
DrawCenteredText(kCenter + 65, "http://", kDarkGray, 1);
|
||||
DrawCenteredText(kCenter + 80, ap_ip.c_str(), kGreen, 2);
|
||||
DrawCenteredText(kHeight - 50, "Connectez-vous au", kDarkGray, 1);
|
||||
DrawCenteredText(kHeight - 38, "WiFi ci-dessus", kDarkGray, 1);
|
||||
break;
|
||||
|
||||
case WifiManager::State::kConnected:
|
||||
for (int r = kCenter - 8; r <= kCenter - 4; r++)
|
||||
fbCircle(kCenter, kCenter, r, kGreen);
|
||||
DrawCenteredText(kCenter - 20, "Connecte!", kGreen, 3);
|
||||
DrawCenteredText(kCenter + 20, info.c_str(), kWhite, 2);
|
||||
break;
|
||||
case WifiManager::State::kConnected:
|
||||
for (int r = kCenter - 8; r <= kCenter - 4; r++)
|
||||
fbCircle(kCenter, kCenter, r, kGreen);
|
||||
DrawCenteredText(kCenter - 20, "Connecte!", kGreen, 3);
|
||||
DrawCenteredText(kCenter + 20, info.c_str(), kWhite, 2);
|
||||
break;
|
||||
|
||||
case WifiManager::State::kFailed:
|
||||
for (int r = kCenter - 8; r <= kCenter - 4; r++)
|
||||
fbCircle(kCenter, kCenter, r, kRed);
|
||||
DrawCenteredText(kCenter - 20, "Echec WiFi", kRed, 3);
|
||||
DrawCenteredText(kCenter + 20, info.c_str(), kDarkGray, 2);
|
||||
DrawCenteredText(kCenter + 50, "Appui long=config", kYellow, 1);
|
||||
break;
|
||||
case WifiManager::State::kFailed:
|
||||
for (int r = kCenter - 8; r <= kCenter - 4; r++)
|
||||
fbCircle(kCenter, kCenter, r, kRed);
|
||||
DrawCenteredText(kCenter - 20, "Echec WiFi", kRed, 3);
|
||||
DrawCenteredText(kCenter + 20, info.c_str(), kDarkGray, 2);
|
||||
DrawCenteredText(kCenter + 50, "Appui long=config", kYellow, 1);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
Flush();
|
||||
@@ -242,7 +262,7 @@ void LcdUi::RenderWifi(WifiManager::State state, const std::string& info,
|
||||
// ---------------------------------------------------------------------------
|
||||
void LcdUi::Flush() {
|
||||
if (initialized_ && lcd && fb) {
|
||||
lcd->drawBitmap(0, 0, kWidth, kHeight, (const uint8_t*)fb);
|
||||
lcd->drawBitmap(0, 0, kWidth, kHeight, (const uint8_t *)fb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,19 +274,22 @@ void LcdUi::DrawBackground() {
|
||||
fbCircle(kCenter, kCenter, kCenter - 2, kDarkGray);
|
||||
}
|
||||
|
||||
void LcdUi::DrawCenteredText(int y, const char* text, uint16_t color, int sz) {
|
||||
void LcdUi::DrawCenteredText(int y, const char *text, uint16_t color, int sz) {
|
||||
int w = textPixelWidth(text, sz);
|
||||
int x = (kWidth - w) / 2;
|
||||
if (x < 10) x = 10;
|
||||
if (x < 10)
|
||||
x = 10;
|
||||
fbString(x, y, text, color, sz);
|
||||
}
|
||||
|
||||
void LcdUi::DrawStatusBar(const MediaSnapshot& media) {
|
||||
void LcdUi::DrawStatusBar(const MediaSnapshot &media) {
|
||||
// Top area: mode + volume.
|
||||
char buf[48];
|
||||
const char* mode_str = "IDLE";
|
||||
if (media.mode == MediaMode::kMp3) mode_str = "MP3";
|
||||
if (media.mode == MediaMode::kRadio) mode_str = "RADIO";
|
||||
const char *mode_str = "IDLE";
|
||||
if (media.mode == MediaMode::kMp3)
|
||||
mode_str = "MP3";
|
||||
if (media.mode == MediaMode::kRadio)
|
||||
mode_str = "RADIO";
|
||||
snprintf(buf, sizeof(buf), "%s vol:%d", mode_str, media.volume);
|
||||
DrawCenteredText(40, buf, kYellow, 2);
|
||||
|
||||
@@ -287,11 +310,21 @@ void LcdUi::DrawStatusBar(const MediaSnapshot& media) {
|
||||
void LcdUi::DrawVoiceRing(VoicePhase phase) {
|
||||
uint16_t color = kBlue;
|
||||
switch (phase) {
|
||||
case VoicePhase::kRecording: color = kRed; break;
|
||||
case VoicePhase::kThinking: color = kOrange; break;
|
||||
case VoicePhase::kSpeaking: color = kGreen; break;
|
||||
case VoicePhase::kError: color = kRed; break;
|
||||
default: color = kBlue; break;
|
||||
case VoicePhase::kRecording:
|
||||
color = kRed;
|
||||
break;
|
||||
case VoicePhase::kThinking:
|
||||
color = kOrange;
|
||||
break;
|
||||
case VoicePhase::kSpeaking:
|
||||
color = kGreen;
|
||||
break;
|
||||
case VoicePhase::kError:
|
||||
color = kRed;
|
||||
break;
|
||||
default:
|
||||
color = kBlue;
|
||||
break;
|
||||
}
|
||||
for (int r = kCenter - 10; r <= kCenter - 5; r++) {
|
||||
fbCircle(kCenter, kCenter, r, color);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* Native build stub — compile guard for host-side toolchain validation only.
|
||||
Actual firmware runs on ESP32 targets (esp32s3_arduino, esp32_arduino). */
|
||||
#ifdef UNIT_TEST
|
||||
int main() { return 0; }
|
||||
int main() {
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
#include "ota_manager.h"
|
||||
#include "firmware_utils.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
#include <HTTPClient.h>
|
||||
#include <Update.h>
|
||||
#include <WiFi.h>
|
||||
#include <WiFiClient.h>
|
||||
|
||||
OtaManager::OtaManager(const std::string& current_version)
|
||||
: current_version_(current_version) {}
|
||||
#include "firmware_utils.h"
|
||||
|
||||
OtaManager::OtaManager(const std::string ¤t_version) : current_version_(current_version) {}
|
||||
|
||||
OtaCheckResult OtaManager::CheckOnly() {
|
||||
return FetchLatestInfo();
|
||||
@@ -75,8 +76,8 @@ OtaCheckResult OtaManager::FetchLatestInfo() {
|
||||
}
|
||||
|
||||
result.latest_version = resp_doc["latest"].as<std::string>();
|
||||
result.url = resp_doc["url"].as<std::string>();
|
||||
result.notes = resp_doc["notes"].as<std::string>();
|
||||
result.url = resp_doc["url"].as<std::string>();
|
||||
result.notes = resp_doc["notes"].as<std::string>();
|
||||
|
||||
if (FwCompareVersions(current_version_, result.latest_version) >= 0) {
|
||||
result.status = OtaCheckResult::Status::kUpToDate;
|
||||
@@ -87,8 +88,9 @@ OtaCheckResult OtaManager::FetchLatestInfo() {
|
||||
return result;
|
||||
}
|
||||
|
||||
bool OtaManager::FlashFromUrl(const std::string& url) {
|
||||
if (url.empty()) return false;
|
||||
bool OtaManager::FlashFromUrl(const std::string &url) {
|
||||
if (url.empty())
|
||||
return false;
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(url.c_str());
|
||||
@@ -100,12 +102,11 @@ bool OtaManager::FlashFromUrl(const std::string& url) {
|
||||
}
|
||||
|
||||
const int content_len = http.getSize();
|
||||
WiFiClient* stream = http.getStreamPtr();
|
||||
WiFiClient *stream = http.getStreamPtr();
|
||||
|
||||
if (!Update.begin(content_len > 0 ? content_len : UPDATE_SIZE_UNKNOWN)) {
|
||||
http.end();
|
||||
Serial.printf("[OTA] Update.begin failed: %s\n",
|
||||
Update.errorString());
|
||||
Serial.printf("[OTA] Update.begin failed: %s\n", Update.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
#include "voice_controller.h"
|
||||
#include "firmware_utils.h"
|
||||
|
||||
#include <Arduino.h> // yield() — feeds WDT, lets other FreeRTOS tasks run
|
||||
#include <Arduino.h> // yield() — feeds WDT, lets other FreeRTOS tasks run
|
||||
|
||||
#include <utility>
|
||||
|
||||
VoiceController::VoiceController(std::string device_id,
|
||||
BackendClient& backend,
|
||||
MediaController& media,
|
||||
UiRenderer& ui)
|
||||
: device_id_(std::move(device_id)),
|
||||
backend_(backend),
|
||||
media_(media),
|
||||
ui_(ui) {}
|
||||
#include "firmware_utils.h"
|
||||
|
||||
VoiceController::VoiceController(std::string device_id, BackendClient &backend,
|
||||
MediaController &media, UiRenderer &ui)
|
||||
: device_id_(std::move(device_id)), backend_(backend), media_(media), ui_(ui) {}
|
||||
|
||||
void VoiceController::Boot() {
|
||||
phase_ = VoicePhase::kIdle;
|
||||
RenderState("pret", IdleSummary(media_.Snapshot()), false);
|
||||
backend_.SendPlayerEvent(device_id_, "boot", media_.Snapshot(),
|
||||
"voice-controller-ready");
|
||||
backend_.SendPlayerEvent(device_id_, "boot", media_.Snapshot(), "voice-controller-ready");
|
||||
}
|
||||
|
||||
bool VoiceController::BeginPushToTalk() {
|
||||
@@ -31,7 +26,7 @@ bool VoiceController::BeginPushToTalk() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VoiceController::CompletePushToTalk(const std::vector<uint8_t>& wav_data) {
|
||||
bool VoiceController::CompletePushToTalk(const std::vector<uint8_t> &wav_data) {
|
||||
if (phase_ != VoicePhase::kRecording) {
|
||||
return false;
|
||||
}
|
||||
@@ -43,14 +38,12 @@ bool VoiceController::CompletePushToTalk(const std::vector<uint8_t>& wav_data) {
|
||||
phase_ = VoicePhase::kThinking;
|
||||
RenderState("je reflechis", "Envoi de la requete a Mascarade.", true);
|
||||
|
||||
last_response_ = backend_.SubmitVoiceSession(device_id_, media_.Snapshot(),
|
||||
wav_data);
|
||||
yield(); // WDT: feed watchdog after HTTP POST round-trip
|
||||
last_response_ = backend_.SubmitVoiceSession(device_id_, media_.Snapshot(), wav_data);
|
||||
yield(); // WDT: feed watchdog after HTTP POST round-trip
|
||||
|
||||
if (!last_response_.ok) {
|
||||
const std::string error_text =
|
||||
last_response_.error.empty() ? "session vocale en echec"
|
||||
: last_response_.error;
|
||||
last_response_.error.empty() ? "session vocale en echec" : last_response_.error;
|
||||
return Fail(error_text);
|
||||
}
|
||||
|
||||
@@ -59,8 +52,7 @@ bool VoiceController::CompletePushToTalk(const std::vector<uint8_t>& wav_data) {
|
||||
const MediaSnapshot after = media_.Snapshot();
|
||||
|
||||
if (ShouldPublishPlaybackStarted(before, after, last_response_.intent)) {
|
||||
const std::string detail =
|
||||
!after.station.empty() ? after.station : after.track;
|
||||
const std::string detail = !after.station.empty() ? after.station : after.track;
|
||||
backend_.SendPlayerEvent(device_id_, "playback_started", after, detail);
|
||||
}
|
||||
|
||||
@@ -68,19 +60,16 @@ bool VoiceController::CompletePushToTalk(const std::vector<uint8_t>& wav_data) {
|
||||
|
||||
phase_ = VoicePhase::kSpeaking;
|
||||
RenderState("je reponds",
|
||||
last_response_.reply_text.empty()
|
||||
? "Reponse audio en preparation."
|
||||
: last_response_.reply_text,
|
||||
last_response_.reply_text.empty() ? "Reponse audio en preparation."
|
||||
: last_response_.reply_text,
|
||||
true);
|
||||
|
||||
if (!last_response_.reply_audio_url.empty()) {
|
||||
std::vector<uint8_t> reply_audio;
|
||||
if (backend_.DownloadReplyAudio(last_response_.reply_audio_url,
|
||||
&reply_audio)) {
|
||||
yield(); // WDT: feed watchdog after audio download
|
||||
if (backend_.DownloadReplyAudio(last_response_.reply_audio_url, &reply_audio)) {
|
||||
yield(); // WDT: feed watchdog after audio download
|
||||
if (!media_.PlayReplyAudio(reply_audio)) {
|
||||
backend_.SendPlayerEvent(device_id_, "playback_failed",
|
||||
media_.Snapshot(),
|
||||
backend_.SendPlayerEvent(device_id_, "playback_failed", media_.Snapshot(),
|
||||
"tts reply playback failed");
|
||||
}
|
||||
} else {
|
||||
@@ -96,31 +85,29 @@ bool VoiceController::CompletePushToTalk(const std::vector<uint8_t>& wav_data) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VoiceController::Fail(const std::string& error_text) {
|
||||
bool VoiceController::Fail(const std::string &error_text) {
|
||||
phase_ = VoicePhase::kError;
|
||||
last_response_.ok = false;
|
||||
last_response_.error = error_text;
|
||||
RenderState("erreur reseau", error_text, false);
|
||||
backend_.SendPlayerEvent(device_id_, "voice_error", media_.Snapshot(),
|
||||
error_text);
|
||||
backend_.SendPlayerEvent(device_id_, "voice_error", media_.Snapshot(), error_text);
|
||||
media_.RestoreAfterReply(true);
|
||||
phase_ = VoicePhase::kIdle;
|
||||
RenderState("pret", IdleSummary(media_.Snapshot()), false);
|
||||
return false;
|
||||
}
|
||||
|
||||
void VoiceController::RenderState(const std::string& headline,
|
||||
const std::string& summary,
|
||||
void VoiceController::RenderState(const std::string &headline, const std::string &summary,
|
||||
bool show_ring) {
|
||||
ui_.Render(media_.Snapshot(), phase_, headline, summary, show_ring);
|
||||
}
|
||||
|
||||
std::string VoiceController::IdleSummary(const MediaSnapshot& media) {
|
||||
std::string VoiceController::IdleSummary(const MediaSnapshot &media) {
|
||||
return FwIdleSummary(media);
|
||||
}
|
||||
|
||||
bool VoiceController::ShouldPublishPlaybackStarted(
|
||||
const MediaSnapshot& before, const MediaSnapshot& after,
|
||||
const VoiceIntent& intent) {
|
||||
bool VoiceController::ShouldPublishPlaybackStarted(const MediaSnapshot &before,
|
||||
const MediaSnapshot &after,
|
||||
const VoiceIntent &intent) {
|
||||
return FwShouldPublishPlaybackStarted(before, after, intent);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
#include "wifi_manager.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
|
||||
#include <DNSServer.h>
|
||||
#include <Preferences.h>
|
||||
#include <WebServer.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NVS namespace
|
||||
// ---------------------------------------------------------------------------
|
||||
static constexpr const char* kNvsNamespace = "killlife";
|
||||
static constexpr const char *kNvsNamespace = "killlife";
|
||||
static Preferences prefs;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Captive portal
|
||||
// ---------------------------------------------------------------------------
|
||||
static WebServer* web_server = nullptr;
|
||||
static DNSServer* dns_server = nullptr;
|
||||
static WifiManager* g_mgr = nullptr; // back-pointer for handlers
|
||||
static WebServer *web_server = nullptr;
|
||||
static DNSServer *dns_server = nullptr;
|
||||
static WifiManager *g_mgr = nullptr; // back-pointer for handlers
|
||||
|
||||
// HTML page served by the captive portal.
|
||||
static const char kPortalHtml[] PROGMEM = R"rawhtml(
|
||||
@@ -97,8 +98,7 @@ static const char kSavedHtml[] PROGMEM = R"rawhtml(
|
||||
)rawhtml";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void WifiManager::SetApCredentials(const std::string& ssid,
|
||||
const std::string& password) {
|
||||
void WifiManager::SetApCredentials(const std::string &ssid, const std::string &password) {
|
||||
ap_ssid_ = ssid;
|
||||
ap_password_ = password;
|
||||
}
|
||||
@@ -117,8 +117,8 @@ void WifiManager::Begin() {
|
||||
if (TryConnect(saved_ssid_, saved_pass_)) {
|
||||
current_ssid_ = saved_ssid_;
|
||||
SetState(State::kConnected, WiFi.localIP().toString().c_str());
|
||||
Serial.printf("[wifi] connected to %s — %s\n",
|
||||
current_ssid_.c_str(), WiFi.localIP().toString().c_str());
|
||||
Serial.printf("[wifi] connected to %s — %s\n", current_ssid_.c_str(),
|
||||
WiFi.localIP().toString().c_str());
|
||||
} else {
|
||||
Serial.println("[wifi] connection failed → AP mode");
|
||||
StartApMode();
|
||||
@@ -149,12 +149,11 @@ void WifiManager::StartApMode() {
|
||||
WiFi.disconnect(true);
|
||||
delay(100);
|
||||
|
||||
WiFi.mode(WIFI_AP_STA); // AP + STA for scanning.
|
||||
WiFi.mode(WIFI_AP_STA); // AP + STA for scanning.
|
||||
WiFi.softAP(ap_ssid_.c_str(), ap_password_.c_str());
|
||||
delay(200);
|
||||
|
||||
Serial.printf("[wifi] AP started: %s / %s — http://%s\n",
|
||||
ap_ssid_.c_str(), ap_password_.c_str(),
|
||||
Serial.printf("[wifi] AP started: %s / %s — http://%s\n", ap_ssid_.c_str(), ap_password_.c_str(),
|
||||
WiFi.softAPIP().toString().c_str());
|
||||
|
||||
SetupApWebServer();
|
||||
@@ -163,12 +162,14 @@ void WifiManager::StartApMode() {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
std::string WifiManager::ip() const {
|
||||
if (state_ != State::kConnected) return "";
|
||||
if (state_ != State::kConnected)
|
||||
return "";
|
||||
return WiFi.localIP().toString().c_str();
|
||||
}
|
||||
|
||||
int WifiManager::rssi() const {
|
||||
if (state_ != State::kConnected) return 0;
|
||||
if (state_ != State::kConnected)
|
||||
return 0;
|
||||
return WiFi.RSSI();
|
||||
}
|
||||
|
||||
@@ -192,26 +193,23 @@ std::vector<WifiManager::ScanResult> WifiManager::Scan() {
|
||||
WiFi.scanDelete();
|
||||
// Sort by signal strength.
|
||||
std::sort(results.begin(), results.end(),
|
||||
[](const ScanResult& a, const ScanResult& b) {
|
||||
return a.rssi > b.rssi;
|
||||
});
|
||||
[](const ScanResult &a, const ScanResult &b) { return a.rssi > b.rssi; });
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void WifiManager::LoadCredentials() {
|
||||
prefs.begin(kNvsNamespace, true);
|
||||
saved_ssid_ = prefs.getString("wifi_ssid", "").c_str();
|
||||
saved_pass_ = prefs.getString("wifi_pass", "").c_str();
|
||||
saved_ssid_ = prefs.getString("wifi_ssid", "").c_str();
|
||||
saved_pass_ = prefs.getString("wifi_pass", "").c_str();
|
||||
backend_url_ = prefs.getString("backend_url", "http://192.168.1.42:8000").c_str();
|
||||
prefs.end();
|
||||
Serial.printf("[wifi] loaded: ssid='%s' backend='%s'\n",
|
||||
saved_ssid_.c_str(), backend_url_.c_str());
|
||||
Serial.printf("[wifi] loaded: ssid='%s' backend='%s'\n", saved_ssid_.c_str(),
|
||||
backend_url_.c_str());
|
||||
}
|
||||
|
||||
void WifiManager::SaveCredentials(const std::string& ssid,
|
||||
const std::string& pass,
|
||||
const std::string& backend) {
|
||||
void WifiManager::SaveCredentials(const std::string &ssid, const std::string &pass,
|
||||
const std::string &backend) {
|
||||
prefs.begin(kNvsNamespace, false);
|
||||
prefs.putString("wifi_ssid", ssid.c_str());
|
||||
prefs.putString("wifi_pass", pass.c_str());
|
||||
@@ -220,12 +218,11 @@ void WifiManager::SaveCredentials(const std::string& ssid,
|
||||
saved_ssid_ = ssid;
|
||||
saved_pass_ = pass;
|
||||
backend_url_ = backend;
|
||||
Serial.printf("[wifi] saved: ssid='%s' backend='%s'\n",
|
||||
ssid.c_str(), backend.c_str());
|
||||
Serial.printf("[wifi] saved: ssid='%s' backend='%s'\n", ssid.c_str(), backend.c_str());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
bool WifiManager::TryConnect(const std::string& ssid, const std::string& pass,
|
||||
bool WifiManager::TryConnect(const std::string &ssid, const std::string &pass,
|
||||
uint32_t timeout_ms) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
@@ -242,9 +239,10 @@ bool WifiManager::TryConnect(const std::string& ssid, const std::string& pass,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
void WifiManager::SetState(State s, const std::string& info) {
|
||||
void WifiManager::SetState(State s, const std::string &info) {
|
||||
state_ = s;
|
||||
if (on_state_change_) on_state_change_(s, info);
|
||||
if (on_state_change_)
|
||||
on_state_change_(s, info);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -269,7 +267,8 @@ void WifiManager::SetupApWebServer() {
|
||||
auto nets = g_mgr->Scan();
|
||||
String json = "[";
|
||||
for (size_t i = 0; i < nets.size(); i++) {
|
||||
if (i > 0) json += ",";
|
||||
if (i > 0)
|
||||
json += ",";
|
||||
json += "{\"ssid\":\"";
|
||||
// Escape quotes in SSID.
|
||||
String escaped = nets[i].ssid.c_str();
|
||||
@@ -287,8 +286,8 @@ void WifiManager::SetupApWebServer() {
|
||||
|
||||
// Save credentials.
|
||||
web_server->on("/save", HTTP_POST, []() {
|
||||
String ssid = web_server->arg("ssid");
|
||||
String pass = web_server->arg("pass");
|
||||
String ssid = web_server->arg("ssid");
|
||||
String pass = web_server->arg("pass");
|
||||
String backend = web_server->arg("backend");
|
||||
|
||||
if (ssid.isEmpty()) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#include "wifi_scanner.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <WiFi.h>
|
||||
#include <algorithm>
|
||||
|
||||
std::vector<WifiNetwork> WifiScanner::Scan(uint32_t timeout_ms) {
|
||||
const uint32_t t_start = millis();
|
||||
@@ -22,12 +23,13 @@ std::vector<WifiNetwork> WifiScanner::Scan(uint32_t timeout_ms) {
|
||||
result.reserve(static_cast<size_t>(n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const String ssid = WiFi.SSID(i);
|
||||
if (ssid.isEmpty()) continue; // skip hidden networks
|
||||
if (ssid.isEmpty())
|
||||
continue; // skip hidden networks
|
||||
|
||||
WifiNetwork net;
|
||||
net.ssid = ssid.c_str();
|
||||
net.rssi = WiFi.RSSI(i);
|
||||
net.open = (WiFi.encryptionType(i) == WIFI_AUTH_OPEN);
|
||||
net.ssid = ssid.c_str();
|
||||
net.rssi = WiFi.RSSI(i);
|
||||
net.open = (WiFi.encryptionType(i) == WIFI_AUTH_OPEN);
|
||||
net.channel = WiFi.channel(i);
|
||||
result.push_back(std::move(net));
|
||||
}
|
||||
@@ -38,10 +40,9 @@ std::vector<WifiNetwork> WifiScanner::Scan(uint32_t timeout_ms) {
|
||||
|
||||
// Cap elapsed if scan returned very fast (sanity).
|
||||
last_duration_ms_ = elapsed;
|
||||
last_json_ = FwWifiToJson(result, elapsed);
|
||||
last_json_ = FwWifiToJson(result, elapsed);
|
||||
|
||||
Serial.printf("[wifi_scan] found %zu network(s) in %u ms\n",
|
||||
result.size(), elapsed);
|
||||
Serial.printf("[wifi_scan] found %zu network(s) in %u ms\n", result.size(), elapsed);
|
||||
Serial.println(last_json_.c_str());
|
||||
|
||||
WiFi.scanDelete();
|
||||
|
||||
+37
-29
@@ -1,46 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef UNIT_TEST
|
||||
#include <string>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
using String = std::string;
|
||||
|
||||
static inline String escape_json_string(const String& s) {
|
||||
String out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
if (c == '"') out += "\\\"";
|
||||
else if (c == '\\') out += "\\\\";
|
||||
else out += c;
|
||||
}
|
||||
return out;
|
||||
static inline String escape_json_string(const String &s) {
|
||||
String out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
if (c == '"')
|
||||
out += "\\\"";
|
||||
else if (c == '\\')
|
||||
out += "\\\\";
|
||||
else
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static inline String itos(int n) { return std::to_string(n); }
|
||||
static inline String itos(int n) {
|
||||
return std::to_string(n);
|
||||
}
|
||||
|
||||
#else // Arduino
|
||||
#else // Arduino
|
||||
#include <Arduino.h>
|
||||
|
||||
static inline String escape_json_string(const String& s) {
|
||||
String out;
|
||||
out.reserve(s.length());
|
||||
for (size_t i = 0; i < s.length(); i++) {
|
||||
char c = s[i];
|
||||
if (c == '"') out += "\\\"";
|
||||
else if (c == '\\') out += "\\\\";
|
||||
else out += c;
|
||||
}
|
||||
return out;
|
||||
static inline String escape_json_string(const String &s) {
|
||||
String out;
|
||||
out.reserve(s.length());
|
||||
for (size_t i = 0; i < s.length(); i++) {
|
||||
char c = s[i];
|
||||
if (c == '"')
|
||||
out += "\\\"";
|
||||
else if (c == '\\')
|
||||
out += "\\\\";
|
||||
else
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static inline String itos(int n) { return String(n); }
|
||||
static inline String itos(int n) {
|
||||
return String(n);
|
||||
}
|
||||
|
||||
#endif // UNIT_TEST
|
||||
#endif // UNIT_TEST
|
||||
|
||||
// Serialize a single WiFi network entry to a JSON object string.
|
||||
static inline String serialize_network(const String& ssid, int rssi, int channel, int auth) {
|
||||
return String("{\"ssid\":\"") + escape_json_string(ssid) +
|
||||
"\",\"rssi\":" + itos(rssi) +
|
||||
",\"channel\":" + itos(channel) +
|
||||
",\"auth\":" + itos(auth) + "}";
|
||||
static inline String serialize_network(const String &ssid, int rssi, int channel, int auth) {
|
||||
return String("{\"ssid\":\"") + escape_json_string(ssid) + "\",\"rssi\":" + itos(rssi) +
|
||||
",\"channel\":" + itos(channel) + ",\"auth\":" + itos(auth) + "}";
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
/// Covers: IdleSummary, ShouldPublishPlaybackStarted, version comparison,
|
||||
/// WAV header validation, backend URL validation.
|
||||
|
||||
#include <unity.h>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <unity.h>
|
||||
|
||||
#include "../../include/firmware_utils.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -16,7 +17,7 @@ void test_idle_summary_idle_mode() {
|
||||
MediaSnapshot m;
|
||||
m.mode = MediaMode::kIdle;
|
||||
m.volume = 40;
|
||||
m.playing = true; // playing flag ignored in Idle mode
|
||||
m.playing = true; // playing flag ignored in Idle mode
|
||||
std::string s = FwIdleSummary(m);
|
||||
TEST_ASSERT_EQUAL_STRING("Idle | volume 40", s.c_str());
|
||||
}
|
||||
@@ -166,23 +167,21 @@ void test_version_patch_downgrade() {
|
||||
void test_wav_valid_header() {
|
||||
// Minimal 12-byte RIFF/WAVE header
|
||||
const uint8_t data[] = {
|
||||
'R', 'I', 'F', 'F', // chunk id
|
||||
0x24, 0x00, 0x00, 0x00, // chunk size (36 bytes data)
|
||||
'W', 'A', 'V', 'E', // format
|
||||
'R', 'I', 'F', 'F', // chunk id
|
||||
0x24, 0x00, 0x00, 0x00, // chunk size (36 bytes data)
|
||||
'W', 'A', 'V', 'E', // format
|
||||
};
|
||||
TEST_ASSERT_TRUE(FwIsValidWavHeader(data, sizeof(data)));
|
||||
}
|
||||
|
||||
void test_wav_too_short() {
|
||||
const uint8_t data[] = { 'R', 'I', 'F', 'F', 0x00 };
|
||||
const uint8_t data[] = {'R', 'I', 'F', 'F', 0x00};
|
||||
TEST_ASSERT_FALSE(FwIsValidWavHeader(data, sizeof(data)));
|
||||
}
|
||||
|
||||
void test_wav_wrong_magic() {
|
||||
const uint8_t data[] = {
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
TEST_ASSERT_FALSE(FwIsValidWavHeader(data, sizeof(data)));
|
||||
}
|
||||
@@ -278,13 +277,19 @@ void test_wifi_json_escapes_quotes() {
|
||||
}
|
||||
|
||||
void test_wifi_sort_by_rssi() {
|
||||
WifiNetwork a; a.ssid = "weak"; a.rssi = -80;
|
||||
WifiNetwork b; b.ssid = "good"; b.rssi = -55;
|
||||
WifiNetwork c; c.ssid = "ok"; c.rssi = -70;
|
||||
WifiNetwork a;
|
||||
a.ssid = "weak";
|
||||
a.rssi = -80;
|
||||
WifiNetwork b;
|
||||
b.ssid = "good";
|
||||
b.rssi = -55;
|
||||
WifiNetwork c;
|
||||
c.ssid = "ok";
|
||||
c.rssi = -70;
|
||||
std::vector<WifiNetwork> nets = {a, b, c};
|
||||
std::sort(nets.begin(), nets.end(), FwNetworkBetterSignal);
|
||||
TEST_ASSERT_EQUAL_STRING("good", nets[0].ssid.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("ok", nets[1].ssid.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("ok", nets[1].ssid.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("weak", nets[2].ssid.c_str());
|
||||
}
|
||||
|
||||
@@ -305,8 +310,7 @@ void test_wifi_json_open_network() {
|
||||
|
||||
void test_media_snapshot_defaults() {
|
||||
MediaSnapshot m;
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(MediaMode::kIdle),
|
||||
static_cast<int>(m.mode));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(MediaMode::kIdle), static_cast<int>(m.mode));
|
||||
TEST_ASSERT_FALSE(m.playing);
|
||||
TEST_ASSERT_EQUAL_INT(40, m.volume);
|
||||
TEST_ASSERT_EQUAL_INT(-1, m.battery_pct);
|
||||
@@ -320,7 +324,7 @@ void test_voice_intent_defaults() {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int main(int, char**) {
|
||||
int main(int, char **) {
|
||||
UNITY_BEGIN();
|
||||
|
||||
// IdleSummary
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
/// checking. Compiles on the native (Linux x86_64) target with -D UNIT_TEST=1.
|
||||
/// No Arduino/ESP32 headers are used.
|
||||
|
||||
#include <unity.h>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unity.h>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -16,29 +16,23 @@
|
||||
inline void yield() {}
|
||||
|
||||
// Include the pure-C++ headers (no Arduino deps).
|
||||
#include "../../include/voice_controller.h"
|
||||
#include "../../include/firmware_utils.h"
|
||||
#include "../../include/ota_manager.h"
|
||||
#include "../../include/voice_controller.h"
|
||||
|
||||
// ===========================================================================
|
||||
// VoiceController implementation (copied from voice_controller.cpp to avoid
|
||||
// pulling in Arduino.h which is not available on native).
|
||||
// ===========================================================================
|
||||
|
||||
VoiceController::VoiceController(std::string device_id,
|
||||
BackendClient& backend,
|
||||
MediaController& media,
|
||||
UiRenderer& ui)
|
||||
: device_id_(std::move(device_id)),
|
||||
backend_(backend),
|
||||
media_(media),
|
||||
ui_(ui) {}
|
||||
VoiceController::VoiceController(std::string device_id, BackendClient &backend,
|
||||
MediaController &media, UiRenderer &ui)
|
||||
: device_id_(std::move(device_id)), backend_(backend), media_(media), ui_(ui) {}
|
||||
|
||||
void VoiceController::Boot() {
|
||||
phase_ = VoicePhase::kIdle;
|
||||
RenderState("pret", IdleSummary(media_.Snapshot()), false);
|
||||
backend_.SendPlayerEvent(device_id_, "boot", media_.Snapshot(),
|
||||
"voice-controller-ready");
|
||||
backend_.SendPlayerEvent(device_id_, "boot", media_.Snapshot(), "voice-controller-ready");
|
||||
}
|
||||
|
||||
bool VoiceController::BeginPushToTalk() {
|
||||
@@ -50,7 +44,7 @@ bool VoiceController::BeginPushToTalk() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VoiceController::CompletePushToTalk(const std::vector<uint8_t>& wav_data) {
|
||||
bool VoiceController::CompletePushToTalk(const std::vector<uint8_t> &wav_data) {
|
||||
if (phase_ != VoicePhase::kRecording) {
|
||||
return false;
|
||||
}
|
||||
@@ -61,14 +55,12 @@ bool VoiceController::CompletePushToTalk(const std::vector<uint8_t>& wav_data) {
|
||||
phase_ = VoicePhase::kThinking;
|
||||
RenderState("je reflechis", "Envoi de la requete a Mascarade.", true);
|
||||
|
||||
last_response_ = backend_.SubmitVoiceSession(device_id_, media_.Snapshot(),
|
||||
wav_data);
|
||||
last_response_ = backend_.SubmitVoiceSession(device_id_, media_.Snapshot(), wav_data);
|
||||
yield();
|
||||
|
||||
if (!last_response_.ok) {
|
||||
const std::string error_text =
|
||||
last_response_.error.empty() ? "session vocale en echec"
|
||||
: last_response_.error;
|
||||
last_response_.error.empty() ? "session vocale en echec" : last_response_.error;
|
||||
return Fail(error_text);
|
||||
}
|
||||
|
||||
@@ -77,8 +69,7 @@ bool VoiceController::CompletePushToTalk(const std::vector<uint8_t>& wav_data) {
|
||||
const MediaSnapshot after = media_.Snapshot();
|
||||
|
||||
if (ShouldPublishPlaybackStarted(before, after, last_response_.intent)) {
|
||||
const std::string detail =
|
||||
!after.station.empty() ? after.station : after.track;
|
||||
const std::string detail = !after.station.empty() ? after.station : after.track;
|
||||
backend_.SendPlayerEvent(device_id_, "playback_started", after, detail);
|
||||
}
|
||||
|
||||
@@ -86,19 +77,16 @@ bool VoiceController::CompletePushToTalk(const std::vector<uint8_t>& wav_data) {
|
||||
|
||||
phase_ = VoicePhase::kSpeaking;
|
||||
RenderState("je reponds",
|
||||
last_response_.reply_text.empty()
|
||||
? "Reponse audio en preparation."
|
||||
: last_response_.reply_text,
|
||||
last_response_.reply_text.empty() ? "Reponse audio en preparation."
|
||||
: last_response_.reply_text,
|
||||
true);
|
||||
|
||||
if (!last_response_.reply_audio_url.empty()) {
|
||||
std::vector<uint8_t> reply_audio;
|
||||
if (backend_.DownloadReplyAudio(last_response_.reply_audio_url,
|
||||
&reply_audio)) {
|
||||
if (backend_.DownloadReplyAudio(last_response_.reply_audio_url, &reply_audio)) {
|
||||
yield();
|
||||
if (!media_.PlayReplyAudio(reply_audio)) {
|
||||
backend_.SendPlayerEvent(device_id_, "playback_failed",
|
||||
media_.Snapshot(),
|
||||
backend_.SendPlayerEvent(device_id_, "playback_failed", media_.Snapshot(),
|
||||
"tts reply playback failed");
|
||||
}
|
||||
} else {
|
||||
@@ -114,32 +102,30 @@ bool VoiceController::CompletePushToTalk(const std::vector<uint8_t>& wav_data) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VoiceController::Fail(const std::string& error_text) {
|
||||
bool VoiceController::Fail(const std::string &error_text) {
|
||||
phase_ = VoicePhase::kError;
|
||||
last_response_.ok = false;
|
||||
last_response_.error = error_text;
|
||||
RenderState("erreur reseau", error_text, false);
|
||||
backend_.SendPlayerEvent(device_id_, "voice_error", media_.Snapshot(),
|
||||
error_text);
|
||||
backend_.SendPlayerEvent(device_id_, "voice_error", media_.Snapshot(), error_text);
|
||||
media_.RestoreAfterReply(true);
|
||||
phase_ = VoicePhase::kIdle;
|
||||
RenderState("pret", IdleSummary(media_.Snapshot()), false);
|
||||
return false;
|
||||
}
|
||||
|
||||
void VoiceController::RenderState(const std::string& headline,
|
||||
const std::string& summary,
|
||||
void VoiceController::RenderState(const std::string &headline, const std::string &summary,
|
||||
bool show_ring) {
|
||||
ui_.Render(media_.Snapshot(), phase_, headline, summary, show_ring);
|
||||
}
|
||||
|
||||
std::string VoiceController::IdleSummary(const MediaSnapshot& media) {
|
||||
std::string VoiceController::IdleSummary(const MediaSnapshot &media) {
|
||||
return FwIdleSummary(media);
|
||||
}
|
||||
|
||||
bool VoiceController::ShouldPublishPlaybackStarted(
|
||||
const MediaSnapshot& before, const MediaSnapshot& after,
|
||||
const VoiceIntent& intent) {
|
||||
bool VoiceController::ShouldPublishPlaybackStarted(const MediaSnapshot &before,
|
||||
const MediaSnapshot &after,
|
||||
const VoiceIntent &intent) {
|
||||
return FwShouldPublishPlaybackStarted(before, after, intent);
|
||||
}
|
||||
|
||||
@@ -148,8 +134,7 @@ bool VoiceController::ShouldPublishPlaybackStarted(
|
||||
// We only need the constructor and simple accessors for testing.
|
||||
// ===========================================================================
|
||||
|
||||
OtaManager::OtaManager(const std::string& current_version)
|
||||
: current_version_(current_version) {}
|
||||
OtaManager::OtaManager(const std::string ¤t_version) : current_version_(current_version) {}
|
||||
|
||||
// Stubs for methods that depend on Arduino/HTTP — not called in our tests
|
||||
// but needed to satisfy the linker (non-pure-virtual members).
|
||||
@@ -159,8 +144,10 @@ OtaCheckResult OtaManager::CheckOnly() {
|
||||
|
||||
OtaCheckResult OtaManager::CheckAndUpdate() {
|
||||
OtaCheckResult info = FetchLatestInfo();
|
||||
if (info.status == OtaCheckResult::Status::kCheckFailed) return info;
|
||||
if (info.status == OtaCheckResult::Status::kUpToDate) return info;
|
||||
if (info.status == OtaCheckResult::Status::kCheckFailed)
|
||||
return info;
|
||||
if (info.status == OtaCheckResult::Status::kUpToDate)
|
||||
return info;
|
||||
if (FlashFromUrl(info.url)) {
|
||||
info.status = OtaCheckResult::Status::kFlashOk;
|
||||
} else {
|
||||
@@ -177,7 +164,7 @@ OtaCheckResult OtaManager::FetchLatestInfo() {
|
||||
return result;
|
||||
}
|
||||
|
||||
bool OtaManager::FlashFromUrl(const std::string& /*url*/) {
|
||||
bool OtaManager::FlashFromUrl(const std::string & /*url*/) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -186,7 +173,7 @@ bool OtaManager::FlashFromUrl(const std::string& /*url*/) {
|
||||
// ===========================================================================
|
||||
|
||||
class MockBackend : public BackendClient {
|
||||
public:
|
||||
public:
|
||||
VoiceSessionResponse next_voice_response;
|
||||
bool download_succeeds = true;
|
||||
std::vector<uint8_t> download_audio;
|
||||
@@ -197,26 +184,23 @@ class MockBackend : public BackendClient {
|
||||
int submit_voice_count = 0;
|
||||
int download_count = 0;
|
||||
|
||||
bool SendPlayerEvent(const std::string& /*device_id*/,
|
||||
const std::string& event_name,
|
||||
const MediaSnapshot& /*media*/,
|
||||
const std::string& detail) override {
|
||||
bool SendPlayerEvent(const std::string & /*device_id*/, const std::string &event_name,
|
||||
const MediaSnapshot & /*media*/, const std::string &detail) override {
|
||||
++send_event_count;
|
||||
last_event_name = event_name;
|
||||
last_event_detail = detail;
|
||||
return true;
|
||||
}
|
||||
|
||||
VoiceSessionResponse SubmitVoiceSession(
|
||||
const std::string& /*device_id*/,
|
||||
const MediaSnapshot& /*media*/,
|
||||
const std::vector<uint8_t>& /*wav_data*/) override {
|
||||
VoiceSessionResponse SubmitVoiceSession(const std::string & /*device_id*/,
|
||||
const MediaSnapshot & /*media*/,
|
||||
const std::vector<uint8_t> & /*wav_data*/) override {
|
||||
++submit_voice_count;
|
||||
return next_voice_response;
|
||||
}
|
||||
|
||||
bool DownloadReplyAudio(const std::string& /*audio_url*/,
|
||||
std::vector<uint8_t>* wav_data) override {
|
||||
bool DownloadReplyAudio(const std::string & /*audio_url*/,
|
||||
std::vector<uint8_t> *wav_data) override {
|
||||
++download_count;
|
||||
if (download_succeeds && wav_data) {
|
||||
*wav_data = download_audio;
|
||||
@@ -226,7 +210,7 @@ class MockBackend : public BackendClient {
|
||||
};
|
||||
|
||||
class MockMedia : public MediaController {
|
||||
public:
|
||||
public:
|
||||
MediaSnapshot snapshot;
|
||||
int apply_intent_count = 0;
|
||||
int prepare_count = 0;
|
||||
@@ -239,7 +223,7 @@ class MockMedia : public MediaController {
|
||||
|
||||
MediaSnapshot Snapshot() const override { return snapshot; }
|
||||
|
||||
void ApplyIntent(const VoiceIntent& intent) override {
|
||||
void ApplyIntent(const VoiceIntent &intent) override {
|
||||
++apply_intent_count;
|
||||
last_intent = intent;
|
||||
}
|
||||
@@ -254,25 +238,22 @@ class MockMedia : public MediaController {
|
||||
last_restore_resume = resume_media_after_tts;
|
||||
}
|
||||
|
||||
bool PlayReplyAudio(const std::vector<uint8_t>& /*wav_data*/) override {
|
||||
bool PlayReplyAudio(const std::vector<uint8_t> & /*wav_data*/) override {
|
||||
++play_reply_count;
|
||||
return play_reply_result;
|
||||
}
|
||||
};
|
||||
|
||||
class MockUi : public UiRenderer {
|
||||
public:
|
||||
public:
|
||||
int render_count = 0;
|
||||
VoicePhase last_phase = VoicePhase::kIdle;
|
||||
std::string last_headline;
|
||||
std::string last_summary;
|
||||
bool last_show_ring = false;
|
||||
|
||||
void Render(const MediaSnapshot& /*media*/,
|
||||
VoicePhase phase,
|
||||
const std::string& headline,
|
||||
const std::string& summary,
|
||||
bool show_ring) override {
|
||||
void Render(const MediaSnapshot & /*media*/, VoicePhase phase, const std::string &headline,
|
||||
const std::string &summary, bool show_ring) override {
|
||||
++render_count;
|
||||
last_phase = phase;
|
||||
last_headline = headline;
|
||||
@@ -297,8 +278,7 @@ void test_vc_initial_phase_is_idle() {
|
||||
MockMedia media;
|
||||
MockUi ui;
|
||||
VoiceController vc("dev-001", backend, media, ui);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
|
||||
}
|
||||
|
||||
void test_vc_boot_sends_event_and_stays_idle() {
|
||||
@@ -309,8 +289,7 @@ void test_vc_boot_sends_event_and_stays_idle() {
|
||||
|
||||
vc.Boot();
|
||||
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL_INT(1, backend.send_event_count);
|
||||
TEST_ASSERT_EQUAL_STRING("boot", backend.last_event_name.c_str());
|
||||
TEST_ASSERT_TRUE(ui.render_count > 0);
|
||||
@@ -326,8 +305,7 @@ void test_vc_begin_ptt_transitions_to_recording() {
|
||||
bool ok = vc.BeginPushToTalk();
|
||||
|
||||
TEST_ASSERT_TRUE(ok);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording), static_cast<int>(vc.phase()));
|
||||
}
|
||||
|
||||
void test_vc_begin_ptt_fails_when_not_idle() {
|
||||
@@ -341,8 +319,7 @@ void test_vc_begin_ptt_fails_when_not_idle() {
|
||||
bool ok = vc.BeginPushToTalk();
|
||||
|
||||
TEST_ASSERT_FALSE(ok);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording), static_cast<int>(vc.phase()));
|
||||
}
|
||||
|
||||
void test_vc_complete_ptt_fails_when_not_recording() {
|
||||
@@ -370,10 +347,8 @@ void test_vc_complete_ptt_empty_audio_fails() {
|
||||
bool ok = vc.CompletePushToTalk(empty);
|
||||
|
||||
TEST_ASSERT_FALSE(ok);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL_STRING("capture audio vide",
|
||||
vc.last_response().error.c_str());
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL_STRING("capture audio vide", vc.last_response().error.c_str());
|
||||
}
|
||||
|
||||
void test_vc_full_successful_flow_idle_to_idle() {
|
||||
@@ -391,14 +366,12 @@ void test_vc_full_successful_flow_idle_to_idle() {
|
||||
vc.Boot();
|
||||
|
||||
TEST_ASSERT_TRUE(vc.BeginPushToTalk());
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording), static_cast<int>(vc.phase()));
|
||||
|
||||
bool ok = vc.CompletePushToTalk(DummyWav());
|
||||
|
||||
TEST_ASSERT_TRUE(ok);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL_INT(1, backend.submit_voice_count);
|
||||
TEST_ASSERT_EQUAL_INT(1, media.apply_intent_count);
|
||||
TEST_ASSERT_EQUAL_INT(1, media.prepare_count);
|
||||
@@ -424,8 +397,7 @@ void test_vc_successful_flow_with_audio_download() {
|
||||
bool ok = vc.CompletePushToTalk(DummyWav());
|
||||
|
||||
TEST_ASSERT_TRUE(ok);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL_INT(1, backend.download_count);
|
||||
TEST_ASSERT_EQUAL_INT(1, media.play_reply_count);
|
||||
}
|
||||
@@ -445,10 +417,8 @@ void test_vc_backend_error_returns_to_idle() {
|
||||
bool ok = vc.CompletePushToTalk(DummyWav());
|
||||
|
||||
TEST_ASSERT_FALSE(ok);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL_STRING("server timeout",
|
||||
vc.last_response().error.c_str());
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL_STRING("server timeout", vc.last_response().error.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("voice_error", backend.last_event_name.c_str());
|
||||
}
|
||||
|
||||
@@ -465,8 +435,7 @@ void test_vc_backend_error_empty_message_uses_default() {
|
||||
vc.BeginPushToTalk();
|
||||
vc.CompletePushToTalk(DummyWav());
|
||||
|
||||
TEST_ASSERT_EQUAL_STRING("session vocale en echec",
|
||||
vc.last_response().error.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("session vocale en echec", vc.last_response().error.c_str());
|
||||
}
|
||||
|
||||
void test_vc_audio_download_failure_sends_event() {
|
||||
@@ -486,8 +455,7 @@ void test_vc_audio_download_failure_sends_event() {
|
||||
|
||||
TEST_ASSERT_TRUE(ok);
|
||||
TEST_ASSERT_EQUAL_STRING("playback_failed", backend.last_event_name.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("tts reply download failed",
|
||||
backend.last_event_detail.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("tts reply download failed", backend.last_event_detail.c_str());
|
||||
}
|
||||
|
||||
void test_vc_audio_playback_failure_sends_event() {
|
||||
@@ -509,8 +477,7 @@ void test_vc_audio_playback_failure_sends_event() {
|
||||
|
||||
TEST_ASSERT_TRUE(ok);
|
||||
TEST_ASSERT_EQUAL_STRING("playback_failed", backend.last_event_name.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("tts reply playback failed",
|
||||
backend.last_event_detail.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("tts reply playback failed", backend.last_event_detail.c_str());
|
||||
}
|
||||
|
||||
void test_vc_restore_called_with_resume_flag() {
|
||||
@@ -561,12 +528,10 @@ void test_vc_can_start_new_session_after_success() {
|
||||
|
||||
vc.BeginPushToTalk();
|
||||
vc.CompletePushToTalk(DummyWav());
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
|
||||
|
||||
TEST_ASSERT_TRUE(vc.BeginPushToTalk());
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording), static_cast<int>(vc.phase()));
|
||||
}
|
||||
|
||||
void test_vc_can_start_new_session_after_error() {
|
||||
@@ -582,8 +547,7 @@ void test_vc_can_start_new_session_after_error() {
|
||||
|
||||
vc.BeginPushToTalk();
|
||||
vc.CompletePushToTalk(DummyWav());
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle),
|
||||
static_cast<int>(vc.phase()));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(vc.phase()));
|
||||
|
||||
TEST_ASSERT_TRUE(vc.BeginPushToTalk());
|
||||
}
|
||||
@@ -594,21 +558,21 @@ void test_vc_can_start_new_session_after_error() {
|
||||
|
||||
void test_ota_version_equal_means_up_to_date() {
|
||||
std::string current = "1.2.3";
|
||||
std::string latest = "1.2.3";
|
||||
std::string latest = "1.2.3";
|
||||
int cmp = FwCompareVersions(current, latest);
|
||||
TEST_ASSERT_TRUE(cmp >= 0);
|
||||
}
|
||||
|
||||
void test_ota_version_newer_means_update_available() {
|
||||
std::string current = "1.0.0";
|
||||
std::string latest = "1.0.1";
|
||||
std::string latest = "1.0.1";
|
||||
int cmp = FwCompareVersions(current, latest);
|
||||
TEST_ASSERT_TRUE(cmp < 0);
|
||||
}
|
||||
|
||||
void test_ota_version_current_ahead_means_up_to_date() {
|
||||
std::string current = "2.0.0";
|
||||
std::string latest = "1.9.9";
|
||||
std::string latest = "1.9.9";
|
||||
int cmp = FwCompareVersions(current, latest);
|
||||
TEST_ASSERT_TRUE(cmp >= 0);
|
||||
}
|
||||
@@ -732,8 +696,7 @@ void test_vc_boot_renders_idle_state() {
|
||||
|
||||
TEST_ASSERT_EQUAL_STRING("pret", ui.last_headline.c_str());
|
||||
TEST_ASSERT_FALSE(ui.last_show_ring);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle),
|
||||
static_cast<int>(ui.last_phase));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kIdle), static_cast<int>(ui.last_phase));
|
||||
}
|
||||
|
||||
void test_vc_begin_ptt_renders_recording_state() {
|
||||
@@ -747,15 +710,14 @@ void test_vc_begin_ptt_renders_recording_state() {
|
||||
|
||||
TEST_ASSERT_EQUAL_STRING("j'ecoute", ui.last_headline.c_str());
|
||||
TEST_ASSERT_TRUE(ui.last_show_ring);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording),
|
||||
static_cast<int>(ui.last_phase));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(VoicePhase::kRecording), static_cast<int>(ui.last_phase));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int main(int, char**) {
|
||||
int main(int, char **) {
|
||||
UNITY_BEGIN();
|
||||
|
||||
// VoiceController state transitions
|
||||
|
||||
@@ -5,28 +5,30 @@
|
||||
/// No real Audio/I2S/LittleFS hardware used — audio_ pointer stays null;
|
||||
/// every method that calls audio_ is already guarded by null / initialized_ checks.
|
||||
|
||||
#include <unity.h>
|
||||
#include <algorithm>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <unity.h>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal Arduino stubs
|
||||
// ---------------------------------------------------------------------------
|
||||
inline void yield() {}
|
||||
inline void delay(unsigned long) {}
|
||||
inline unsigned long millis() { return 0; }
|
||||
template<typename T> inline T constrain(T v, T lo, T hi) {
|
||||
inline unsigned long millis() {
|
||||
return 0;
|
||||
}
|
||||
template <typename T> inline T constrain(T v, T lo, T hi) {
|
||||
return v < lo ? lo : (v > hi ? hi : v);
|
||||
}
|
||||
|
||||
struct _SerialStub {
|
||||
void printf(const char*, ...) {}
|
||||
void println(const char*) {}
|
||||
void print(const char*) {}
|
||||
void printf(const char *, ...) {}
|
||||
void println(const char *) {}
|
||||
void print(const char *) {}
|
||||
} Serial;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -35,28 +37,31 @@ struct _SerialStub {
|
||||
class Audio {
|
||||
public:
|
||||
int last_volume = 0;
|
||||
const char* last_fs_path = nullptr;
|
||||
const char *last_fs_path = nullptr;
|
||||
void setPinout(int, int, int) {}
|
||||
void setVolume(int v) { last_volume = v; }
|
||||
bool connecttohost(const char*) { return true; }
|
||||
bool connecttohost(const char *) { return true; }
|
||||
// connecttoFS used for both SD MP3 and LittleFS TTS
|
||||
template<typename FS>
|
||||
bool connecttoFS(FS&, const char* path) { last_fs_path = path; return true; }
|
||||
template <typename FS> bool connecttoFS(FS &, const char *path) {
|
||||
last_fs_path = path;
|
||||
return true;
|
||||
}
|
||||
void stopSong() {}
|
||||
bool isRunning() { return false; }
|
||||
void loop() {}
|
||||
};
|
||||
|
||||
// Minimal SD stub so radio_player.h compiles (SD not used in native tests).
|
||||
struct _SDStub {} SD;
|
||||
struct _SDStub {
|
||||
} SD;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure-C++ headers (no Arduino deps)
|
||||
// Open private members for white-box state testing.
|
||||
// ---------------------------------------------------------------------------
|
||||
#define private public
|
||||
#include "../../include/voice_controller.h"
|
||||
#include "../../include/radio_player.h"
|
||||
#include "../../include/voice_controller.h"
|
||||
#undef private
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,7 +71,9 @@ struct _SDStub {} SD;
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
RadioPlayer::RadioPlayer() {}
|
||||
RadioPlayer::~RadioPlayer() { delete audio_; }
|
||||
RadioPlayer::~RadioPlayer() {
|
||||
delete audio_;
|
||||
}
|
||||
|
||||
bool RadioPlayer::Begin(int bck, int ws, int dout) {
|
||||
audio_ = new Audio();
|
||||
@@ -77,7 +84,8 @@ bool RadioPlayer::Begin(int bck, int ws, int dout) {
|
||||
}
|
||||
|
||||
void RadioPlayer::Loop() {
|
||||
if (initialized_ && audio_) audio_->loop();
|
||||
if (initialized_ && audio_)
|
||||
audio_->loop();
|
||||
}
|
||||
|
||||
MediaSnapshot RadioPlayer::Snapshot() const {
|
||||
@@ -88,31 +96,37 @@ MediaSnapshot RadioPlayer::Snapshot() const {
|
||||
snap.wifi_ssid = wifi_ssid_;
|
||||
snap.wifi_rssi = wifi_rssi_;
|
||||
snap.battery_pct = battery_pct_;
|
||||
if (!stations_.empty() && current_station_ >= 0 &&
|
||||
current_station_ < (int)stations_.size()) {
|
||||
if (!stations_.empty() && current_station_ >= 0 && current_station_ < (int)stations_.size()) {
|
||||
snap.station = stations_[current_station_].first;
|
||||
}
|
||||
snap.track = current_title_;
|
||||
for (const auto& s : stations_) snap.available_stations.push_back(s.first);
|
||||
for (const auto &s : stations_)
|
||||
snap.available_stations.push_back(s.first);
|
||||
return snap;
|
||||
}
|
||||
|
||||
void RadioPlayer::ApplyIntent(const VoiceIntent& intent) {
|
||||
void RadioPlayer::ApplyIntent(const VoiceIntent &intent) {
|
||||
if (intent.type == "set_volume") {
|
||||
SetVolume(atoi(intent.value.c_str()));
|
||||
} else if (intent.type == "play") {
|
||||
if (!playing_) {
|
||||
if (mode_ == MediaMode::kMp3) StartCurrentMp3();
|
||||
else StartCurrentStation();
|
||||
if (mode_ == MediaMode::kMp3)
|
||||
StartCurrentMp3();
|
||||
else
|
||||
StartCurrentStation();
|
||||
}
|
||||
} else if (intent.type == "pause") {
|
||||
Stop();
|
||||
} else if (intent.type == "next") {
|
||||
if (mode_ == MediaMode::kMp3) NextMp3();
|
||||
else Next();
|
||||
if (mode_ == MediaMode::kMp3)
|
||||
NextMp3();
|
||||
else
|
||||
Next();
|
||||
} else if (intent.type == "previous") {
|
||||
if (mode_ == MediaMode::kMp3) PreviousMp3();
|
||||
else Previous();
|
||||
if (mode_ == MediaMode::kMp3)
|
||||
PreviousMp3();
|
||||
else
|
||||
Previous();
|
||||
} else if (intent.type == "next_mp3") {
|
||||
NextMp3();
|
||||
} else if (intent.type == "previous_mp3") {
|
||||
@@ -135,104 +149,125 @@ void RadioPlayer::PrepareForReply(PlayerAction action) {
|
||||
was_playing_ = playing_;
|
||||
saved_volume_ = volume_;
|
||||
if (action == PlayerAction::kDuck) {
|
||||
if (audio_) audio_->setVolume((volume_ / 2) * 21 / 100);
|
||||
if (audio_)
|
||||
audio_->setVolume((volume_ / 2) * 21 / 100);
|
||||
} else if (action == PlayerAction::kStopResume) {
|
||||
if (playing_ && audio_) { audio_->stopSong(); playing_ = false; }
|
||||
if (playing_ && audio_) {
|
||||
audio_->stopSong();
|
||||
playing_ = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RadioPlayer::RestoreAfterReply(bool resume) {
|
||||
if (saved_volume_ >= 0) {
|
||||
if (audio_) audio_->setVolume(saved_volume_ * 21 / 100);
|
||||
if (audio_)
|
||||
audio_->setVolume(saved_volume_ * 21 / 100);
|
||||
saved_volume_ = -1;
|
||||
}
|
||||
if (resume && was_playing_ && !playing_) StartCurrentStation();
|
||||
if (resume && was_playing_ && !playing_)
|
||||
StartCurrentStation();
|
||||
was_playing_ = false;
|
||||
}
|
||||
|
||||
bool RadioPlayer::PlayReplyAudio(const std::vector<uint8_t>&) {
|
||||
return false; // stub — not tested
|
||||
bool RadioPlayer::PlayReplyAudio(const std::vector<uint8_t> &) {
|
||||
return false; // stub — not tested
|
||||
}
|
||||
|
||||
void RadioPlayer::SetStations(
|
||||
const std::vector<std::pair<std::string, std::string>>& list) {
|
||||
void RadioPlayer::SetStations(const std::vector<std::pair<std::string, std::string>> &list) {
|
||||
stations_ = list;
|
||||
if (current_station_ >= (int)stations_.size()) current_station_ = 0;
|
||||
if (current_station_ >= (int)stations_.size())
|
||||
current_station_ = 0;
|
||||
}
|
||||
|
||||
void RadioPlayer::PlayStation(int index) {
|
||||
if (stations_.empty()) return;
|
||||
if (stations_.empty())
|
||||
return;
|
||||
current_station_ = index % (int)stations_.size();
|
||||
StartCurrentStation();
|
||||
}
|
||||
|
||||
void RadioPlayer::PlayStation(const std::string& name) {
|
||||
void RadioPlayer::PlayStation(const std::string &name) {
|
||||
for (int i = 0; i < (int)stations_.size(); i++) {
|
||||
if (stations_[i].first == name) { PlayStation(i); return; }
|
||||
if (stations_[i].first == name) {
|
||||
PlayStation(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RadioPlayer::Next() {
|
||||
if (stations_.empty()) return;
|
||||
if (stations_.empty())
|
||||
return;
|
||||
current_station_ = (current_station_ + 1) % (int)stations_.size();
|
||||
StartCurrentStation();
|
||||
}
|
||||
|
||||
void RadioPlayer::Previous() {
|
||||
if (stations_.empty()) return;
|
||||
current_station_ = (current_station_ - 1 + (int)stations_.size()) %
|
||||
(int)stations_.size();
|
||||
if (stations_.empty())
|
||||
return;
|
||||
current_station_ = (current_station_ - 1 + (int)stations_.size()) % (int)stations_.size();
|
||||
StartCurrentStation();
|
||||
}
|
||||
|
||||
void RadioPlayer::Stop() {
|
||||
if (audio_) audio_->stopSong();
|
||||
if (audio_)
|
||||
audio_->stopSong();
|
||||
playing_ = false;
|
||||
}
|
||||
|
||||
void RadioPlayer::SetVolume(int vol) {
|
||||
volume_ = constrain(vol, 0, 100);
|
||||
if (audio_) audio_->setVolume(volume_ * 21 / 100);
|
||||
if (audio_)
|
||||
audio_->setVolume(volume_ * 21 / 100);
|
||||
}
|
||||
|
||||
bool RadioPlayer::IsPlaying() const { return playing_; }
|
||||
bool RadioPlayer::IsPlaying() const {
|
||||
return playing_;
|
||||
}
|
||||
|
||||
void RadioPlayer::SetMp3Files(const std::vector<std::string>& files) {
|
||||
void RadioPlayer::SetMp3Files(const std::vector<std::string> &files) {
|
||||
mp3_files_ = files;
|
||||
if (current_mp3_ >= (int)mp3_files_.size()) current_mp3_ = 0;
|
||||
if (current_mp3_ >= (int)mp3_files_.size())
|
||||
current_mp3_ = 0;
|
||||
}
|
||||
|
||||
void RadioPlayer::NextMp3() {
|
||||
if (mp3_files_.empty()) return;
|
||||
if (mp3_files_.empty())
|
||||
return;
|
||||
current_mp3_ = (current_mp3_ + 1) % (int)mp3_files_.size();
|
||||
StartCurrentMp3();
|
||||
}
|
||||
|
||||
void RadioPlayer::PreviousMp3() {
|
||||
if (mp3_files_.empty()) return;
|
||||
current_mp3_ = (current_mp3_ - 1 + (int)mp3_files_.size()) %
|
||||
(int)mp3_files_.size();
|
||||
if (mp3_files_.empty())
|
||||
return;
|
||||
current_mp3_ = (current_mp3_ - 1 + (int)mp3_files_.size()) % (int)mp3_files_.size();
|
||||
StartCurrentMp3();
|
||||
}
|
||||
|
||||
void RadioPlayer::StartCurrentMp3() {
|
||||
if (!initialized_ || !audio_ || mp3_files_.empty()) return;
|
||||
const std::string& path = mp3_files_[current_mp3_];
|
||||
if (!initialized_ || !audio_ || mp3_files_.empty())
|
||||
return;
|
||||
const std::string &path = mp3_files_[current_mp3_];
|
||||
audio_->stopSong();
|
||||
playing_ = audio_->connecttoFS(SD, path.c_str());
|
||||
}
|
||||
|
||||
void RadioPlayer::StartCurrentStation() {
|
||||
if (!initialized_ || !audio_ || stations_.empty()) return;
|
||||
const auto& [name, url] = stations_[current_station_];
|
||||
if (!initialized_ || !audio_ || stations_.empty())
|
||||
return;
|
||||
const auto &[name, url] = stations_[current_station_];
|
||||
audio_->stopSong();
|
||||
playing_ = audio_->connecttohost(url.c_str());
|
||||
}
|
||||
|
||||
void RadioPlayer::OnInfo(const char*) {}
|
||||
void RadioPlayer::OnTitle(const char* title) { current_title_ = title ? title : ""; }
|
||||
void RadioPlayer::OnInfo(const char *) {}
|
||||
void RadioPlayer::OnTitle(const char *title) {
|
||||
current_title_ = title ? title : "";
|
||||
}
|
||||
|
||||
void updatePlayerWifi(RadioPlayer& p, const std::string& ssid, int rssi) {
|
||||
void updatePlayerWifi(RadioPlayer &p, const std::string &ssid, int rssi) {
|
||||
p.wifi_ssid_ = ssid;
|
||||
p.wifi_rssi_ = rssi;
|
||||
}
|
||||
@@ -241,8 +276,9 @@ void updatePlayerWifi(RadioPlayer& p, const std::string& ssid, int rssi) {
|
||||
// Helpers
|
||||
// ===========================================================================
|
||||
|
||||
static std::vector<std::pair<std::string,std::string>> three_stations() {
|
||||
return {{"FIP", "http://fip.fr/stream"}, {"NovaPlanet", "http://nova.fr/stream"},
|
||||
static std::vector<std::pair<std::string, std::string>> three_stations() {
|
||||
return {{"FIP", "http://fip.fr/stream"},
|
||||
{"NovaPlanet", "http://nova.fr/stream"},
|
||||
{"France Inter", "http://inter.fr/stream"}};
|
||||
}
|
||||
|
||||
@@ -260,7 +296,7 @@ void test_snapshot_defaults() {
|
||||
auto s = p.Snapshot();
|
||||
TEST_ASSERT_EQUAL(MediaMode::kRadio, s.mode);
|
||||
TEST_ASSERT_FALSE(s.playing);
|
||||
TEST_ASSERT_EQUAL_INT(40, s.volume); // default volume
|
||||
TEST_ASSERT_EQUAL_INT(40, s.volume); // default volume
|
||||
TEST_ASSERT_TRUE(s.station.empty());
|
||||
TEST_ASSERT_EQUAL_INT(-1, s.battery_pct);
|
||||
}
|
||||
@@ -301,7 +337,7 @@ void test_playstation_int_sets_index() {
|
||||
void test_playstation_int_wraps() {
|
||||
RadioPlayer p;
|
||||
p.SetStations(three_stations());
|
||||
p.PlayStation(5); // 5 % 3 == 2
|
||||
p.PlayStation(5); // 5 % 3 == 2
|
||||
TEST_ASSERT_EQUAL_STRING("France Inter", p.Snapshot().station.c_str());
|
||||
}
|
||||
|
||||
@@ -322,15 +358,15 @@ void test_playstation_unknown_name_no_change() {
|
||||
void test_next_advances() {
|
||||
RadioPlayer p;
|
||||
p.SetStations(three_stations());
|
||||
p.Next(); // 0 → 1
|
||||
p.Next(); // 0 → 1
|
||||
TEST_ASSERT_EQUAL_STRING("NovaPlanet", p.Snapshot().station.c_str());
|
||||
}
|
||||
|
||||
void test_next_wraps_at_end() {
|
||||
RadioPlayer p;
|
||||
p.SetStations(three_stations());
|
||||
p.PlayStation(2); // France Inter (last)
|
||||
p.Next(); // wraps → 0 (FIP)
|
||||
p.PlayStation(2); // France Inter (last)
|
||||
p.Next(); // wraps → 0 (FIP)
|
||||
TEST_ASSERT_EQUAL_STRING("FIP", p.Snapshot().station.c_str());
|
||||
}
|
||||
|
||||
@@ -375,7 +411,7 @@ void test_intent_set_volume() {
|
||||
|
||||
void test_intent_pause_clears_playing() {
|
||||
RadioPlayer p;
|
||||
p.playing_ = true; // simulate playing state
|
||||
p.playing_ = true; // simulate playing state
|
||||
VoiceIntent intent;
|
||||
intent.type = "pause";
|
||||
p.ApplyIntent(intent);
|
||||
@@ -431,8 +467,8 @@ void test_prepare_saves_state() {
|
||||
// saved_volume_ = 80, was_playing_ = true
|
||||
// After restore with resume=false, saved_volume_ reset
|
||||
p.RestoreAfterReply(false);
|
||||
TEST_ASSERT_EQUAL_INT(80, p.Snapshot().volume); // volume restored
|
||||
TEST_ASSERT_TRUE(p.IsPlaying()); // kDuck never stops playback
|
||||
TEST_ASSERT_EQUAL_INT(80, p.Snapshot().volume); // volume restored
|
||||
TEST_ASSERT_TRUE(p.IsPlaying()); // kDuck never stops playback
|
||||
}
|
||||
|
||||
void test_prepare_stop_resume_action() {
|
||||
@@ -441,11 +477,11 @@ void test_prepare_stop_resume_action() {
|
||||
// (guard: if (playing_ && audio_) — audio is null, so no-op)
|
||||
p.playing_ = true;
|
||||
p.PrepareForReply(PlayerAction::kStopResume);
|
||||
TEST_ASSERT_TRUE(p.IsPlaying()); // audio_ is null → no change
|
||||
TEST_ASSERT_TRUE(p.IsPlaying()); // audio_ is null → no change
|
||||
// resume=true, was_playing_=true, but !playing_=false → StartCurrentStation not called.
|
||||
// playing_ never stopped (no audio_) → remains true.
|
||||
p.RestoreAfterReply(true);
|
||||
TEST_ASSERT_TRUE(p.IsPlaying()); // never stopped; was_playing_ reset internally
|
||||
TEST_ASSERT_TRUE(p.IsPlaying()); // never stopped; was_playing_ reset internally
|
||||
}
|
||||
|
||||
void test_restore_resets_saved_volume() {
|
||||
@@ -512,14 +548,16 @@ void test_nextmp3_advances() {
|
||||
void test_nextmp3_wraps() {
|
||||
RadioPlayer p;
|
||||
p.SetMp3Files(three_mp3s());
|
||||
p.NextMp3(); p.NextMp3(); p.NextMp3(); // 0→1→2→0
|
||||
p.NextMp3();
|
||||
p.NextMp3();
|
||||
p.NextMp3(); // 0→1→2→0
|
||||
TEST_ASSERT_EQUAL_INT(0, p.CurrentMp3Index());
|
||||
}
|
||||
|
||||
void test_previousmp3_wraps_at_start() {
|
||||
RadioPlayer p;
|
||||
p.SetMp3Files(three_mp3s());
|
||||
p.PreviousMp3(); // (0-1+3)%3 == 2
|
||||
p.PreviousMp3(); // (0-1+3)%3 == 2
|
||||
TEST_ASSERT_EQUAL_INT(2, p.CurrentMp3Index());
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
/// No WiFi/Arduino/Preferences hardware used — only the inline header methods
|
||||
/// and the two pure state methods inlined below.
|
||||
|
||||
#include <unity.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unity.h>
|
||||
#include <vector>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Include the pure-C++ header (no Arduino deps)
|
||||
@@ -25,15 +25,15 @@
|
||||
// apPassword(), backendUrl() are already inline in the header.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void WifiManager::SetApCredentials(const std::string& ssid,
|
||||
const std::string& password) {
|
||||
void WifiManager::SetApCredentials(const std::string &ssid, const std::string &password) {
|
||||
ap_ssid_ = ssid;
|
||||
ap_password_ = password;
|
||||
}
|
||||
|
||||
void WifiManager::SetState(State s, const std::string& info) {
|
||||
void WifiManager::SetState(State s, const std::string &info) {
|
||||
state_ = s;
|
||||
if (on_state_change_) on_state_change_(s, info);
|
||||
if (on_state_change_)
|
||||
on_state_change_(s, info);
|
||||
}
|
||||
|
||||
// Hardware-dependent methods — not called in these tests; provide empty stubs
|
||||
@@ -41,15 +41,23 @@ void WifiManager::SetState(State s, const std::string& info) {
|
||||
void WifiManager::Begin() {}
|
||||
void WifiManager::Loop() {}
|
||||
void WifiManager::StartApMode() {}
|
||||
std::string WifiManager::ip() const { return ""; }
|
||||
int WifiManager::rssi() const { return 0; }
|
||||
std::string WifiManager::apIp() const { return ""; }
|
||||
std::vector<WifiManager::ScanResult> WifiManager::Scan() { return {}; }
|
||||
std::string WifiManager::ip() const {
|
||||
return "";
|
||||
}
|
||||
int WifiManager::rssi() const {
|
||||
return 0;
|
||||
}
|
||||
std::string WifiManager::apIp() const {
|
||||
return "";
|
||||
}
|
||||
std::vector<WifiManager::ScanResult> WifiManager::Scan() {
|
||||
return {};
|
||||
}
|
||||
void WifiManager::LoadCredentials() {}
|
||||
void WifiManager::SaveCredentials(const std::string&, const std::string&,
|
||||
const std::string&) {}
|
||||
bool WifiManager::TryConnect(const std::string&, const std::string&,
|
||||
uint32_t) { return false; }
|
||||
void WifiManager::SaveCredentials(const std::string &, const std::string &, const std::string &) {}
|
||||
bool WifiManager::TryConnect(const std::string &, const std::string &, uint32_t) {
|
||||
return false;
|
||||
}
|
||||
void WifiManager::SetupApWebServer() {}
|
||||
void WifiManager::StopApWebServer() {}
|
||||
|
||||
@@ -119,7 +127,7 @@ void test_callback_fires_on_state_change() {
|
||||
WifiManager::State last_state = WifiManager::State::kIdle;
|
||||
std::string last_info;
|
||||
|
||||
mgr.SetOnStateChange([&](WifiManager::State s, const std::string& info) {
|
||||
mgr.SetOnStateChange([&](WifiManager::State s, const std::string &info) {
|
||||
++call_count;
|
||||
last_state = s;
|
||||
last_info = info;
|
||||
@@ -164,7 +172,7 @@ void test_callback_not_called_without_registration() {
|
||||
void test_multiple_state_transitions() {
|
||||
WifiManager mgr;
|
||||
int count = 0;
|
||||
mgr.SetOnStateChange([&](WifiManager::State, const std::string&) { ++count; });
|
||||
mgr.SetOnStateChange([&](WifiManager::State, const std::string &) { ++count; });
|
||||
|
||||
mgr.SetState(WifiManager::State::kConnecting, "");
|
||||
mgr.SetState(WifiManager::State::kFailed, "");
|
||||
|
||||
Reference in New Issue
Block a user