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:
kxkm
2026-03-27 05:46:03 +01:00
co-authored by Claude Sonnet 4.6
parent d7aad0665a
commit defc5eba2d
22 changed files with 866 additions and 804 deletions
+62 -51
View File
@@ -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;
}
+10 -15
View File
@@ -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_;
};
+5 -6
View File
@@ -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;
+9 -13
View File
@@ -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;
+11 -11
View File
@@ -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 &current_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 &current_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";
+27 -39
View File
@@ -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_;
};
+10 -11
View File
@@ -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_;
+5 -5
View File
@@ -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
View File
@@ -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
};
+81 -59
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+3 -1
View File
@@ -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
+11 -10
View File
@@ -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 &current_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;
}
+24 -37
View File
@@ -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);
}
+32 -33
View File
@@ -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()) {
+9 -8
View File
@@ -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
View File
@@ -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) + "}";
}
+20 -16
View File
@@ -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
+64 -102
View File
@@ -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 &current_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, "");