Add RP2040 TFT touch UI firmware and ESP32 UI serial protocol
This commit is contained in:
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "hardware/firmware/esp32/**"
|
||||
- "hardware/firmware/ui/**"
|
||||
- ".github/workflows/firmware-story-v2.yml"
|
||||
- ".github/PULL_REQUEST_TEMPLATE.md"
|
||||
push:
|
||||
@@ -12,6 +13,7 @@ on:
|
||||
- codex/esp32-audio-mozzi-20260213
|
||||
paths:
|
||||
- "hardware/firmware/esp32/**"
|
||||
- "hardware/firmware/ui/**"
|
||||
- ".github/workflows/firmware-story-v2.yml"
|
||||
- ".github/PULL_REQUEST_TEMPLATE.md"
|
||||
workflow_dispatch:
|
||||
@@ -155,3 +157,49 @@ jobs:
|
||||
name: build-nodemcuv2-log
|
||||
path: hardware/firmware/esp32/reports/ci/build_nodemcuv2.log
|
||||
if-no-files-found: error
|
||||
|
||||
build-ui-rp2040:
|
||||
name: build ${{ matrix.env }} (rp2040 ui)
|
||||
runs-on: ubuntu-latest
|
||||
needs: story-toolchain
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
env: [rp2040_ili9488, rp2040_ili9486]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: hardware/firmware/ui/rp2040_tft35_xpt2046
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache PlatformIO
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.platformio/.cache
|
||||
~/.platformio/packages
|
||||
~/.platformio/platforms
|
||||
key: pio-${{ runner.os }}-${{ hashFiles('hardware/firmware/ui/rp2040_tft35_xpt2046/platformio.ini') }}
|
||||
restore-keys: |
|
||||
pio-${{ runner.os }}-
|
||||
|
||||
- name: Install PlatformIO
|
||||
run: python -m pip install --upgrade pip platformio
|
||||
|
||||
- name: Build RP2040 UI
|
||||
run: |
|
||||
mkdir -p ../../esp32/reports/ci
|
||||
pio run -e "${{ matrix.env }}" 2>&1 | tee "../../esp32/reports/ci/build_${{ matrix.env }}.log"
|
||||
|
||||
- name: Upload RP2040 build log
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ matrix.env }}-log
|
||||
path: hardware/firmware/esp32/reports/ci/build_${{ matrix.env }}.log
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -32,6 +32,7 @@ Dossier : `hardware/`
|
||||
- BOM (liste de matériel)
|
||||
- câblage
|
||||
- firmware Arduino / ESP32
|
||||
- option UI tactile RP2040 + écran 3.5": `hardware/firmware/ui/rp2040_tft35_xpt2046/`
|
||||
|
||||
## État du projet
|
||||
- **Kit MJ** : documentation d'animation complète, exports PDF possibles via `kit-maitre-du-jeu/export/pdf/`.
|
||||
|
||||
@@ -6,6 +6,7 @@ Contenu :
|
||||
- BOM : `bom/bom.md`
|
||||
- Câblage : `wiring/wiring.md`
|
||||
- Firmware : `firmware/arduino/` et `firmware/esp32/`
|
||||
- UI tactile optionnelle: `firmware/ui/rp2040_tft35_xpt2046/`
|
||||
- Boîtier : `enclosure/`
|
||||
|
||||
Licence :
|
||||
|
||||
@@ -420,3 +420,13 @@ Reglage live (sans reflash):
|
||||
Contraintes:
|
||||
|
||||
- ordre strict obligatoire: `K1 < K2 < K3 < K4 < K5 < K6 < REL`
|
||||
|
||||
## Option UI tactile RP2040 (additive)
|
||||
|
||||
- Firmware UI optionnel: `../ui/rp2040_tft35_xpt2046/`
|
||||
- Module serie UI cote ESP32 (off par defaut): `src/services/ui_serial/`
|
||||
- Flags build:
|
||||
- `UI_SERIAL_ENABLED=1`
|
||||
- `UI_SERIAL_BAUD=115200`
|
||||
- `UI_SERIAL_RX_PIN=18`
|
||||
- `UI_SERIAL_TX_PIN=19`
|
||||
|
||||
@@ -29,6 +29,10 @@ lib_deps =
|
||||
build_flags =
|
||||
-DCORE_DEBUG_LEVEL=1
|
||||
-DUSON_STORY_V2_DEFAULT=1
|
||||
-DUI_SERIAL_ENABLED=0
|
||||
-DUI_SERIAL_BAUD=115200
|
||||
-DUI_SERIAL_RX_PIN=18
|
||||
-DUI_SERIAL_TX_PIN=19
|
||||
|
||||
[env:esp32_release]
|
||||
extends = env:esp32dev
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#include "../services/network/wifi_service.h"
|
||||
#include "../services/radio/radio_service.h"
|
||||
#include "../services/web/web_ui_service.h"
|
||||
#include "../services/ui_serial/ui_serial.h"
|
||||
#include "../services/screen/screen_sync_service.h"
|
||||
#include "../story/resources/screen_scene_registry.h"
|
||||
#include "../story/story_engine.h"
|
||||
@@ -114,6 +115,32 @@ struct NetRuntimeConfig {
|
||||
|
||||
NetRuntimeConfig g_netConfig;
|
||||
|
||||
struct UiSerialStateCache {
|
||||
bool playing = false;
|
||||
char source[8] = "sd";
|
||||
char title[96] = {};
|
||||
char artist[64] = {};
|
||||
char station[64] = {};
|
||||
int32_t pos = 0;
|
||||
int32_t dur = 0;
|
||||
int32_t vol = 0;
|
||||
int32_t rssi = -127;
|
||||
int32_t buffer = -1;
|
||||
char error[64] = {};
|
||||
};
|
||||
|
||||
struct UiSerialRuntimeState {
|
||||
bool initialized = false;
|
||||
bool forceState = true;
|
||||
uint32_t nextTickMs = 0U;
|
||||
uint32_t nextHbMs = 0U;
|
||||
uint32_t nextStateMs = 0U;
|
||||
uint32_t nextListMs = 0U;
|
||||
UiSerialStateCache lastState = {};
|
||||
};
|
||||
|
||||
UiSerialRuntimeState g_uiSerialRuntime;
|
||||
|
||||
void setBootAudioPaEnabled(bool enabled, const char* source);
|
||||
void printBootAudioOutputInfo(const char* source);
|
||||
void printLittleFsInfo(const char* source);
|
||||
@@ -196,6 +223,9 @@ bool commandMatches(const char* cmd, const char* token);
|
||||
void printRadioDebugHelp();
|
||||
bool loadNetRuntimeConfigFromLittleFs(NetRuntimeConfig* outCfg);
|
||||
void setupRadioWifiWebRuntime();
|
||||
void setupUiSerialRuntime();
|
||||
void updateUiSerialRuntime(uint32_t nowMs);
|
||||
bool onUiSerialCommand(const UiSerialCommand& command, void* ctx);
|
||||
|
||||
InputService& inputService() {
|
||||
static InputService service(g_keypad);
|
||||
@@ -559,6 +589,333 @@ uint8_t encodeMp3ErrorForScreen() {
|
||||
return 99U;
|
||||
}
|
||||
|
||||
void copyUiSerialText(char* out, size_t outLen, const char* in) {
|
||||
if (out == nullptr || outLen == 0U) {
|
||||
return;
|
||||
}
|
||||
out[0] = '\0';
|
||||
if (in == nullptr) {
|
||||
return;
|
||||
}
|
||||
snprintf(out, outLen, "%s", in);
|
||||
}
|
||||
|
||||
bool uiSerialCacheEquals(const UiSerialStateCache& lhs, const UiSerialStateCache& rhs) {
|
||||
return lhs.playing == rhs.playing &&
|
||||
lhs.pos == rhs.pos &&
|
||||
lhs.dur == rhs.dur &&
|
||||
lhs.vol == rhs.vol &&
|
||||
lhs.rssi == rhs.rssi &&
|
||||
lhs.buffer == rhs.buffer &&
|
||||
strcmp(lhs.source, rhs.source) == 0 &&
|
||||
strcmp(lhs.title, rhs.title) == 0 &&
|
||||
strcmp(lhs.artist, rhs.artist) == 0 &&
|
||||
strcmp(lhs.station, rhs.station) == 0 &&
|
||||
strcmp(lhs.error, rhs.error) == 0;
|
||||
}
|
||||
|
||||
void buildUiSerialStateCache(UiSerialStateCache* out) {
|
||||
if (out == nullptr) {
|
||||
return;
|
||||
}
|
||||
*out = UiSerialStateCache();
|
||||
|
||||
const RadioService::Snapshot radioSnap = g_radio.snapshot();
|
||||
const WifiService::Snapshot wifiSnap = g_wifi.snapshot();
|
||||
const bool radioSource =
|
||||
(g_playerUi.source() == PlayerUiSource::kRadio) || radioSnap.active;
|
||||
|
||||
out->playing = radioSource ? radioSnap.active : g_mp3.isPlaying();
|
||||
copyUiSerialText(out->source, sizeof(out->source), radioSource ? "radio" : "sd");
|
||||
out->vol = static_cast<int32_t>(g_mp3.volumePercent());
|
||||
out->rssi = static_cast<int32_t>(wifiSnap.rssi);
|
||||
out->buffer = radioSource ? static_cast<int32_t>(radioSnap.bufferPercent) : -1;
|
||||
|
||||
if (radioSource) {
|
||||
copyUiSerialText(out->title, sizeof(out->title), radioSnap.title);
|
||||
copyUiSerialText(out->artist, sizeof(out->artist), "");
|
||||
copyUiSerialText(out->station, sizeof(out->station), radioSnap.activeStationName);
|
||||
out->pos = 0;
|
||||
out->dur = 0;
|
||||
if (radioSnap.lastError[0] != '\0' && strcmp(radioSnap.lastError, "OK") != 0) {
|
||||
copyUiSerialText(out->error, sizeof(out->error), radioSnap.lastError);
|
||||
} else {
|
||||
copyUiSerialText(out->error, sizeof(out->error), "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const uint16_t trackNum = g_mp3.currentTrackNumber();
|
||||
const TrackEntry* entry = g_mp3.trackEntryByNumber(trackNum);
|
||||
copyUiSerialText(out->title, sizeof(out->title), g_mp3.currentTrackName().c_str());
|
||||
copyUiSerialText(out->artist, sizeof(out->artist), (entry != nullptr) ? entry->artist : "");
|
||||
copyUiSerialText(out->station, sizeof(out->station), "");
|
||||
out->pos = 0;
|
||||
out->dur = (entry != nullptr) ? static_cast<int32_t>(entry->durationMs / 1000U) : 0;
|
||||
const char* mp3Error = g_mp3.lastBackendError();
|
||||
if (mp3Error != nullptr && strcmp(mp3Error, "OK") != 0) {
|
||||
copyUiSerialText(out->error, sizeof(out->error), mp3Error);
|
||||
}
|
||||
}
|
||||
|
||||
void publishUiSerialStateCache(const UiSerialStateCache& cache) {
|
||||
UiSerialState out;
|
||||
out.playing = cache.playing;
|
||||
out.source = cache.source;
|
||||
out.title = cache.title;
|
||||
out.artist = cache.artist;
|
||||
out.station = cache.station;
|
||||
out.pos = cache.pos;
|
||||
out.dur = cache.dur;
|
||||
out.vol = cache.vol;
|
||||
out.rssi = cache.rssi;
|
||||
out.buffer = cache.buffer;
|
||||
out.error = cache.error;
|
||||
uiSerialPublishState(out);
|
||||
}
|
||||
|
||||
void publishUiSerialList() {
|
||||
UiSerialList out;
|
||||
static char items[8][48];
|
||||
memset(items, 0, sizeof(items));
|
||||
const PlayerUiSnapshot uiSnap = g_playerUi.snapshot();
|
||||
const bool radioSource = (uiSnap.source == PlayerUiSource::kRadio);
|
||||
out.source = radioSource ? "radio" : "sd";
|
||||
|
||||
if (radioSource) {
|
||||
const uint16_t total = g_radio.stationCount();
|
||||
out.total = total;
|
||||
uint16_t cursor = uiSnap.cursor;
|
||||
if (cursor >= total && total > 0U) {
|
||||
cursor = total - 1U;
|
||||
}
|
||||
out.cursor = cursor;
|
||||
out.offset = (cursor > 1U) ? static_cast<uint16_t>(cursor - 1U) : 0U;
|
||||
const uint16_t maxCount = 4U;
|
||||
uint8_t idx = 0U;
|
||||
for (uint16_t i = out.offset; i < total && idx < maxCount; ++i, ++idx) {
|
||||
const StationRepository::Station* station = g_radio.stationAt(i);
|
||||
if (station == nullptr) {
|
||||
continue;
|
||||
}
|
||||
snprintf(items[idx], sizeof(items[idx]), "%s", station->name);
|
||||
out.items[idx] = items[idx];
|
||||
}
|
||||
out.count = idx;
|
||||
uiSerialPublishList(out);
|
||||
return;
|
||||
}
|
||||
|
||||
const uint16_t total = g_mp3.trackCount();
|
||||
out.total = total;
|
||||
uint16_t cursor = g_mp3.currentTrackNumber();
|
||||
if (cursor > 0U) {
|
||||
cursor = static_cast<uint16_t>(cursor - 1U);
|
||||
}
|
||||
if (cursor >= total && total > 0U) {
|
||||
cursor = total - 1U;
|
||||
}
|
||||
out.cursor = cursor;
|
||||
out.offset = (cursor > 1U) ? static_cast<uint16_t>(cursor - 1U) : 0U;
|
||||
const uint16_t maxCount = 4U;
|
||||
uint8_t idx = 0U;
|
||||
for (uint16_t i = out.offset; i < total && idx < maxCount; ++i, ++idx) {
|
||||
const TrackEntry* entry = g_mp3.trackEntryByNumber(static_cast<uint16_t>(i + 1U));
|
||||
if (entry == nullptr) {
|
||||
continue;
|
||||
}
|
||||
if (entry->title[0] != '\0') {
|
||||
snprintf(items[idx], sizeof(items[idx]), "%s", entry->title);
|
||||
} else {
|
||||
snprintf(items[idx], sizeof(items[idx]), "%s", entry->path);
|
||||
}
|
||||
out.items[idx] = items[idx];
|
||||
}
|
||||
out.count = idx;
|
||||
uiSerialPublishList(out);
|
||||
}
|
||||
|
||||
bool onUiSerialCommand(const UiSerialCommand& command, void* ctx) {
|
||||
(void)ctx;
|
||||
const PlayerUiSource currentSource = g_playerUi.source();
|
||||
const bool useRadio = (currentSource == PlayerUiSource::kRadio) || g_radio.snapshot().active;
|
||||
switch (command.action) {
|
||||
case UiSerialAction::kPlayPause:
|
||||
if (useRadio) {
|
||||
const RadioService::Snapshot snap = g_radio.snapshot();
|
||||
if (snap.active) {
|
||||
g_radio.stop("ui_serial_play_pause");
|
||||
} else {
|
||||
const uint16_t cursor = g_playerUi.cursor();
|
||||
const StationRepository::Station* station = g_radio.stationAt(cursor);
|
||||
if (station != nullptr) {
|
||||
g_radio.playById(station->id, "ui_serial_play_pause");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
g_mp3.togglePause();
|
||||
}
|
||||
g_uiSerialRuntime.forceState = true;
|
||||
return true;
|
||||
|
||||
case UiSerialAction::kNext:
|
||||
if (useRadio) {
|
||||
g_radio.next("ui_serial_next");
|
||||
} else {
|
||||
g_mp3.nextTrack();
|
||||
}
|
||||
g_uiSerialRuntime.forceState = true;
|
||||
return true;
|
||||
|
||||
case UiSerialAction::kPrev:
|
||||
if (useRadio) {
|
||||
g_radio.prev("ui_serial_prev");
|
||||
} else {
|
||||
g_mp3.previousTrack();
|
||||
}
|
||||
g_uiSerialRuntime.forceState = true;
|
||||
return true;
|
||||
|
||||
case UiSerialAction::kVolDelta: {
|
||||
int32_t delta = command.hasIntValue ? command.intValue : 0;
|
||||
delta = delta * 2;
|
||||
const int32_t current = static_cast<int32_t>(g_mp3.volumePercent());
|
||||
const int32_t target = constrain(current + delta, 0, 100);
|
||||
g_mp3.setGain(static_cast<float>(target) / 100.0f);
|
||||
g_uiSerialRuntime.forceState = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiSerialAction::kVolSet: {
|
||||
if (!command.hasIntValue) {
|
||||
return false;
|
||||
}
|
||||
const int32_t target = constrain(command.intValue, 0, 100);
|
||||
g_mp3.setGain(static_cast<float>(target) / 100.0f);
|
||||
g_uiSerialRuntime.forceState = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiSerialAction::kSourceSet:
|
||||
if (!command.hasTextValue) {
|
||||
return false;
|
||||
}
|
||||
if (strcmp(command.textValue, "radio") == 0) {
|
||||
setPlayerUiSource(PlayerUiSource::kRadio);
|
||||
} else if (strcmp(command.textValue, "sd") == 0) {
|
||||
setPlayerUiSource(PlayerUiSource::kSd);
|
||||
if (g_radio.snapshot().active) {
|
||||
g_radio.stop("ui_serial_source_sd");
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
g_uiSerialRuntime.forceState = true;
|
||||
return true;
|
||||
|
||||
case UiSerialAction::kSeek:
|
||||
// Seek fine n'est pas expose par le player actuel: fallback restart.
|
||||
if (command.hasIntValue && command.intValue <= 1) {
|
||||
g_mp3.restartTrack();
|
||||
}
|
||||
g_uiSerialRuntime.forceState = true;
|
||||
return true;
|
||||
|
||||
case UiSerialAction::kStationDelta: {
|
||||
int32_t delta = command.hasIntValue ? command.intValue : 0;
|
||||
if (delta == 0) {
|
||||
return false;
|
||||
}
|
||||
delta = constrain(delta, -12, 12);
|
||||
if (useRadio) {
|
||||
while (delta < 0) {
|
||||
g_radio.prev("ui_serial_station_delta");
|
||||
++delta;
|
||||
}
|
||||
while (delta > 0) {
|
||||
g_radio.next("ui_serial_station_delta");
|
||||
--delta;
|
||||
}
|
||||
} else {
|
||||
while (delta < 0) {
|
||||
g_mp3.previousTrack();
|
||||
++delta;
|
||||
}
|
||||
while (delta > 0) {
|
||||
g_mp3.nextTrack();
|
||||
--delta;
|
||||
}
|
||||
}
|
||||
g_uiSerialRuntime.forceState = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiSerialAction::kRequestState:
|
||||
g_uiSerialRuntime.forceState = true;
|
||||
return true;
|
||||
|
||||
case UiSerialAction::kUnknown:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void setupUiSerialRuntime() {
|
||||
if (!config::kUiSerialEnabled) {
|
||||
return;
|
||||
}
|
||||
uiSerialInit(Serial1, config::kUiSerialBaud, config::kPinUiSerialRx, config::kPinUiSerialTx);
|
||||
uiSerialSetCommandHandler(onUiSerialCommand, nullptr);
|
||||
const uint32_t nowMs = millis();
|
||||
g_uiSerialRuntime.initialized = true;
|
||||
g_uiSerialRuntime.forceState = true;
|
||||
g_uiSerialRuntime.nextTickMs = nowMs + 200U;
|
||||
g_uiSerialRuntime.nextHbMs = nowMs + 1000U;
|
||||
g_uiSerialRuntime.nextStateMs = nowMs + 1000U;
|
||||
g_uiSerialRuntime.nextListMs = nowMs + 900U;
|
||||
Serial.printf("[UI_SERIAL] enabled baud=%lu rx=%d tx=%d\n",
|
||||
static_cast<unsigned long>(config::kUiSerialBaud),
|
||||
static_cast<int>(config::kPinUiSerialRx),
|
||||
static_cast<int>(config::kPinUiSerialTx));
|
||||
}
|
||||
|
||||
void updateUiSerialRuntime(uint32_t nowMs) {
|
||||
if (!config::kUiSerialEnabled || !g_uiSerialRuntime.initialized) {
|
||||
return;
|
||||
}
|
||||
uiSerialPoll(nowMs);
|
||||
|
||||
if (static_cast<int32_t>(nowMs - g_uiSerialRuntime.nextTickMs) >= 0) {
|
||||
UiSerialTick tick;
|
||||
tick.pos = 0;
|
||||
tick.buffer = g_radio.snapshot().active ? static_cast<int32_t>(g_radio.snapshot().bufferPercent) : -1;
|
||||
tick.vu = 0.0f;
|
||||
uiSerialPublishTick(tick);
|
||||
g_uiSerialRuntime.nextTickMs = nowMs + 200U;
|
||||
}
|
||||
|
||||
if (static_cast<int32_t>(nowMs - g_uiSerialRuntime.nextHbMs) >= 0) {
|
||||
uiSerialPublishHeartbeat(nowMs);
|
||||
g_uiSerialRuntime.nextHbMs = nowMs + 1000U;
|
||||
}
|
||||
|
||||
UiSerialStateCache current;
|
||||
buildUiSerialStateCache(¤t);
|
||||
const bool changed = !uiSerialCacheEquals(current, g_uiSerialRuntime.lastState);
|
||||
if (g_uiSerialRuntime.forceState || changed ||
|
||||
static_cast<int32_t>(nowMs - g_uiSerialRuntime.nextStateMs) >= 0) {
|
||||
publishUiSerialStateCache(current);
|
||||
g_uiSerialRuntime.lastState = current;
|
||||
g_uiSerialRuntime.forceState = false;
|
||||
g_uiSerialRuntime.nextStateMs = nowMs + 1200U;
|
||||
}
|
||||
|
||||
if (static_cast<int32_t>(nowMs - g_uiSerialRuntime.nextListMs) >= 0) {
|
||||
publishUiSerialList();
|
||||
g_uiSerialRuntime.nextListMs = nowMs + 1200U;
|
||||
}
|
||||
}
|
||||
|
||||
void printMp3ScanStatus(const char* source) {
|
||||
mp3Controller().printScanStatus(Serial, source);
|
||||
}
|
||||
@@ -3253,6 +3610,7 @@ void app_orchestrator::setup() {
|
||||
g_mp3.setFxDuckingGain(config::kMp3FxDuckingGainDefault);
|
||||
g_mp3.setFxOverlayGain(config::kMp3FxOverlayGainDefault);
|
||||
setupRadioWifiWebRuntime();
|
||||
setupUiSerialRuntime();
|
||||
g_playerUi.reset();
|
||||
mp3Controller().setBrowsePath("/");
|
||||
g_screen.begin();
|
||||
@@ -3328,6 +3686,8 @@ void app_orchestrator::setup() {
|
||||
void app_orchestrator::loop() {
|
||||
const uint32_t loopStartMs = millis();
|
||||
uint32_t nowMs = millis();
|
||||
updateUiSerialRuntime(nowMs);
|
||||
nowMs = millis();
|
||||
updateAsyncAudioService(nowMs);
|
||||
serviceStoryAudioCaptureGuard(nowMs);
|
||||
nowMs = millis();
|
||||
|
||||
@@ -6,6 +6,22 @@
|
||||
#define USON_STORY_V2_DEFAULT 1
|
||||
#endif
|
||||
|
||||
#ifndef UI_SERIAL_ENABLED
|
||||
#define UI_SERIAL_ENABLED 0
|
||||
#endif
|
||||
|
||||
#ifndef UI_SERIAL_BAUD
|
||||
#define UI_SERIAL_BAUD 115200
|
||||
#endif
|
||||
|
||||
#ifndef UI_SERIAL_RX_PIN
|
||||
#define UI_SERIAL_RX_PIN 18
|
||||
#endif
|
||||
|
||||
#ifndef UI_SERIAL_TX_PIN
|
||||
#define UI_SERIAL_TX_PIN 19
|
||||
#endif
|
||||
|
||||
namespace config {
|
||||
|
||||
constexpr uint8_t kPinLedR = 16;
|
||||
@@ -77,6 +93,12 @@ constexpr uint32_t kScreenBaud = 19200;
|
||||
constexpr uint16_t kScreenUpdatePeriodMs = 250;
|
||||
constexpr uint16_t kScreenChangeMinPeriodMs = 90;
|
||||
|
||||
// UART UI tactile externe (RP2040), module optionnel.
|
||||
constexpr bool kUiSerialEnabled = (UI_SERIAL_ENABLED != 0);
|
||||
constexpr uint32_t kUiSerialBaud = static_cast<uint32_t>(UI_SERIAL_BAUD);
|
||||
constexpr int8_t kPinUiSerialRx = static_cast<int8_t>(UI_SERIAL_RX_PIN);
|
||||
constexpr int8_t kPinUiSerialTx = static_cast<int8_t>(UI_SERIAL_TX_PIN);
|
||||
|
||||
// Clavier analogique (6 touches sur 1 entree ADC)
|
||||
constexpr uint8_t kPinKeysAdc = 36;
|
||||
constexpr uint16_t kKeysSampleEveryMs = 8;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
#include "ui_serial.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
HardwareSerial* g_serial = nullptr;
|
||||
UiSerialCommandHandler g_handler = nullptr;
|
||||
void* g_handlerCtx = nullptr;
|
||||
|
||||
constexpr size_t kLineMax = 512U;
|
||||
char g_line[kLineMax + 1U] = {};
|
||||
size_t g_lineLen = 0U;
|
||||
bool g_dropLine = false;
|
||||
bool g_ready = false;
|
||||
|
||||
UiSerialAction parseAction(const char* token) {
|
||||
if (token == nullptr) {
|
||||
return UiSerialAction::kUnknown;
|
||||
}
|
||||
if (strcmp(token, "play_pause") == 0) {
|
||||
return UiSerialAction::kPlayPause;
|
||||
}
|
||||
if (strcmp(token, "next") == 0) {
|
||||
return UiSerialAction::kNext;
|
||||
}
|
||||
if (strcmp(token, "prev") == 0) {
|
||||
return UiSerialAction::kPrev;
|
||||
}
|
||||
if (strcmp(token, "vol_delta") == 0) {
|
||||
return UiSerialAction::kVolDelta;
|
||||
}
|
||||
if (strcmp(token, "vol_set") == 0) {
|
||||
return UiSerialAction::kVolSet;
|
||||
}
|
||||
if (strcmp(token, "source_set") == 0) {
|
||||
return UiSerialAction::kSourceSet;
|
||||
}
|
||||
if (strcmp(token, "seek") == 0) {
|
||||
return UiSerialAction::kSeek;
|
||||
}
|
||||
if (strcmp(token, "station_delta") == 0) {
|
||||
return UiSerialAction::kStationDelta;
|
||||
}
|
||||
if (strcmp(token, "request_state") == 0) {
|
||||
return UiSerialAction::kRequestState;
|
||||
}
|
||||
return UiSerialAction::kUnknown;
|
||||
}
|
||||
|
||||
void processJsonLine(const char* line) {
|
||||
if (line == nullptr || line[0] == '\0' || g_handler == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
StaticJsonDocument<640> doc;
|
||||
if (deserializeJson(doc, line) != DeserializationError::Ok) {
|
||||
return;
|
||||
}
|
||||
const char* type = doc["t"] | "";
|
||||
if (strcmp(type, "cmd") != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
UiSerialCommand cmd;
|
||||
cmd.action = parseAction(doc["a"] | "");
|
||||
if (cmd.action == UiSerialAction::kUnknown) {
|
||||
return;
|
||||
}
|
||||
if (doc.containsKey("v")) {
|
||||
JsonVariantConst v = doc["v"];
|
||||
if (v.is<int32_t>() || v.is<int>() || v.is<long>()) {
|
||||
cmd.hasIntValue = true;
|
||||
cmd.intValue = v.as<int32_t>();
|
||||
} else if (v.is<const char*>()) {
|
||||
cmd.hasTextValue = true;
|
||||
snprintf(cmd.textValue, sizeof(cmd.textValue), "%s", v.as<const char*>());
|
||||
}
|
||||
}
|
||||
|
||||
g_handler(cmd, g_handlerCtx);
|
||||
}
|
||||
|
||||
void sendDoc(const JsonDocument& doc) {
|
||||
if (g_serial == nullptr) {
|
||||
return;
|
||||
}
|
||||
serializeJson(doc, *g_serial);
|
||||
g_serial->print('\n');
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void uiSerialInit(HardwareSerial& serial, uint32_t baud, int8_t rxPin, int8_t txPin) {
|
||||
g_serial = &serial;
|
||||
g_serial->begin(baud, SERIAL_8N1, rxPin, txPin);
|
||||
g_lineLen = 0U;
|
||||
g_dropLine = false;
|
||||
g_ready = true;
|
||||
}
|
||||
|
||||
void uiSerialSetCommandHandler(UiSerialCommandHandler handler, void* ctx) {
|
||||
g_handler = handler;
|
||||
g_handlerCtx = ctx;
|
||||
}
|
||||
|
||||
bool uiSerialIsReady() {
|
||||
return g_ready && g_serial != nullptr;
|
||||
}
|
||||
|
||||
void uiSerialPoll(uint32_t nowMs) {
|
||||
(void)nowMs;
|
||||
if (!uiSerialIsReady()) {
|
||||
return;
|
||||
}
|
||||
while (g_serial->available() > 0) {
|
||||
const int raw = g_serial->read();
|
||||
if (raw < 0) {
|
||||
break;
|
||||
}
|
||||
const char c = static_cast<char>(raw);
|
||||
if (c == '\r') {
|
||||
continue;
|
||||
}
|
||||
if (c == '\n') {
|
||||
if (!g_dropLine) {
|
||||
g_line[g_lineLen] = '\0';
|
||||
processJsonLine(g_line);
|
||||
}
|
||||
g_lineLen = 0U;
|
||||
g_dropLine = false;
|
||||
continue;
|
||||
}
|
||||
if (g_dropLine) {
|
||||
continue;
|
||||
}
|
||||
if (g_lineLen >= kLineMax) {
|
||||
g_dropLine = true;
|
||||
g_lineLen = 0U;
|
||||
continue;
|
||||
}
|
||||
g_line[g_lineLen++] = c;
|
||||
}
|
||||
}
|
||||
|
||||
void uiSerialPublishState(const UiSerialState& state) {
|
||||
if (!uiSerialIsReady()) {
|
||||
return;
|
||||
}
|
||||
StaticJsonDocument<512> doc;
|
||||
doc["t"] = "state";
|
||||
doc["playing"] = state.playing;
|
||||
doc["source"] = state.source != nullptr ? state.source : "sd";
|
||||
doc["title"] = state.title != nullptr ? state.title : "";
|
||||
doc["artist"] = state.artist != nullptr ? state.artist : "";
|
||||
doc["station"] = state.station != nullptr ? state.station : "";
|
||||
doc["pos"] = state.pos;
|
||||
doc["dur"] = state.dur;
|
||||
doc["vol"] = state.vol;
|
||||
doc["rssi"] = state.rssi;
|
||||
doc["buffer"] = state.buffer;
|
||||
doc["error"] = state.error != nullptr ? state.error : "";
|
||||
sendDoc(doc);
|
||||
}
|
||||
|
||||
void uiSerialPublishTick(const UiSerialTick& tick) {
|
||||
if (!uiSerialIsReady()) {
|
||||
return;
|
||||
}
|
||||
StaticJsonDocument<160> doc;
|
||||
doc["t"] = "tick";
|
||||
doc["pos"] = tick.pos;
|
||||
doc["buffer"] = tick.buffer;
|
||||
doc["vu"] = tick.vu;
|
||||
sendDoc(doc);
|
||||
}
|
||||
|
||||
void uiSerialPublishHeartbeat(uint32_t nowMs) {
|
||||
if (!uiSerialIsReady()) {
|
||||
return;
|
||||
}
|
||||
StaticJsonDocument<64> doc;
|
||||
doc["t"] = "hb";
|
||||
doc["ms"] = nowMs;
|
||||
sendDoc(doc);
|
||||
}
|
||||
|
||||
void uiSerialPublishList(const UiSerialList& list) {
|
||||
if (!uiSerialIsReady()) {
|
||||
return;
|
||||
}
|
||||
StaticJsonDocument<512> doc;
|
||||
doc["t"] = "list";
|
||||
doc["source"] = list.source != nullptr ? list.source : "sd";
|
||||
doc["offset"] = list.offset;
|
||||
doc["total"] = list.total;
|
||||
doc["cursor"] = list.cursor;
|
||||
JsonArray items = doc.createNestedArray("items");
|
||||
for (uint8_t i = 0U; i < list.count && i < 8U; ++i) {
|
||||
const char* item = list.items[i] != nullptr ? list.items[i] : "";
|
||||
items.add(item);
|
||||
}
|
||||
sendDoc(doc);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "ui_serial_types.h"
|
||||
|
||||
using UiSerialCommandHandler = bool (*)(const UiSerialCommand& command, void* ctx);
|
||||
|
||||
void uiSerialInit(HardwareSerial& serial, uint32_t baud, int8_t rxPin, int8_t txPin);
|
||||
void uiSerialSetCommandHandler(UiSerialCommandHandler handler, void* ctx);
|
||||
void uiSerialPoll(uint32_t nowMs);
|
||||
void uiSerialPublishState(const UiSerialState& state);
|
||||
void uiSerialPublishTick(const UiSerialTick& tick);
|
||||
void uiSerialPublishHeartbeat(uint32_t nowMs);
|
||||
void uiSerialPublishList(const UiSerialList& list);
|
||||
bool uiSerialIsReady();
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
enum class UiSerialAction : uint8_t {
|
||||
kUnknown = 0,
|
||||
kPlayPause,
|
||||
kNext,
|
||||
kPrev,
|
||||
kVolDelta,
|
||||
kVolSet,
|
||||
kSourceSet,
|
||||
kSeek,
|
||||
kStationDelta,
|
||||
kRequestState,
|
||||
};
|
||||
|
||||
struct UiSerialCommand {
|
||||
UiSerialAction action = UiSerialAction::kUnknown;
|
||||
bool hasIntValue = false;
|
||||
int32_t intValue = 0;
|
||||
bool hasTextValue = false;
|
||||
char textValue[20] = {};
|
||||
};
|
||||
|
||||
struct UiSerialState {
|
||||
bool playing = false;
|
||||
const char* source = "sd";
|
||||
const char* title = "";
|
||||
const char* artist = "";
|
||||
const char* station = "";
|
||||
int32_t pos = 0;
|
||||
int32_t dur = 0;
|
||||
int32_t vol = 0;
|
||||
int32_t rssi = -127;
|
||||
int32_t buffer = -1;
|
||||
const char* error = "";
|
||||
};
|
||||
|
||||
struct UiSerialTick {
|
||||
int32_t pos = 0;
|
||||
int32_t buffer = -1;
|
||||
float vu = 0.0f;
|
||||
};
|
||||
|
||||
struct UiSerialList {
|
||||
const char* source = "sd";
|
||||
uint16_t offset = 0;
|
||||
uint16_t total = 0;
|
||||
uint16_t cursor = 0;
|
||||
uint8_t count = 0;
|
||||
const char* items[8] = {};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
.pio/
|
||||
.vscode/
|
||||
@@ -0,0 +1,48 @@
|
||||
# UART Protocol (JSON Lines)
|
||||
|
||||
Transport: UART, UTF-8, 1 JSON object per line (`\n`).
|
||||
|
||||
- Baud recommandé: `115200`
|
||||
- Longueur max ligne: `512` bytes
|
||||
- Clés inconnues: ignorées
|
||||
- Champs manquants: tolérés
|
||||
|
||||
## UI -> ESP32 (`t=cmd`)
|
||||
|
||||
```json
|
||||
{"t":"cmd","a":"play_pause"}
|
||||
{"t":"cmd","a":"next"}
|
||||
{"t":"cmd","a":"prev"}
|
||||
{"t":"cmd","a":"vol_delta","v":1}
|
||||
{"t":"cmd","a":"vol_set","v":42}
|
||||
{"t":"cmd","a":"source_set","v":"radio"}
|
||||
{"t":"cmd","a":"seek","v":120}
|
||||
{"t":"cmd","a":"station_delta","v":1}
|
||||
{"t":"cmd","a":"request_state"}
|
||||
```
|
||||
|
||||
## ESP32 -> UI
|
||||
|
||||
### `state`
|
||||
```json
|
||||
{"t":"state","playing":true,"source":"radio","title":"...","artist":"","station":"Station A","pos":12,"dur":0,"vol":42,"rssi":-61,"buffer":87,"error":""}
|
||||
```
|
||||
|
||||
### `tick` (5 Hz recommandé)
|
||||
```json
|
||||
{"t":"tick","pos":13,"buffer":86,"vu":0.42}
|
||||
```
|
||||
|
||||
### `hb` (1 Hz recommandé)
|
||||
```json
|
||||
{"t":"hb","ms":12345678}
|
||||
```
|
||||
|
||||
### `list` (extension additive utilisée par l’UI)
|
||||
```json
|
||||
{"t":"list","source":"sd","offset":12,"total":75,"cursor":14,"items":["Track A","Track B","Track C","Track D"]}
|
||||
```
|
||||
|
||||
## Reconnect policy UI
|
||||
1. Si aucun `hb` reçu pendant `3s`, l’UI se marque offline.
|
||||
2. L’UI envoie `request_state` toutes les `1s` tant que le lien n’est pas revenu.
|
||||
@@ -0,0 +1,49 @@
|
||||
# RP2040 Touch UI Option (3.5" TFT + XPT2046)
|
||||
|
||||
UI tactile optionnelle (100% touch) pour piloter le lecteur ESP32 Audio Kit via UART JSONL.
|
||||
|
||||
## Scope
|
||||
- Ecran 480x320 (3.5" SPI, ILI9488 ou ILI9486)
|
||||
- Touch résistif XPT2046 (SPI partagé)
|
||||
- Protocole UART JSONL avec ESP32
|
||||
- 3 écrans: `LECTURE`, `LISTE`, `REGLAGES`
|
||||
|
||||
## Build / Flash (PlatformIO)
|
||||
Depuis ce dossier:
|
||||
|
||||
```bash
|
||||
pio run -e rp2040_ili9488
|
||||
pio run -e rp2040_ili9488 -t upload --upload-port <PORT_RP2040>
|
||||
```
|
||||
|
||||
Alternative dalle ILI9486:
|
||||
|
||||
```bash
|
||||
pio run -e rp2040_ili9486
|
||||
pio run -e rp2040_ili9486 -t upload --upload-port <PORT_RP2040>
|
||||
```
|
||||
|
||||
Monitor:
|
||||
|
||||
```bash
|
||||
pio device monitor -e rp2040_ili9488 --port <PORT_RP2040>
|
||||
```
|
||||
|
||||
## First Boot
|
||||
1. L’UI démarre en mode "Connecting...".
|
||||
2. Calibration tactile 3 points si aucune calibration enregistrée.
|
||||
3. Demande d’état automatique à l’ESP32 (`request_state`).
|
||||
|
||||
## ESP32 Integration
|
||||
Activer côté ESP32 via flags compile:
|
||||
- `UI_SERIAL_ENABLED=1`
|
||||
- `UI_SERIAL_BAUD=115200`
|
||||
- `UI_SERIAL_RX_PIN=18`
|
||||
- `UI_SERIAL_TX_PIN=19`
|
||||
|
||||
Par défaut dans le firmware ESP32, le module reste **désactivé** (`UI_SERIAL_ENABLED=0`).
|
||||
|
||||
## Docs
|
||||
- Câblage: [`WIRING.md`](./WIRING.md)
|
||||
- Protocole: [`PROTOCOL.md`](./PROTOCOL.md)
|
||||
- Spécification UI: [`UI_SPEC.md`](./UI_SPEC.md)
|
||||
@@ -0,0 +1,43 @@
|
||||
# UI Spec — 480x320 Touch
|
||||
|
||||
## Pages
|
||||
|
||||
## 1) `LECTURE`
|
||||
- Badge source (`SD` / `RADIO`)
|
||||
- Statut lien UART
|
||||
- RSSI Wi-Fi
|
||||
- Titre (2 lignes, marquee si débordement)
|
||||
- Ligne secondaire (`artist` en SD, `station` en radio)
|
||||
- Barre de progression:
|
||||
- SD: seek par tap
|
||||
- Radio: `LIVE`
|
||||
- Mini VU meter
|
||||
- Actions bas écran: `PREV`, `PLAY`, `NEXT`, `VOL-`, `VOL+`
|
||||
|
||||
## 2) `LISTE`
|
||||
- Source active (`SD` / `RADIO`)
|
||||
- 4 lignes visibles avec surlignage
|
||||
- Offset / total
|
||||
- Actions bas écran: `UP`, `DOWN`, `OK`, `BACK`, `MODE`
|
||||
|
||||
## 3) `REGLAGES`
|
||||
- Items:
|
||||
- Wi-Fi
|
||||
- EQ
|
||||
- Luminosite
|
||||
- Screensaver
|
||||
- Action `APPLY`
|
||||
- Actions bas écran: `UP`, `DOWN`, `APPLY`, `BACK`, `MODE`
|
||||
|
||||
## Gestures
|
||||
- Swipe horizontal: `next/prev`
|
||||
- Swipe vertical: `vol_delta`
|
||||
- Tap zones:
|
||||
- Header: switch page
|
||||
- Bottom buttons: actions contextuelles
|
||||
|
||||
## Rendering policy
|
||||
- Partiel (zones), pas de refresh plein écran en continu
|
||||
- Cadence max:
|
||||
- 30 FPS quand dirty
|
||||
- 5 FPS en idle
|
||||
@@ -0,0 +1,44 @@
|
||||
# Wiring RP2040 UI <-> TFT Touch <-> ESP32
|
||||
|
||||
## 1) RP2040 -> TFT + Touch (SPI shared)
|
||||
|
||||
| Function | RP2040 pin | Note |
|
||||
|---|---:|---|
|
||||
| SPI SCK | GP2 | partagé TFT + XPT2046 |
|
||||
| SPI MOSI | GP3 | partagé TFT + XPT2046 |
|
||||
| SPI MISO | GP4 | partagé TFT + XPT2046 |
|
||||
| TFT CS | GP5 | chip select écran |
|
||||
| TFT DC/RS | GP6 | data/command |
|
||||
| TFT RST | GP7 | reset écran |
|
||||
| Touch CS | GP9 | chip select XPT2046 |
|
||||
| Touch IRQ | GP15 | optionnel mais recommandé |
|
||||
| 5V | 5V | alim module écran |
|
||||
| GND | GND | masse commune |
|
||||
|
||||
## 2) RP2040 -> ESP32 (UART JSONL)
|
||||
|
||||
| UART | RP2040 | ESP32 |
|
||||
|---|---|---|
|
||||
| TX | GP0 | RX GPIO18 |
|
||||
| RX | GP1 | TX GPIO19 |
|
||||
| GND | GND | GND |
|
||||
|
||||
## 3) Mapping vers header 40 pins type Raspberry (module LCD)
|
||||
|
||||
| Header 40p (phys) | Function | RP2040 |
|
||||
|---:|---|---|
|
||||
| 2 / 4 | 5V | 5V |
|
||||
| 6 | GND | GND |
|
||||
| 23 | SPI SCLK | GP2 |
|
||||
| 19 | SPI MOSI | GP3 |
|
||||
| 21 | SPI MISO | GP4 |
|
||||
| 24 | LCD_CS | GP5 |
|
||||
| 18 | LCD_DC | GP6 |
|
||||
| 22 | LCD_RST | GP7 |
|
||||
| 26 | TP_CS | GP9 |
|
||||
| 11 | TP_IRQ | GP15 |
|
||||
|
||||
## 4) Notes critiques
|
||||
1. Le bus touch doit rester plus lent que le bus TFT (`SPI_TOUCH_FREQUENCY` plus bas).
|
||||
2. Toujours partager la masse RP2040 / ESP32 / écran.
|
||||
3. Si l’écran reste noir: vérifier alimentation/backlight et test ILI9488 vs ILI9486.
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
namespace ui_config {
|
||||
|
||||
constexpr uint16_t kScreenWidth = 480;
|
||||
constexpr uint16_t kScreenHeight = 320;
|
||||
|
||||
constexpr uint8_t kPinSpiSck = 2;
|
||||
constexpr uint8_t kPinSpiMosi = 3;
|
||||
constexpr uint8_t kPinSpiMiso = 4;
|
||||
|
||||
constexpr uint8_t kPinTftCs = 5;
|
||||
constexpr uint8_t kPinTftDc = 6;
|
||||
constexpr uint8_t kPinTftRst = 7;
|
||||
|
||||
constexpr uint8_t kPinTouchCs = 9;
|
||||
#ifdef UI_TOUCH_IRQ_PIN
|
||||
constexpr int8_t kPinTouchIrq = static_cast<int8_t>(UI_TOUCH_IRQ_PIN);
|
||||
#else
|
||||
constexpr int8_t kPinTouchIrq = 15;
|
||||
#endif
|
||||
|
||||
#ifdef UI_UART_TX_PIN
|
||||
constexpr int8_t kPinUartTx = static_cast<int8_t>(UI_UART_TX_PIN);
|
||||
#else
|
||||
constexpr int8_t kPinUartTx = 0;
|
||||
#endif
|
||||
|
||||
#ifdef UI_UART_RX_PIN
|
||||
constexpr int8_t kPinUartRx = static_cast<int8_t>(UI_UART_RX_PIN);
|
||||
#else
|
||||
constexpr int8_t kPinUartRx = 1;
|
||||
#endif
|
||||
|
||||
#ifdef UI_SERIAL_BAUD
|
||||
constexpr uint32_t kSerialBaud = static_cast<uint32_t>(UI_SERIAL_BAUD);
|
||||
#else
|
||||
constexpr uint32_t kSerialBaud = 115200U;
|
||||
#endif
|
||||
|
||||
#ifdef UI_ROTATION
|
||||
constexpr uint8_t kRotation = static_cast<uint8_t>(UI_ROTATION);
|
||||
#else
|
||||
constexpr uint8_t kRotation = 1U;
|
||||
#endif
|
||||
|
||||
constexpr uint32_t kTouchPollPeriodMs = 20U; // 50 Hz
|
||||
constexpr uint32_t kTouchDebounceMs = 70U;
|
||||
constexpr uint16_t kTapMaxTravelPx = 18U;
|
||||
constexpr uint16_t kSwipeMinTravelPx = 52U;
|
||||
constexpr uint32_t kGestureMaxTapMs = 450U;
|
||||
|
||||
constexpr uint32_t kRenderDirtyFramePeriodMs = 34U; // ~30 FPS max
|
||||
constexpr uint32_t kRenderIdleFramePeriodMs = 200U; // 5 FPS idle
|
||||
|
||||
constexpr uint32_t kHbTimeoutMs = 3000U;
|
||||
constexpr uint32_t kRequestStateRetryMs = 1000U;
|
||||
|
||||
constexpr uint32_t kTxtMarqueeStepMs = 120U;
|
||||
constexpr uint16_t kTxtMarqueeStartDelayMs = 900U;
|
||||
|
||||
constexpr char kCalibrationPath[] = "/touch_cal_v1.json";
|
||||
|
||||
} // namespace ui_config
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
enum class UiPage : uint8_t {
|
||||
kNowPlaying = 0,
|
||||
kList = 1,
|
||||
kSettings = 2,
|
||||
};
|
||||
|
||||
enum class UiSource : uint8_t {
|
||||
kSd = 0,
|
||||
kRadio = 1,
|
||||
};
|
||||
|
||||
enum class UiOutCmd : uint8_t {
|
||||
kNone = 0,
|
||||
kPlayPause,
|
||||
kNext,
|
||||
kPrev,
|
||||
kVolDelta,
|
||||
kVolSet,
|
||||
kSourceSet,
|
||||
kSeek,
|
||||
kStationDelta,
|
||||
kRequestState,
|
||||
};
|
||||
|
||||
struct UiOutgoingCommand {
|
||||
UiOutCmd cmd = UiOutCmd::kNone;
|
||||
int32_t value = 0;
|
||||
char textValue[16] = {};
|
||||
};
|
||||
|
||||
struct UiRemoteState {
|
||||
bool playing = false;
|
||||
UiSource source = UiSource::kSd;
|
||||
char title[96] = {};
|
||||
char artist[64] = {};
|
||||
char station[64] = {};
|
||||
int32_t posSec = 0;
|
||||
int32_t durSec = 0;
|
||||
int32_t volume = 0;
|
||||
int32_t rssi = -127;
|
||||
int32_t bufferPercent = -1;
|
||||
char error[64] = {};
|
||||
};
|
||||
|
||||
struct UiRemoteTick {
|
||||
int32_t posSec = 0;
|
||||
int32_t bufferPercent = -1;
|
||||
float vu = 0.0f;
|
||||
};
|
||||
|
||||
struct UiRemoteList {
|
||||
UiSource source = UiSource::kSd;
|
||||
uint16_t offset = 0;
|
||||
uint16_t total = 0;
|
||||
uint16_t cursor = 0;
|
||||
uint8_t count = 0;
|
||||
char items[8][48] = {};
|
||||
};
|
||||
|
||||
inline const char* uiSourceToken(UiSource source) {
|
||||
return (source == UiSource::kRadio) ? "radio" : "sd";
|
||||
}
|
||||
|
||||
inline UiSource uiSourceFromToken(const char* token) {
|
||||
if (token != nullptr && strcmp(token, "radio") == 0) {
|
||||
return UiSource::kRadio;
|
||||
}
|
||||
return UiSource::kSd;
|
||||
}
|
||||
|
||||
inline const char* uiPageLabel(UiPage page) {
|
||||
switch (page) {
|
||||
case UiPage::kNowPlaying:
|
||||
return "LECTURE";
|
||||
case UiPage::kList:
|
||||
return "LISTE";
|
||||
case UiPage::kSettings:
|
||||
return "REGLAGES";
|
||||
default:
|
||||
return "LECTURE";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
[platformio]
|
||||
default_envs = rp2040_ili9488
|
||||
|
||||
[env]
|
||||
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
|
||||
board = rpipico
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
lib_deps =
|
||||
bodmer/TFT_eSPI@^2.5.43
|
||||
paulstoffregen/XPT2046_Touchscreen@0.0.0-alpha+sha.26b691b2c8
|
||||
bblanchon/ArduinoJson@^6.21.5
|
||||
build_flags =
|
||||
-DUSER_SETUP_LOADED=1
|
||||
-DTFT_MISO=4
|
||||
-DTFT_MOSI=3
|
||||
-DTFT_SCLK=2
|
||||
-DTFT_CS=5
|
||||
-DTFT_DC=6
|
||||
-DTFT_RST=7
|
||||
-DTOUCH_CS=9
|
||||
-DSPI_FREQUENCY=40000000
|
||||
-DSPI_READ_FREQUENCY=20000000
|
||||
-DSPI_TOUCH_FREQUENCY=2500000
|
||||
-DSUPPORT_TRANSACTIONS
|
||||
-DUI_LCD_SPI_HOST=0
|
||||
-DUI_TOUCH_IRQ_PIN=15
|
||||
-DUI_UART_RX_PIN=1
|
||||
-DUI_UART_TX_PIN=0
|
||||
-DUI_SERIAL_BAUD=115200
|
||||
-DUI_ROTATION=1
|
||||
|
||||
[env:rp2040_ili9488]
|
||||
build_flags =
|
||||
${env.build_flags}
|
||||
-DILI9488_DRIVER
|
||||
-DTFT_RGB_ORDER=TFT_BGR
|
||||
|
||||
[env:rp2040_ili9486]
|
||||
build_flags =
|
||||
${env.build_flags}
|
||||
-DILI9486_DRIVER
|
||||
-DTFT_RGB_ORDER=TFT_BGR
|
||||
@@ -0,0 +1,307 @@
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include <TFT_eSPI.h>
|
||||
#include <XPT2046_Touchscreen.h>
|
||||
|
||||
#include "../include/ui_config.h"
|
||||
#include "../include/ui_protocol.h"
|
||||
#include "touch_calibration.h"
|
||||
#include "uart_link.h"
|
||||
#include "ui_renderer.h"
|
||||
#include "ui_state.h"
|
||||
|
||||
namespace {
|
||||
|
||||
TFT_eSPI g_tft;
|
||||
XPT2046_Touchscreen g_touch(ui_config::kPinTouchCs, ui_config::kPinTouchIrq);
|
||||
TouchCalibration g_calibration;
|
||||
UartLink g_uart;
|
||||
UiStateModel g_ui;
|
||||
UiRenderer g_renderer(g_tft);
|
||||
|
||||
UiRemoteState g_lastState;
|
||||
|
||||
struct TouchTracker {
|
||||
bool active = false;
|
||||
uint16_t startX = 0;
|
||||
uint16_t startY = 0;
|
||||
uint16_t lastX = 0;
|
||||
uint16_t lastY = 0;
|
||||
uint32_t startMs = 0;
|
||||
uint32_t lastActionMs = 0;
|
||||
};
|
||||
|
||||
TouchTracker g_touchTracker;
|
||||
uint32_t g_nextTouchPollMs = 0U;
|
||||
uint32_t g_nextRenderMs = 0U;
|
||||
bool g_forceRender = true;
|
||||
|
||||
template <typename T>
|
||||
T clampValue(T value, T minValue, T maxValue) {
|
||||
if (value < minValue) {
|
||||
return minValue;
|
||||
}
|
||||
if (value > maxValue) {
|
||||
return maxValue;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bool readTouchPoint(uint16_t* outX, uint16_t* outY) {
|
||||
if (outX == nullptr || outY == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (ui_config::kPinTouchIrq >= 0 && digitalRead(static_cast<uint8_t>(ui_config::kPinTouchIrq)) == HIGH &&
|
||||
!g_touch.touched()) {
|
||||
return false;
|
||||
}
|
||||
if (!g_touch.touched()) {
|
||||
return false;
|
||||
}
|
||||
TS_Point p = g_touch.getPoint();
|
||||
if (p.z < 80) {
|
||||
return false;
|
||||
}
|
||||
return g_calibration.mapRaw(
|
||||
p.x, p.y, ui_config::kScreenWidth, ui_config::kScreenHeight, outX, outY);
|
||||
}
|
||||
|
||||
void sendUiCommand(const UiOutgoingCommand& cmd) {
|
||||
if (cmd.cmd == UiOutCmd::kNone) {
|
||||
return;
|
||||
}
|
||||
g_uart.sendCommand(cmd);
|
||||
}
|
||||
|
||||
void processGesture(uint16_t x, uint16_t y, uint32_t nowMs) {
|
||||
UiOutgoingCommand cmd;
|
||||
if (!g_touchTracker.active) {
|
||||
g_touchTracker.active = true;
|
||||
g_touchTracker.startX = x;
|
||||
g_touchTracker.startY = y;
|
||||
g_touchTracker.lastX = x;
|
||||
g_touchTracker.lastY = y;
|
||||
g_touchTracker.startMs = nowMs;
|
||||
return;
|
||||
}
|
||||
g_touchTracker.lastX = x;
|
||||
g_touchTracker.lastY = y;
|
||||
}
|
||||
|
||||
void processTouch(uint32_t nowMs) {
|
||||
if (static_cast<int32_t>(nowMs - g_nextTouchPollMs) < 0) {
|
||||
return;
|
||||
}
|
||||
g_nextTouchPollMs = nowMs + ui_config::kTouchPollPeriodMs;
|
||||
|
||||
uint16_t x = 0U;
|
||||
uint16_t y = 0U;
|
||||
const bool pressed = readTouchPoint(&x, &y);
|
||||
if (pressed) {
|
||||
processGesture(x, y, nowMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!g_touchTracker.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int16_t dx = static_cast<int16_t>(g_touchTracker.lastX) - static_cast<int16_t>(g_touchTracker.startX);
|
||||
const int16_t dy = static_cast<int16_t>(g_touchTracker.lastY) - static_cast<int16_t>(g_touchTracker.startY);
|
||||
const uint16_t adx = static_cast<uint16_t>(dx < 0 ? -dx : dx);
|
||||
const uint16_t ady = static_cast<uint16_t>(dy < 0 ? -dy : dy);
|
||||
const uint32_t pressMs = nowMs - g_touchTracker.startMs;
|
||||
|
||||
if (static_cast<int32_t>(nowMs - g_touchTracker.lastActionMs) <
|
||||
static_cast<int32_t>(ui_config::kTouchDebounceMs)) {
|
||||
g_touchTracker.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
UiOutgoingCommand cmd;
|
||||
bool send = false;
|
||||
if ((adx >= ui_config::kSwipeMinTravelPx || ady >= ui_config::kSwipeMinTravelPx) &&
|
||||
pressMs <= 900U) {
|
||||
send = g_ui.onSwipe(dx, dy, nowMs, &cmd);
|
||||
} else if (adx <= ui_config::kTapMaxTravelPx && ady <= ui_config::kTapMaxTravelPx &&
|
||||
pressMs <= ui_config::kGestureMaxTapMs) {
|
||||
send = g_ui.onTap(g_touchTracker.startX, g_touchTracker.startY, nowMs, &cmd);
|
||||
}
|
||||
|
||||
if (send) {
|
||||
sendUiCommand(cmd);
|
||||
g_touchTracker.lastActionMs = nowMs;
|
||||
}
|
||||
g_touchTracker.active = false;
|
||||
}
|
||||
|
||||
void handleIncomingJson(const JsonDocument& doc, void* ctx) {
|
||||
(void)ctx;
|
||||
const char* type = doc["t"] | "";
|
||||
const uint32_t nowMs = millis();
|
||||
if (strcmp(type, "state") == 0) {
|
||||
UiRemoteState state = g_lastState;
|
||||
if (doc.containsKey("playing")) {
|
||||
state.playing = doc["playing"] | false;
|
||||
}
|
||||
if (doc.containsKey("source")) {
|
||||
state.source = uiSourceFromToken(doc["source"] | "sd");
|
||||
}
|
||||
if (doc.containsKey("title")) {
|
||||
snprintf(state.title, sizeof(state.title), "%s", doc["title"] | "");
|
||||
}
|
||||
if (doc.containsKey("artist")) {
|
||||
snprintf(state.artist, sizeof(state.artist), "%s", doc["artist"] | "");
|
||||
}
|
||||
if (doc.containsKey("station")) {
|
||||
snprintf(state.station, sizeof(state.station), "%s", doc["station"] | "");
|
||||
}
|
||||
if (doc.containsKey("pos")) {
|
||||
state.posSec = doc["pos"] | state.posSec;
|
||||
}
|
||||
if (doc.containsKey("dur")) {
|
||||
state.durSec = doc["dur"] | state.durSec;
|
||||
}
|
||||
if (doc.containsKey("vol")) {
|
||||
state.volume = clampValue<int32_t>(doc["vol"] | state.volume, 0, 100);
|
||||
}
|
||||
if (doc.containsKey("rssi")) {
|
||||
state.rssi = doc["rssi"] | state.rssi;
|
||||
}
|
||||
if (doc.containsKey("buffer")) {
|
||||
state.bufferPercent = doc["buffer"] | state.bufferPercent;
|
||||
}
|
||||
if (doc.containsKey("error")) {
|
||||
snprintf(state.error, sizeof(state.error), "%s", doc["error"] | "");
|
||||
}
|
||||
g_lastState = state;
|
||||
g_ui.applyState(state, nowMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(type, "tick") == 0) {
|
||||
UiRemoteTick tick;
|
||||
tick.posSec = doc["pos"] | g_lastState.posSec;
|
||||
tick.bufferPercent = doc["buffer"] | g_lastState.bufferPercent;
|
||||
tick.vu = doc["vu"] | 0.0f;
|
||||
g_ui.applyTick(tick, nowMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(type, "hb") == 0) {
|
||||
g_ui.onHeartbeat(nowMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (strcmp(type, "list") == 0) {
|
||||
UiRemoteList list;
|
||||
list.source = uiSourceFromToken(doc["source"] | "sd");
|
||||
list.offset = doc["offset"] | 0;
|
||||
list.total = doc["total"] | 0;
|
||||
list.cursor = doc["cursor"] | 0;
|
||||
JsonArrayConst arr = doc["items"].as<JsonArrayConst>();
|
||||
uint8_t idx = 0U;
|
||||
for (JsonVariantConst item : arr) {
|
||||
if (idx >= 8U) {
|
||||
break;
|
||||
}
|
||||
snprintf(list.items[idx], sizeof(list.items[idx]), "%s", item.as<const char*>());
|
||||
++idx;
|
||||
}
|
||||
list.count = idx;
|
||||
g_ui.applyList(list, nowMs);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void showCalibrationHintAndMaybeRun() {
|
||||
bool needCalibration = !g_calibration.load();
|
||||
if (!needCalibration) {
|
||||
g_renderer.drawBootScreen("Touch coin haut-gauche", "pour recalibrer");
|
||||
const uint32_t deadline = millis() + 1200U;
|
||||
while (static_cast<int32_t>(millis() - deadline) < 0) {
|
||||
uint16_t x = 0U;
|
||||
uint16_t y = 0U;
|
||||
if (readTouchPoint(&x, &y) && x < 80U && y < 80U) {
|
||||
needCalibration = true;
|
||||
break;
|
||||
}
|
||||
delay(20);
|
||||
}
|
||||
}
|
||||
if (needCalibration) {
|
||||
g_renderer.drawBootScreen("Calibration tactile", "Touchez les 3 points");
|
||||
if (!g_calibration.runWizard(g_tft, g_touch, ui_config::kScreenWidth, ui_config::kScreenHeight)) {
|
||||
g_renderer.drawBootScreen("Calibration echec", "Profil par defaut");
|
||||
TouchCalibration::Data* d = g_calibration.mutableData();
|
||||
d->valid = true;
|
||||
d->swapXY = false;
|
||||
d->invertX = false;
|
||||
d->invertY = false;
|
||||
d->xMin = 200;
|
||||
d->xMax = 3900;
|
||||
d->yMin = 200;
|
||||
d->yMax = 3900;
|
||||
g_calibration.save();
|
||||
delay(700);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(100);
|
||||
Serial.println("[UI] RP2040 TFT touch UI boot");
|
||||
|
||||
SPI.setSCK(ui_config::kPinSpiSck);
|
||||
SPI.setTX(ui_config::kPinSpiMosi);
|
||||
SPI.setRX(ui_config::kPinSpiMiso);
|
||||
|
||||
g_tft.init();
|
||||
g_tft.setRotation(ui_config::kRotation);
|
||||
g_tft.fillScreen(TFT_BLACK);
|
||||
|
||||
if (ui_config::kPinTouchIrq >= 0) {
|
||||
pinMode(static_cast<uint8_t>(ui_config::kPinTouchIrq), INPUT_PULLUP);
|
||||
}
|
||||
g_touch.begin();
|
||||
g_touch.setRotation(ui_config::kRotation);
|
||||
|
||||
g_calibration.begin();
|
||||
g_renderer.begin();
|
||||
g_renderer.drawBootScreen("Initialisation", "TFT + Touch + UART");
|
||||
showCalibrationHintAndMaybeRun();
|
||||
|
||||
g_ui.begin();
|
||||
Serial1.setRX(ui_config::kPinUartRx);
|
||||
Serial1.setTX(ui_config::kPinUartTx);
|
||||
g_uart.begin(Serial1, ui_config::kSerialBaud, ui_config::kPinUartRx, ui_config::kPinUartTx);
|
||||
g_uart.setJsonHandler(handleIncomingJson, nullptr);
|
||||
g_uart.sendRequestState();
|
||||
|
||||
g_nextTouchPollMs = millis();
|
||||
g_nextRenderMs = millis();
|
||||
g_forceRender = true;
|
||||
}
|
||||
|
||||
void loop() {
|
||||
const uint32_t nowMs = millis();
|
||||
g_uart.poll();
|
||||
g_ui.updateConnection(nowMs);
|
||||
if (g_ui.shouldRequestState(nowMs)) {
|
||||
g_uart.sendRequestState();
|
||||
}
|
||||
|
||||
processTouch(nowMs);
|
||||
|
||||
const bool dirty = g_ui.consumeDirty();
|
||||
if (g_forceRender || dirty || static_cast<int32_t>(nowMs - g_nextRenderMs) >= 0) {
|
||||
const bool forceFull = g_forceRender;
|
||||
g_renderer.render(g_ui, nowMs, forceFull);
|
||||
g_forceRender = false;
|
||||
g_nextRenderMs =
|
||||
nowMs + ((dirty || forceFull) ? ui_config::kRenderDirtyFramePeriodMs : ui_config::kRenderIdleFramePeriodMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
#include "touch_calibration.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <LittleFS.h>
|
||||
#include <TFT_eSPI.h>
|
||||
#include <XPT2046_Touchscreen.h>
|
||||
|
||||
#include "../include/ui_config.h"
|
||||
|
||||
namespace {
|
||||
|
||||
template <typename T>
|
||||
T clampValue(T value, T minValue, T maxValue) {
|
||||
if (value < minValue) {
|
||||
return minValue;
|
||||
}
|
||||
if (value > maxValue) {
|
||||
return maxValue;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
int32_t abs32(int32_t v) {
|
||||
return (v < 0) ? -v : v;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool TouchCalibration::begin() {
|
||||
return LittleFS.begin();
|
||||
}
|
||||
|
||||
bool TouchCalibration::load() {
|
||||
File f = LittleFS.open(ui_config::kCalibrationPath, "r");
|
||||
if (!f) {
|
||||
return false;
|
||||
}
|
||||
StaticJsonDocument<256> doc;
|
||||
DeserializationError err = deserializeJson(doc, f);
|
||||
f.close();
|
||||
if (err) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Data tmp;
|
||||
tmp.valid = doc["valid"] | false;
|
||||
tmp.swapXY = doc["swapXY"] | false;
|
||||
tmp.invertX = doc["invertX"] | false;
|
||||
tmp.invertY = doc["invertY"] | false;
|
||||
tmp.xMin = doc["xMin"] | 200;
|
||||
tmp.xMax = doc["xMax"] | 3900;
|
||||
tmp.yMin = doc["yMin"] | 200;
|
||||
tmp.yMax = doc["yMax"] | 3900;
|
||||
if (tmp.xMax <= tmp.xMin || tmp.yMax <= tmp.yMin) {
|
||||
return false;
|
||||
}
|
||||
data_ = tmp;
|
||||
return data_.valid;
|
||||
}
|
||||
|
||||
bool TouchCalibration::save() const {
|
||||
File f = LittleFS.open(ui_config::kCalibrationPath, "w");
|
||||
if (!f) {
|
||||
return false;
|
||||
}
|
||||
StaticJsonDocument<256> doc;
|
||||
doc["valid"] = data_.valid;
|
||||
doc["swapXY"] = data_.swapXY;
|
||||
doc["invertX"] = data_.invertX;
|
||||
doc["invertY"] = data_.invertY;
|
||||
doc["xMin"] = data_.xMin;
|
||||
doc["xMax"] = data_.xMax;
|
||||
doc["yMin"] = data_.yMin;
|
||||
doc["yMax"] = data_.yMax;
|
||||
const bool ok = serializeJson(doc, f) > 0;
|
||||
f.close();
|
||||
return ok;
|
||||
}
|
||||
|
||||
const TouchCalibration::Data& TouchCalibration::data() const {
|
||||
return data_;
|
||||
}
|
||||
|
||||
TouchCalibration::Data* TouchCalibration::mutableData() {
|
||||
return &data_;
|
||||
}
|
||||
|
||||
void TouchCalibration::drawTarget(TFT_eSPI& tft, int16_t x, int16_t y, const char* label) {
|
||||
tft.fillScreen(TFT_BLACK);
|
||||
tft.setTextColor(TFT_WHITE, TFT_BLACK);
|
||||
tft.setTextSize(2);
|
||||
tft.setCursor(12, 10);
|
||||
tft.print("Calibration");
|
||||
tft.setTextSize(1);
|
||||
tft.setCursor(12, 38);
|
||||
tft.print(label);
|
||||
|
||||
const int16_t size = 14;
|
||||
tft.drawCircle(x, y, size, TFT_YELLOW);
|
||||
tft.drawLine(x - size, y, x + size, y, TFT_YELLOW);
|
||||
tft.drawLine(x, y - size, x, y + size, TFT_YELLOW);
|
||||
}
|
||||
|
||||
bool TouchCalibration::captureRawPoint(TFT_eSPI& tft,
|
||||
XPT2046_Touchscreen& touch,
|
||||
int16_t targetX,
|
||||
int16_t targetY,
|
||||
int32_t* outRawX,
|
||||
int32_t* outRawY) {
|
||||
drawTarget(tft, targetX, targetY, "Touchez la cible");
|
||||
delay(100);
|
||||
uint32_t deadline = millis() + 15000U;
|
||||
|
||||
while (static_cast<int32_t>(millis() - deadline) < 0) {
|
||||
if (!touch.touched()) {
|
||||
delay(10);
|
||||
continue;
|
||||
}
|
||||
int64_t sumX = 0;
|
||||
int64_t sumY = 0;
|
||||
uint8_t samples = 0;
|
||||
while (touch.touched() && samples < 18U) {
|
||||
TS_Point p = touch.getPoint();
|
||||
if (p.z > 80) {
|
||||
sumX += p.x;
|
||||
sumY += p.y;
|
||||
++samples;
|
||||
}
|
||||
delay(12);
|
||||
}
|
||||
if (samples < 4U) {
|
||||
continue;
|
||||
}
|
||||
*outRawX = static_cast<int32_t>(sumX / samples);
|
||||
*outRawY = static_cast<int32_t>(sumY / samples);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool TouchCalibration::runWizard(TFT_eSPI& tft,
|
||||
XPT2046_Touchscreen& touch,
|
||||
uint16_t screenW,
|
||||
uint16_t screenH) {
|
||||
int32_t x1 = 0, y1 = 0, x2 = 0, y2 = 0, x3 = 0, y3 = 0;
|
||||
const int16_t margin = 28;
|
||||
if (!captureRawPoint(tft, touch, margin, margin, &x1, &y1)) {
|
||||
return false;
|
||||
}
|
||||
if (!captureRawPoint(tft, touch, static_cast<int16_t>(screenW - margin), margin, &x2, &y2)) {
|
||||
return false;
|
||||
}
|
||||
if (!captureRawPoint(tft, touch, margin, static_cast<int16_t>(screenH - margin), &x3, &y3)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Data out;
|
||||
const int32_t dxTop = abs32(x2 - x1);
|
||||
const int32_t dyTop = abs32(y2 - y1);
|
||||
out.swapXY = (dyTop > dxTop);
|
||||
|
||||
int32_t rawXLeft = 0;
|
||||
int32_t rawXRight = 0;
|
||||
int32_t rawYTop = 0;
|
||||
int32_t rawYBottom = 0;
|
||||
|
||||
if (!out.swapXY) {
|
||||
rawXLeft = (x1 + x3) / 2;
|
||||
rawXRight = x2;
|
||||
rawYTop = (y1 + y2) / 2;
|
||||
rawYBottom = y3;
|
||||
} else {
|
||||
rawXLeft = (y1 + y3) / 2;
|
||||
rawXRight = y2;
|
||||
rawYTop = (x1 + x2) / 2;
|
||||
rawYBottom = x3;
|
||||
}
|
||||
|
||||
out.invertX = rawXRight < rawXLeft;
|
||||
out.invertY = rawYBottom < rawYTop;
|
||||
out.xMin = (rawXLeft < rawXRight) ? rawXLeft : rawXRight;
|
||||
out.xMax = (rawXLeft > rawXRight) ? rawXLeft : rawXRight;
|
||||
out.yMin = (rawYTop < rawYBottom) ? rawYTop : rawYBottom;
|
||||
out.yMax = (rawYTop > rawYBottom) ? rawYTop : rawYBottom;
|
||||
out.valid = (out.xMax - out.xMin) > 200 && (out.yMax - out.yMin) > 200;
|
||||
if (!out.valid) {
|
||||
return false;
|
||||
}
|
||||
data_ = out;
|
||||
save();
|
||||
|
||||
tft.fillScreen(TFT_BLACK);
|
||||
tft.setTextColor(TFT_GREEN, TFT_BLACK);
|
||||
tft.setTextSize(2);
|
||||
tft.setCursor(14, 24);
|
||||
tft.print("Calibration OK");
|
||||
tft.setTextSize(1);
|
||||
tft.setCursor(14, 58);
|
||||
tft.printf("swap=%u invX=%u invY=%u", out.swapXY ? 1U : 0U, out.invertX ? 1U : 0U, out.invertY ? 1U : 0U);
|
||||
delay(700);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TouchCalibration::mapRaw(int32_t rawX,
|
||||
int32_t rawY,
|
||||
uint16_t screenW,
|
||||
uint16_t screenH,
|
||||
uint16_t* outX,
|
||||
uint16_t* outY) const {
|
||||
if (!data_.valid || outX == nullptr || outY == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int32_t ax = rawX;
|
||||
int32_t ay = rawY;
|
||||
if (data_.swapXY) {
|
||||
ax = rawY;
|
||||
ay = rawX;
|
||||
}
|
||||
|
||||
if (data_.invertX) {
|
||||
ax = data_.xMax - (ax - data_.xMin);
|
||||
}
|
||||
if (data_.invertY) {
|
||||
ay = data_.yMax - (ay - data_.yMin);
|
||||
}
|
||||
|
||||
ax = clampValue(ax, data_.xMin, data_.xMax);
|
||||
ay = clampValue(ay, data_.yMin, data_.yMax);
|
||||
const int32_t dx = data_.xMax - data_.xMin;
|
||||
const int32_t dy = data_.yMax - data_.yMin;
|
||||
if (dx <= 0 || dy <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int32_t mappedX = ((ax - data_.xMin) * static_cast<int32_t>(screenW - 1U)) / dx;
|
||||
const int32_t mappedY = ((ay - data_.yMin) * static_cast<int32_t>(screenH - 1U)) / dy;
|
||||
*outX = static_cast<uint16_t>(clampValue<int32_t>(mappedX, 0, static_cast<int32_t>(screenW - 1U)));
|
||||
*outY = static_cast<uint16_t>(clampValue<int32_t>(mappedY, 0, static_cast<int32_t>(screenH - 1U)));
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
class TFT_eSPI;
|
||||
class XPT2046_Touchscreen;
|
||||
|
||||
class TouchCalibration {
|
||||
public:
|
||||
struct Data {
|
||||
bool valid = false;
|
||||
bool swapXY = false;
|
||||
bool invertX = false;
|
||||
bool invertY = false;
|
||||
int32_t xMin = 200;
|
||||
int32_t xMax = 3900;
|
||||
int32_t yMin = 200;
|
||||
int32_t yMax = 3900;
|
||||
};
|
||||
|
||||
bool begin();
|
||||
bool load();
|
||||
bool save() const;
|
||||
bool runWizard(TFT_eSPI& tft, XPT2046_Touchscreen& touch, uint16_t screenW, uint16_t screenH);
|
||||
bool mapRaw(int32_t rawX, int32_t rawY, uint16_t screenW, uint16_t screenH, uint16_t* outX, uint16_t* outY) const;
|
||||
|
||||
const Data& data() const;
|
||||
Data* mutableData();
|
||||
|
||||
private:
|
||||
bool captureRawPoint(TFT_eSPI& tft,
|
||||
XPT2046_Touchscreen& touch,
|
||||
int16_t targetX,
|
||||
int16_t targetY,
|
||||
int32_t* outRawX,
|
||||
int32_t* outRawY);
|
||||
void drawTarget(TFT_eSPI& tft, int16_t x, int16_t y, const char* label);
|
||||
|
||||
Data data_;
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
#include "uart_link.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
void UartLink::begin(HardwareSerial& serial, uint32_t baud, int8_t rxPin, int8_t txPin) {
|
||||
serial_ = &serial;
|
||||
(void)rxPin;
|
||||
(void)txPin;
|
||||
serial_->begin(baud);
|
||||
lineLen_ = 0U;
|
||||
droppingLine_ = false;
|
||||
}
|
||||
|
||||
void UartLink::setJsonHandler(JsonHandler handler, void* ctx) {
|
||||
handler_ = handler;
|
||||
handlerCtx_ = ctx;
|
||||
}
|
||||
|
||||
void UartLink::poll() {
|
||||
if (serial_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (serial_->available() > 0) {
|
||||
const int raw = serial_->read();
|
||||
if (raw < 0) {
|
||||
break;
|
||||
}
|
||||
const char c = static_cast<char>(raw);
|
||||
if (c == '\r') {
|
||||
continue;
|
||||
}
|
||||
if (c == '\n') {
|
||||
if (!droppingLine_) {
|
||||
lineBuf_[lineLen_] = '\0';
|
||||
processLine(lineBuf_);
|
||||
}
|
||||
lineLen_ = 0U;
|
||||
droppingLine_ = false;
|
||||
continue;
|
||||
}
|
||||
if (droppingLine_) {
|
||||
continue;
|
||||
}
|
||||
if (lineLen_ >= kLineMax) {
|
||||
droppingLine_ = true;
|
||||
lineLen_ = 0U;
|
||||
continue;
|
||||
}
|
||||
lineBuf_[lineLen_++] = c;
|
||||
}
|
||||
}
|
||||
|
||||
bool UartLink::processLine(const char* line) {
|
||||
if (line == nullptr || line[0] == '\0' || handler_ == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
StaticJsonDocument<640> doc;
|
||||
DeserializationError err = deserializeJson(doc, line);
|
||||
if (err) {
|
||||
return false;
|
||||
}
|
||||
handler_(doc, handlerCtx_);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UartLink::sendRawLine(const char* line) {
|
||||
if (serial_ == nullptr || line == nullptr) {
|
||||
return false;
|
||||
}
|
||||
serial_->print(line);
|
||||
serial_->print('\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UartLink::sendRequestState() {
|
||||
UiOutgoingCommand cmd;
|
||||
cmd.cmd = UiOutCmd::kRequestState;
|
||||
return sendCommand(cmd);
|
||||
}
|
||||
|
||||
bool UartLink::sendCommand(const UiOutgoingCommand& command) {
|
||||
if (serial_ == nullptr || command.cmd == UiOutCmd::kNone) {
|
||||
return false;
|
||||
}
|
||||
|
||||
StaticJsonDocument<192> doc;
|
||||
doc["t"] = "cmd";
|
||||
|
||||
switch (command.cmd) {
|
||||
case UiOutCmd::kPlayPause:
|
||||
doc["a"] = "play_pause";
|
||||
break;
|
||||
case UiOutCmd::kNext:
|
||||
doc["a"] = "next";
|
||||
break;
|
||||
case UiOutCmd::kPrev:
|
||||
doc["a"] = "prev";
|
||||
break;
|
||||
case UiOutCmd::kVolDelta:
|
||||
doc["a"] = "vol_delta";
|
||||
doc["v"] = command.value;
|
||||
break;
|
||||
case UiOutCmd::kVolSet:
|
||||
doc["a"] = "vol_set";
|
||||
doc["v"] = command.value;
|
||||
break;
|
||||
case UiOutCmd::kSourceSet:
|
||||
doc["a"] = "source_set";
|
||||
doc["v"] = command.textValue;
|
||||
break;
|
||||
case UiOutCmd::kSeek:
|
||||
doc["a"] = "seek";
|
||||
doc["v"] = command.value;
|
||||
break;
|
||||
case UiOutCmd::kStationDelta:
|
||||
doc["a"] = "station_delta";
|
||||
doc["v"] = command.value;
|
||||
break;
|
||||
case UiOutCmd::kRequestState:
|
||||
doc["a"] = "request_state";
|
||||
break;
|
||||
case UiOutCmd::kNone:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
serializeJson(doc, *serial_);
|
||||
serial_->print('\n');
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
#include "../include/ui_protocol.h"
|
||||
|
||||
class UartLink {
|
||||
public:
|
||||
using JsonHandler = void (*)(const JsonDocument& doc, void* ctx);
|
||||
|
||||
void begin(HardwareSerial& serial, uint32_t baud, int8_t rxPin, int8_t txPin);
|
||||
void setJsonHandler(JsonHandler handler, void* ctx);
|
||||
void poll();
|
||||
|
||||
bool sendCommand(const UiOutgoingCommand& command);
|
||||
bool sendRequestState();
|
||||
bool sendRawLine(const char* line);
|
||||
|
||||
private:
|
||||
bool processLine(const char* line);
|
||||
|
||||
HardwareSerial* serial_ = nullptr;
|
||||
JsonHandler handler_ = nullptr;
|
||||
void* handlerCtx_ = nullptr;
|
||||
|
||||
static constexpr size_t kLineMax = 512U;
|
||||
char lineBuf_[kLineMax + 1U] = {};
|
||||
size_t lineLen_ = 0U;
|
||||
bool droppingLine_ = false;
|
||||
};
|
||||
@@ -0,0 +1,396 @@
|
||||
#include "ui_renderer.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include <TFT_eSPI.h>
|
||||
|
||||
#include "../include/ui_config.h"
|
||||
#include "ui_state.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint16_t kBg = TFT_BLACK;
|
||||
constexpr uint16_t kFg = TFT_WHITE;
|
||||
constexpr uint16_t kAccent = 0x05FF;
|
||||
constexpr uint16_t kWarn = 0xFD20;
|
||||
constexpr uint16_t kOk = 0x07E0;
|
||||
constexpr uint16_t kPanel = 0x1082;
|
||||
constexpr uint16_t kPanelDark = 0x0841;
|
||||
|
||||
void splitTwoLines(const char* in, char* l1, size_t l1Len, char* l2, size_t l2Len) {
|
||||
if (l1Len > 0U) {
|
||||
l1[0] = '\0';
|
||||
}
|
||||
if (l2Len > 0U) {
|
||||
l2[0] = '\0';
|
||||
}
|
||||
if (in == nullptr || in[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
const size_t n = strlen(in);
|
||||
if (n <= 36U) {
|
||||
snprintf(l1, l1Len, "%s", in);
|
||||
return;
|
||||
}
|
||||
size_t split = 36U;
|
||||
while (split > 10U && in[split] != ' ') {
|
||||
--split;
|
||||
}
|
||||
if (split <= 10U) {
|
||||
split = 36U;
|
||||
}
|
||||
snprintf(l1, l1Len, "%.*s", static_cast<int>(split), in);
|
||||
while (in[split] == ' ') {
|
||||
++split;
|
||||
}
|
||||
snprintf(l2, l2Len, "%s", in + split);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
UiRenderer::UiRenderer(TFT_eSPI& tft) : tft_(tft) {}
|
||||
|
||||
void UiRenderer::begin() {
|
||||
tft_.fillScreen(kBg);
|
||||
tft_.setTextColor(kFg, kBg);
|
||||
}
|
||||
|
||||
void UiRenderer::drawBootScreen(const char* line1, const char* line2) {
|
||||
tft_.fillScreen(kBg);
|
||||
tft_.setTextColor(kAccent, kBg);
|
||||
tft_.setTextDatum(TC_DATUM);
|
||||
tft_.setTextFont(4);
|
||||
tft_.drawString("U-SON TOUCH UI", ui_config::kScreenWidth / 2, 70);
|
||||
tft_.setTextFont(2);
|
||||
tft_.setTextColor(kFg, kBg);
|
||||
tft_.drawString(line1 != nullptr ? line1 : "Booting...", ui_config::kScreenWidth / 2, 122);
|
||||
tft_.drawString(line2 != nullptr ? line2 : "", ui_config::kScreenWidth / 2, 146);
|
||||
tft_.setTextDatum(TL_DATUM);
|
||||
}
|
||||
|
||||
void UiRenderer::drawBottomButtons(const char* labels[5], uint16_t color) {
|
||||
const int16_t y = 250;
|
||||
const int16_t h = 70;
|
||||
const int16_t w = ui_config::kScreenWidth / 5;
|
||||
tft_.setTextDatum(MC_DATUM);
|
||||
tft_.setTextFont(2);
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
const int16_t x = i * w;
|
||||
tft_.fillRect(x + 1, y + 1, w - 2, h - 2, kPanelDark);
|
||||
tft_.drawRect(x + 1, y + 1, w - 2, h - 2, color);
|
||||
tft_.setTextColor(color, kPanelDark);
|
||||
tft_.drawString(labels[i], x + (w / 2), y + (h / 2));
|
||||
}
|
||||
tft_.setTextDatum(TL_DATUM);
|
||||
}
|
||||
|
||||
void UiRenderer::drawHeader(const UiStateModel& ui) {
|
||||
tft_.fillRect(0, 0, ui_config::kScreenWidth, 38, kPanelDark);
|
||||
tft_.drawLine(0, 37, ui_config::kScreenWidth, 37, kPanel);
|
||||
|
||||
const char* tabs[3] = {"LECTURE", "LISTE", "REGLAGES"};
|
||||
const int16_t tabW = ui_config::kScreenWidth / 3;
|
||||
tft_.setTextDatum(MC_DATUM);
|
||||
tft_.setTextFont(2);
|
||||
for (uint8_t i = 0U; i < 3U; ++i) {
|
||||
const bool active = (static_cast<uint8_t>(ui.page()) == i);
|
||||
const int16_t x = i * tabW;
|
||||
if (active) {
|
||||
tft_.fillRect(x + 2, 4, tabW - 4, 30, kAccent);
|
||||
} else {
|
||||
tft_.drawRect(x + 2, 4, tabW - 4, 30, kPanel);
|
||||
}
|
||||
tft_.setTextColor(active ? kBg : kFg, active ? kAccent : kPanelDark);
|
||||
tft_.drawString(tabs[i], x + (tabW / 2), 19);
|
||||
}
|
||||
tft_.setTextDatum(TL_DATUM);
|
||||
}
|
||||
|
||||
const char* UiRenderer::wifiModeLabel(uint8_t mode) const {
|
||||
switch (mode % 3U) {
|
||||
case 0:
|
||||
return "STA";
|
||||
case 1:
|
||||
return "AP";
|
||||
default:
|
||||
return "AUTO";
|
||||
}
|
||||
}
|
||||
|
||||
const char* UiRenderer::eqLabel(uint8_t preset) const {
|
||||
switch (preset % 4U) {
|
||||
case 0:
|
||||
return "FLAT";
|
||||
case 1:
|
||||
return "BASS";
|
||||
case 2:
|
||||
return "VOICE";
|
||||
default:
|
||||
return "TREBLE";
|
||||
}
|
||||
}
|
||||
|
||||
const char* UiRenderer::brightnessLabel(uint8_t level) const {
|
||||
switch (level % 4U) {
|
||||
case 0:
|
||||
return "25%";
|
||||
case 1:
|
||||
return "50%";
|
||||
case 2:
|
||||
return "75%";
|
||||
default:
|
||||
return "100%";
|
||||
}
|
||||
}
|
||||
|
||||
void UiRenderer::drawVuMeter(int16_t x, int16_t y, int16_t w, int16_t h, float vu) {
|
||||
tft_.drawRect(x, y, w, h, kPanel);
|
||||
const uint16_t fill = static_cast<uint16_t>((h - 2) * (vu < 0.0f ? 0.0f : (vu > 1.0f ? 1.0f : vu)));
|
||||
tft_.fillRect(x + 1, y + 1, w - 2, h - 2, kBg);
|
||||
if (fill > 0U) {
|
||||
const uint16_t c = (fill > (h * 3 / 4)) ? kWarn : kOk;
|
||||
tft_.fillRect(x + 1, y + h - 1 - fill, w - 2, fill, c);
|
||||
}
|
||||
}
|
||||
|
||||
void UiRenderer::drawProgressBar(int16_t x, int16_t y, int16_t w, int16_t h, int32_t pos, int32_t dur, bool live) {
|
||||
tft_.drawRect(x, y, w, h, kPanel);
|
||||
tft_.fillRect(x + 1, y + 1, w - 2, h - 2, kBg);
|
||||
if (live || dur <= 0) {
|
||||
tft_.setTextColor(kWarn, kBg);
|
||||
tft_.setTextFont(2);
|
||||
tft_.drawString("LIVE", x + 8, y + 4);
|
||||
return;
|
||||
}
|
||||
int32_t clampedPos = pos;
|
||||
if (clampedPos < 0) {
|
||||
clampedPos = 0;
|
||||
} else if (clampedPos > dur) {
|
||||
clampedPos = dur;
|
||||
}
|
||||
const uint16_t fill = static_cast<uint16_t>((static_cast<int64_t>(w - 2) * clampedPos) / dur);
|
||||
tft_.fillRect(x + 1, y + 1, fill, h - 2, kAccent);
|
||||
}
|
||||
|
||||
void UiRenderer::drawWrappedTitle(const char* title, int16_t x, int16_t y, int16_t w, uint32_t nowMs) {
|
||||
if (title == nullptr) {
|
||||
title = "";
|
||||
}
|
||||
if (strcmp(marqueeTitle_, title) != 0) {
|
||||
snprintf(marqueeTitle_, sizeof(marqueeTitle_), "%s", title);
|
||||
marqueeOffset_ = 0U;
|
||||
marqueeStartedMs_ = nowMs;
|
||||
lastMarqueeStepMs_ = nowMs;
|
||||
}
|
||||
|
||||
char line1[48] = {};
|
||||
char line2[48] = {};
|
||||
splitTwoLines(title, line1, sizeof(line1), line2, sizeof(line2));
|
||||
|
||||
tft_.setTextColor(kFg, kBg);
|
||||
tft_.setTextFont(4);
|
||||
if (line2[0] == '\0') {
|
||||
// Marquee only when single long line.
|
||||
const uint16_t textW = tft_.textWidth(line1);
|
||||
if (textW > static_cast<uint16_t>(w) &&
|
||||
static_cast<int32_t>(nowMs - marqueeStartedMs_) > static_cast<int32_t>(ui_config::kTxtMarqueeStartDelayMs) &&
|
||||
static_cast<int32_t>(nowMs - lastMarqueeStepMs_) > static_cast<int32_t>(ui_config::kTxtMarqueeStepMs)) {
|
||||
++marqueeOffset_;
|
||||
if (marqueeOffset_ > strlen(line1)) {
|
||||
marqueeOffset_ = 0U;
|
||||
}
|
||||
lastMarqueeStepMs_ = nowMs;
|
||||
}
|
||||
const char* start = line1 + marqueeOffset_;
|
||||
tft_.setViewport(x, y, w, 58, true);
|
||||
tft_.fillRect(0, 0, w, 58, kBg);
|
||||
tft_.drawString(start, 0, 0);
|
||||
if (tft_.textWidth(start) < static_cast<uint16_t>(w) && marqueeOffset_ > 0U) {
|
||||
tft_.drawString(" ", tft_.textWidth(start), 0);
|
||||
tft_.drawString(line1, tft_.textWidth(start) + 18, 0);
|
||||
}
|
||||
tft_.resetViewport();
|
||||
} else {
|
||||
tft_.fillRect(x, y, w, 58, kBg);
|
||||
tft_.drawString(line1, x, y);
|
||||
tft_.drawString(line2, x, y + 30);
|
||||
}
|
||||
}
|
||||
|
||||
void UiRenderer::drawNowPlaying(const UiStateModel& ui, uint32_t nowMs, bool full) {
|
||||
if (full) {
|
||||
tft_.fillRect(0, 38, ui_config::kScreenWidth, 212, kBg);
|
||||
}
|
||||
tft_.fillRect(10, 46, 98, 26, kPanelDark);
|
||||
tft_.drawRect(10, 46, 98, 26, kAccent);
|
||||
tft_.setTextColor(kAccent, kPanelDark);
|
||||
tft_.setTextFont(2);
|
||||
tft_.drawString(ui.source() == UiSource::kRadio ? "RADIO" : "SD", 22, 53);
|
||||
|
||||
tft_.fillRect(122, 46, 260, 20, kBg);
|
||||
tft_.setTextColor(kFg, kBg);
|
||||
tft_.setTextFont(2);
|
||||
tft_.drawString(ui.connected() ? "UART OK" : "CONNECTING...", 122, 49);
|
||||
|
||||
tft_.fillRect(392, 46, 82, 20, kBg);
|
||||
tft_.setTextColor(kFg, kBg);
|
||||
tft_.setTextFont(2);
|
||||
tft_.drawString(String(ui.rssi()) + "dBm", 392, 49);
|
||||
|
||||
drawWrappedTitle(ui.title(), 14, 80, 360, nowMs);
|
||||
|
||||
tft_.fillRect(14, 144, 360, 24, kBg);
|
||||
tft_.setTextColor(kPanel == 0 ? kFg : 0xC618, kBg);
|
||||
tft_.setTextFont(2);
|
||||
if (ui.source() == UiSource::kRadio) {
|
||||
tft_.drawString(ui.station(), 14, 148);
|
||||
} else {
|
||||
tft_.drawString(ui.artist(), 14, 148);
|
||||
}
|
||||
|
||||
drawProgressBar(16, 198, 360, 22, ui.posSec(), ui.durSec(), ui.source() == UiSource::kRadio);
|
||||
|
||||
tft_.fillRect(16, 224, 360, 20, kBg);
|
||||
tft_.setTextColor(kFg, kBg);
|
||||
tft_.setTextFont(2);
|
||||
tft_.drawString(ui.playing() ? "PLAY" : "PAUSE", 16, 226);
|
||||
tft_.drawString("VOL " + String(ui.volume()) + "%", 120, 226);
|
||||
if (ui.bufferPercent() >= 0) {
|
||||
tft_.drawString("BUF " + String(ui.bufferPercent()) + "%", 236, 226);
|
||||
}
|
||||
|
||||
drawVuMeter(402, 84, 58, 142, ui.vu());
|
||||
|
||||
const char* labels[5] = {"PREV", "PLAY", "NEXT", "VOL-", "VOL+"};
|
||||
drawBottomButtons(labels, kAccent);
|
||||
}
|
||||
|
||||
void UiRenderer::drawList(const UiStateModel& ui, bool full) {
|
||||
if (full) {
|
||||
tft_.fillRect(0, 38, ui_config::kScreenWidth, 212, kBg);
|
||||
}
|
||||
tft_.setTextColor(kAccent, kBg);
|
||||
tft_.setTextFont(2);
|
||||
tft_.fillRect(10, 44, 460, 22, kBg);
|
||||
tft_.drawString(String("Source: ") + (ui.source() == UiSource::kRadio ? "RADIO" : "SD"), 10, 46);
|
||||
|
||||
const UiRemoteList& list = ui.list();
|
||||
const uint8_t rows = (list.count > 4U) ? 4U : list.count;
|
||||
for (uint8_t i = 0U; i < 4U; ++i) {
|
||||
const int16_t y = 72 + (i * 42);
|
||||
const bool active = (i == ui.listCursor() && i < rows);
|
||||
tft_.fillRect(10, y, 460, 36, active ? kAccent : kPanelDark);
|
||||
tft_.drawRect(10, y, 460, 36, active ? kAccent : kPanel);
|
||||
tft_.setTextColor(active ? kBg : kFg, active ? kAccent : kPanelDark);
|
||||
tft_.setTextFont(2);
|
||||
if (i < rows) {
|
||||
tft_.drawString(list.items[i], 18, y + 10);
|
||||
} else {
|
||||
tft_.drawString("-", 18, y + 10);
|
||||
}
|
||||
}
|
||||
|
||||
tft_.fillRect(10, 240, 460, 10, kBg);
|
||||
tft_.setTextColor(0xC618, kBg);
|
||||
tft_.setTextFont(2);
|
||||
tft_.drawString(String("offset ") + list.offset + " / total " + list.total, 10, 240);
|
||||
|
||||
const char* labels[5] = {"UP", "DOWN", "OK", "BACK", "MODE"};
|
||||
drawBottomButtons(labels, kAccent);
|
||||
}
|
||||
|
||||
void UiRenderer::drawSettings(const UiStateModel& ui, bool full) {
|
||||
if (full) {
|
||||
tft_.fillRect(0, 38, ui_config::kScreenWidth, 212, kBg);
|
||||
}
|
||||
|
||||
const char* keys[4] = {"Wi-Fi", "EQ", "Luminosite", "Screensaver"};
|
||||
String vals[4] = {
|
||||
wifiModeLabel(ui.wifiMode()),
|
||||
eqLabel(ui.eqPreset()),
|
||||
brightnessLabel(ui.brightness()),
|
||||
ui.screensaver() ? "ON" : "OFF",
|
||||
};
|
||||
|
||||
for (uint8_t i = 0U; i < 4U; ++i) {
|
||||
const int16_t y = 62 + (i * 42);
|
||||
const bool active = (i == ui.settingsIndex());
|
||||
tft_.fillRect(10, y, 460, 36, active ? kAccent : kPanelDark);
|
||||
tft_.drawRect(10, y, 460, 36, active ? kAccent : kPanel);
|
||||
tft_.setTextColor(active ? kBg : kFg, active ? kAccent : kPanelDark);
|
||||
tft_.setTextFont(2);
|
||||
tft_.drawString(keys[i], 18, y + 10);
|
||||
tft_.setTextDatum(TR_DATUM);
|
||||
tft_.drawString(vals[i], 460, y + 18);
|
||||
tft_.setTextDatum(TL_DATUM);
|
||||
}
|
||||
|
||||
const char* labels[5] = {"UP", "DOWN", "APPLY", "BACK", "MODE"};
|
||||
drawBottomButtons(labels, kAccent);
|
||||
}
|
||||
|
||||
void UiRenderer::drawFrame(UiStateModel& ui) {
|
||||
drawHeader(ui);
|
||||
switch (ui.page()) {
|
||||
case UiPage::kNowPlaying:
|
||||
drawNowPlaying(ui, millis(), true);
|
||||
break;
|
||||
case UiPage::kList:
|
||||
drawList(ui, true);
|
||||
break;
|
||||
case UiPage::kSettings:
|
||||
drawSettings(ui, true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void UiRenderer::render(UiStateModel& ui, uint32_t nowMs, bool forceFull) {
|
||||
const uint8_t currentPage = static_cast<uint8_t>(ui.page());
|
||||
bool full = forceFull || (currentPage != lastPage_);
|
||||
if (full) {
|
||||
drawFrame(ui);
|
||||
lastPage_ = currentPage;
|
||||
lastPosSec_ = -1;
|
||||
lastVol_ = -1;
|
||||
lastBuffer_ = -2;
|
||||
lastRssi_ = -255;
|
||||
lastVu_ = -1.0f;
|
||||
lastConnected_ = !ui.connected();
|
||||
}
|
||||
|
||||
if (lastConnected_ != ui.connected()) {
|
||||
drawHeader(ui);
|
||||
lastConnected_ = ui.connected();
|
||||
}
|
||||
|
||||
switch (ui.page()) {
|
||||
case UiPage::kNowPlaying:
|
||||
if (full || lastPosSec_ != ui.posSec() || lastVol_ != ui.volume() || lastBuffer_ != ui.bufferPercent() ||
|
||||
lastRssi_ != ui.rssi() || fabs(lastVu_ - ui.vu()) > 0.05f ||
|
||||
static_cast<int32_t>(nowMs - lastMarqueeStepMs_) > static_cast<int32_t>(ui_config::kTxtMarqueeStepMs)) {
|
||||
drawNowPlaying(ui, nowMs, false);
|
||||
lastPosSec_ = ui.posSec();
|
||||
lastVol_ = ui.volume();
|
||||
lastBuffer_ = ui.bufferPercent();
|
||||
lastRssi_ = ui.rssi();
|
||||
lastVu_ = ui.vu();
|
||||
}
|
||||
break;
|
||||
case UiPage::kList:
|
||||
if (full || static_cast<int32_t>(nowMs - lastMarqueeStepMs_) > 180) {
|
||||
drawList(ui, false);
|
||||
lastMarqueeStepMs_ = nowMs;
|
||||
}
|
||||
break;
|
||||
case UiPage::kSettings:
|
||||
if (full || static_cast<int32_t>(nowMs - lastMarqueeStepMs_) > 180) {
|
||||
drawSettings(ui, false);
|
||||
lastMarqueeStepMs_ = nowMs;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
class TFT_eSPI;
|
||||
class UiStateModel;
|
||||
|
||||
class UiRenderer {
|
||||
public:
|
||||
explicit UiRenderer(TFT_eSPI& tft);
|
||||
|
||||
void begin();
|
||||
void drawBootScreen(const char* line1, const char* line2);
|
||||
void render(UiStateModel& ui, uint32_t nowMs, bool forceFull);
|
||||
|
||||
private:
|
||||
void drawFrame(UiStateModel& ui);
|
||||
void drawHeader(const UiStateModel& ui);
|
||||
void drawNowPlaying(const UiStateModel& ui, uint32_t nowMs, bool full);
|
||||
void drawList(const UiStateModel& ui, bool full);
|
||||
void drawSettings(const UiStateModel& ui, bool full);
|
||||
void drawBottomButtons(const char* labels[5], uint16_t color);
|
||||
void drawVuMeter(int16_t x, int16_t y, int16_t w, int16_t h, float vu);
|
||||
void drawProgressBar(int16_t x, int16_t y, int16_t w, int16_t h, int32_t pos, int32_t dur, bool live);
|
||||
void drawWrappedTitle(const char* title, int16_t x, int16_t y, int16_t w, uint32_t nowMs);
|
||||
const char* wifiModeLabel(uint8_t mode) const;
|
||||
const char* eqLabel(uint8_t preset) const;
|
||||
const char* brightnessLabel(uint8_t level) const;
|
||||
|
||||
TFT_eSPI& tft_;
|
||||
uint8_t lastPage_ = 255U;
|
||||
bool lastConnected_ = false;
|
||||
int32_t lastPosSec_ = -1;
|
||||
int32_t lastVol_ = -1;
|
||||
int32_t lastBuffer_ = -2;
|
||||
int32_t lastRssi_ = -255;
|
||||
float lastVu_ = -1.0f;
|
||||
char marqueeTitle_[96] = {};
|
||||
uint16_t marqueeOffset_ = 0U;
|
||||
uint32_t marqueeStartedMs_ = 0U;
|
||||
uint32_t lastMarqueeStepMs_ = 0U;
|
||||
};
|
||||
@@ -0,0 +1,392 @@
|
||||
#include "ui_state.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "../include/ui_config.h"
|
||||
|
||||
namespace {
|
||||
|
||||
void copyText(char* out, size_t len, const char* in) {
|
||||
if (out == nullptr || len == 0U) {
|
||||
return;
|
||||
}
|
||||
out[0] = '\0';
|
||||
if (in == nullptr) {
|
||||
return;
|
||||
}
|
||||
snprintf(out, len, "%s", in);
|
||||
}
|
||||
|
||||
int32_t clampI32(int32_t v, int32_t minV, int32_t maxV) {
|
||||
if (v < minV) {
|
||||
return minV;
|
||||
}
|
||||
if (v > maxV) {
|
||||
return maxV;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void UiStateModel::begin() {
|
||||
state_ = UiRemoteState();
|
||||
tick_ = UiRemoteTick();
|
||||
list_ = UiRemoteList();
|
||||
list_.source = state_.source;
|
||||
list_.total = 0U;
|
||||
list_.count = 0U;
|
||||
connected_ = false;
|
||||
lastHeartbeatMs_ = 0U;
|
||||
nextStateRequestMs_ = 0U;
|
||||
page_ = UiPage::kNowPlaying;
|
||||
listCursor_ = 0U;
|
||||
settingsIndex_ = 0U;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void UiStateModel::applyState(const UiRemoteState& state, uint32_t nowMs) {
|
||||
state_ = state;
|
||||
tick_.posSec = state.posSec;
|
||||
tick_.bufferPercent = state.bufferPercent;
|
||||
list_.source = state.source;
|
||||
connected_ = true;
|
||||
lastHeartbeatMs_ = nowMs;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void UiStateModel::applyTick(const UiRemoteTick& tick, uint32_t nowMs) {
|
||||
tick_ = tick;
|
||||
if (tick.posSec >= 0) {
|
||||
state_.posSec = tick.posSec;
|
||||
}
|
||||
if (tick.bufferPercent >= -1) {
|
||||
state_.bufferPercent = tick.bufferPercent;
|
||||
}
|
||||
connected_ = true;
|
||||
lastHeartbeatMs_ = nowMs;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void UiStateModel::applyList(const UiRemoteList& list, uint32_t nowMs) {
|
||||
list_ = list;
|
||||
hasRemoteList_ = true;
|
||||
if (listCursor_ >= list_.count) {
|
||||
listCursor_ = 0U;
|
||||
}
|
||||
connected_ = true;
|
||||
lastHeartbeatMs_ = nowMs;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void UiStateModel::onHeartbeat(uint32_t nowMs) {
|
||||
connected_ = true;
|
||||
lastHeartbeatMs_ = nowMs;
|
||||
}
|
||||
|
||||
void UiStateModel::updateConnection(uint32_t nowMs) {
|
||||
if (lastHeartbeatMs_ == 0U) {
|
||||
return;
|
||||
}
|
||||
if (static_cast<int32_t>(nowMs - lastHeartbeatMs_) > static_cast<int32_t>(ui_config::kHbTimeoutMs)) {
|
||||
if (connected_) {
|
||||
connected_ = false;
|
||||
markDirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool UiStateModel::shouldRequestState(uint32_t nowMs) {
|
||||
if (connected_) {
|
||||
return false;
|
||||
}
|
||||
if (static_cast<int32_t>(nowMs - nextStateRequestMs_) < 0) {
|
||||
return false;
|
||||
}
|
||||
nextStateRequestMs_ = nowMs + ui_config::kRequestStateRetryMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
void UiStateModel::setPage(UiPage page) {
|
||||
if (page_ == page) {
|
||||
return;
|
||||
}
|
||||
page_ = page;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void UiStateModel::toggleSource(UiOutgoingCommand* outCmd) {
|
||||
if (outCmd == nullptr) {
|
||||
return;
|
||||
}
|
||||
state_.source = (state_.source == UiSource::kSd) ? UiSource::kRadio : UiSource::kSd;
|
||||
list_.source = state_.source;
|
||||
outCmd->cmd = UiOutCmd::kSourceSet;
|
||||
copyText(outCmd->textValue, sizeof(outCmd->textValue), uiSourceToken(state_.source));
|
||||
markDirty();
|
||||
}
|
||||
|
||||
bool UiStateModel::buildDeltaCommand(int16_t delta, UiOutgoingCommand* outCmd) {
|
||||
if (outCmd == nullptr || delta == 0) {
|
||||
return false;
|
||||
}
|
||||
outCmd->cmd = UiOutCmd::kStationDelta;
|
||||
outCmd->value = delta;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UiStateModel::applyListDelta(int16_t delta, UiOutgoingCommand* outCmd) {
|
||||
if (delta == 0) {
|
||||
return false;
|
||||
}
|
||||
if (list_.count > 0U) {
|
||||
int16_t next = static_cast<int16_t>(listCursor_) + delta;
|
||||
if (next < 0) {
|
||||
next = 0;
|
||||
}
|
||||
if (next >= static_cast<int16_t>(list_.count)) {
|
||||
next = static_cast<int16_t>(list_.count - 1U);
|
||||
}
|
||||
listCursor_ = static_cast<uint8_t>(next);
|
||||
markDirty();
|
||||
}
|
||||
return buildDeltaCommand(delta, outCmd);
|
||||
}
|
||||
|
||||
bool UiStateModel::applySettingsDelta(int8_t delta, UiOutgoingCommand* outCmd) {
|
||||
if (delta == 0) {
|
||||
return false;
|
||||
}
|
||||
const int8_t next = static_cast<int8_t>(settingsIndex_) + delta;
|
||||
settingsIndex_ = static_cast<uint8_t>(clampI32(next, 0, 3));
|
||||
markDirty();
|
||||
(void)outCmd;
|
||||
return false;
|
||||
}
|
||||
|
||||
void UiStateModel::applySettingAction(UiOutgoingCommand* outCmd) {
|
||||
switch (settingsIndex_) {
|
||||
case 0: // Wi-Fi mode indicator only (cycles local state)
|
||||
wifiMode_ = static_cast<uint8_t>((wifiMode_ + 1U) % 3U);
|
||||
break;
|
||||
case 1: // EQ preset local only for now
|
||||
eqPreset_ = static_cast<uint8_t>((eqPreset_ + 1U) % 4U);
|
||||
break;
|
||||
case 2: // Brightness local
|
||||
brightness_ = static_cast<uint8_t>((brightness_ + 1U) % 4U);
|
||||
break;
|
||||
case 3: // Screensaver local
|
||||
screensaver_ = !screensaver_;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
markDirty();
|
||||
if (outCmd != nullptr && settingsIndex_ == 0U) {
|
||||
outCmd->cmd = UiOutCmd::kRequestState;
|
||||
}
|
||||
}
|
||||
|
||||
bool UiStateModel::handleNowTap(uint16_t x, uint16_t y, UiOutgoingCommand* outCmd) {
|
||||
if (y >= 250U) {
|
||||
const uint16_t col = x / (ui_config::kScreenWidth / 5U);
|
||||
switch (col) {
|
||||
case 0:
|
||||
outCmd->cmd = UiOutCmd::kPrev;
|
||||
return true;
|
||||
case 1:
|
||||
outCmd->cmd = UiOutCmd::kPlayPause;
|
||||
return true;
|
||||
case 2:
|
||||
outCmd->cmd = UiOutCmd::kNext;
|
||||
return true;
|
||||
case 3:
|
||||
outCmd->cmd = UiOutCmd::kVolDelta;
|
||||
outCmd->value = -2;
|
||||
return true;
|
||||
case 4:
|
||||
default:
|
||||
outCmd->cmd = UiOutCmd::kVolDelta;
|
||||
outCmd->value = 2;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Progress seek zone (SD only).
|
||||
if (y >= 200U && y <= 230U && state_.source == UiSource::kSd && state_.durSec > 1) {
|
||||
const uint16_t barX = 22U;
|
||||
const uint16_t barW = 360U;
|
||||
if (x >= barX && x <= (barX + barW)) {
|
||||
const uint32_t rel = static_cast<uint32_t>(x - barX);
|
||||
const uint32_t target =
|
||||
(rel * static_cast<uint32_t>(state_.durSec)) / static_cast<uint32_t>(barW);
|
||||
outCmd->cmd = UiOutCmd::kSeek;
|
||||
outCmd->value = static_cast<int32_t>(target);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Badge source toggle.
|
||||
if (x <= 108U && y <= 44U) {
|
||||
toggleSource(outCmd);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool UiStateModel::handleListTap(uint16_t x, uint16_t y, UiOutgoingCommand* outCmd) {
|
||||
if (y >= 250U) {
|
||||
const uint16_t col = x / (ui_config::kScreenWidth / 5U);
|
||||
switch (col) {
|
||||
case 0:
|
||||
return applyListDelta(-1, outCmd);
|
||||
case 1:
|
||||
return applyListDelta(1, outCmd);
|
||||
case 2:
|
||||
if (list_.count == 0U) {
|
||||
return false;
|
||||
}
|
||||
// Select highlighted line -> jump with delta relative to remote cursor if known.
|
||||
if (list_.count > listCursor_) {
|
||||
const int16_t absolute = static_cast<int16_t>(list_.offset + listCursor_);
|
||||
const int16_t remoteCursor = static_cast<int16_t>(list_.cursor);
|
||||
int16_t delta = absolute - remoteCursor;
|
||||
if (delta == 0) {
|
||||
outCmd->cmd = UiOutCmd::kPlayPause;
|
||||
return true;
|
||||
}
|
||||
delta = static_cast<int16_t>(clampI32(delta, -12, 12));
|
||||
return buildDeltaCommand(delta, outCmd);
|
||||
}
|
||||
return false;
|
||||
case 3:
|
||||
setPage(UiPage::kNowPlaying);
|
||||
return false;
|
||||
case 4:
|
||||
default:
|
||||
toggleSource(outCmd);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Tapping rows.
|
||||
if (y >= 56U && y <= 230U) {
|
||||
const uint8_t row = static_cast<uint8_t>((y - 56U) / 44U);
|
||||
if (row < list_.count && row < 4U) {
|
||||
listCursor_ = row;
|
||||
markDirty();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool UiStateModel::handleSettingsTap(uint16_t x, uint16_t y, UiOutgoingCommand* outCmd) {
|
||||
if (y >= 58U && y <= 226U) {
|
||||
const uint8_t row = static_cast<uint8_t>((y - 58U) / 42U);
|
||||
if (row < 4U) {
|
||||
settingsIndex_ = row;
|
||||
markDirty();
|
||||
}
|
||||
}
|
||||
|
||||
if (y >= 250U) {
|
||||
const uint16_t col = x / (ui_config::kScreenWidth / 5U);
|
||||
switch (col) {
|
||||
case 0:
|
||||
return applySettingsDelta(-1, outCmd);
|
||||
case 1:
|
||||
return applySettingsDelta(1, outCmd);
|
||||
case 2:
|
||||
applySettingAction(outCmd);
|
||||
return outCmd != nullptr && outCmd->cmd != UiOutCmd::kNone;
|
||||
case 3:
|
||||
setPage(UiPage::kNowPlaying);
|
||||
return false;
|
||||
case 4:
|
||||
default:
|
||||
toggleSource(outCmd);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool UiStateModel::onTap(uint16_t x, uint16_t y, uint32_t nowMs, UiOutgoingCommand* outCmd) {
|
||||
(void)nowMs;
|
||||
if (outCmd == nullptr) {
|
||||
return false;
|
||||
}
|
||||
*outCmd = UiOutgoingCommand();
|
||||
|
||||
// Header tabs.
|
||||
if (y <= 34U) {
|
||||
if (x < 160U) {
|
||||
setPage(UiPage::kNowPlaying);
|
||||
} else if (x < 320U) {
|
||||
setPage(UiPage::kList);
|
||||
} else {
|
||||
setPage(UiPage::kSettings);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (page_) {
|
||||
case UiPage::kNowPlaying:
|
||||
return handleNowTap(x, y, outCmd);
|
||||
case UiPage::kList:
|
||||
return handleListTap(x, y, outCmd);
|
||||
case UiPage::kSettings:
|
||||
return handleSettingsTap(x, y, outCmd);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool UiStateModel::onSwipe(int16_t dx, int16_t dy, uint32_t nowMs, UiOutgoingCommand* outCmd) {
|
||||
(void)nowMs;
|
||||
if (outCmd == nullptr) {
|
||||
return false;
|
||||
}
|
||||
*outCmd = UiOutgoingCommand();
|
||||
|
||||
const int16_t adx = (dx < 0) ? -dx : dx;
|
||||
const int16_t ady = (dy < 0) ? -dy : dy;
|
||||
if (adx >= ady) {
|
||||
outCmd->cmd = (dx > 0) ? UiOutCmd::kNext : UiOutCmd::kPrev;
|
||||
return true;
|
||||
}
|
||||
outCmd->cmd = UiOutCmd::kVolDelta;
|
||||
outCmd->value = (dy < 0) ? 2 : -2;
|
||||
return true;
|
||||
}
|
||||
|
||||
UiPage UiStateModel::page() const { return page_; }
|
||||
UiSource UiStateModel::source() const { return state_.source; }
|
||||
bool UiStateModel::connected() const { return connected_; }
|
||||
bool UiStateModel::playing() const { return state_.playing; }
|
||||
int32_t UiStateModel::volume() const { return clampI32(state_.volume, 0, 100); }
|
||||
int32_t UiStateModel::posSec() const { return state_.posSec; }
|
||||
int32_t UiStateModel::durSec() const { return state_.durSec; }
|
||||
int32_t UiStateModel::rssi() const { return state_.rssi; }
|
||||
int32_t UiStateModel::bufferPercent() const { return state_.bufferPercent; }
|
||||
float UiStateModel::vu() const { return clampI32(static_cast<int32_t>(tick_.vu * 100.0f), 0, 100) / 100.0f; }
|
||||
const char* UiStateModel::title() const { return state_.title; }
|
||||
const char* UiStateModel::artist() const { return state_.artist; }
|
||||
const char* UiStateModel::station() const { return state_.station; }
|
||||
const char* UiStateModel::error() const { return state_.error; }
|
||||
uint8_t UiStateModel::settingsIndex() const { return settingsIndex_; }
|
||||
uint8_t UiStateModel::wifiMode() const { return wifiMode_; }
|
||||
uint8_t UiStateModel::eqPreset() const { return eqPreset_; }
|
||||
uint8_t UiStateModel::brightness() const { return brightness_; }
|
||||
bool UiStateModel::screensaver() const { return screensaver_; }
|
||||
const UiRemoteList& UiStateModel::list() const { return list_; }
|
||||
uint8_t UiStateModel::listCursor() const { return listCursor_; }
|
||||
|
||||
void UiStateModel::markDirty() { dirty_ = true; }
|
||||
|
||||
bool UiStateModel::consumeDirty() {
|
||||
const bool was = dirty_;
|
||||
dirty_ = false;
|
||||
return was;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "../include/ui_protocol.h"
|
||||
|
||||
struct UiTouchPoint {
|
||||
uint16_t x = 0;
|
||||
uint16_t y = 0;
|
||||
bool pressed = false;
|
||||
};
|
||||
|
||||
class UiStateModel {
|
||||
public:
|
||||
void begin();
|
||||
|
||||
void applyState(const UiRemoteState& state, uint32_t nowMs);
|
||||
void applyTick(const UiRemoteTick& tick, uint32_t nowMs);
|
||||
void applyList(const UiRemoteList& list, uint32_t nowMs);
|
||||
void onHeartbeat(uint32_t nowMs);
|
||||
void updateConnection(uint32_t nowMs);
|
||||
|
||||
bool shouldRequestState(uint32_t nowMs);
|
||||
|
||||
bool onTap(uint16_t x, uint16_t y, uint32_t nowMs, UiOutgoingCommand* outCmd);
|
||||
bool onSwipe(int16_t dx, int16_t dy, uint32_t nowMs, UiOutgoingCommand* outCmd);
|
||||
|
||||
UiPage page() const;
|
||||
UiSource source() const;
|
||||
bool connected() const;
|
||||
bool playing() const;
|
||||
int32_t volume() const;
|
||||
int32_t posSec() const;
|
||||
int32_t durSec() const;
|
||||
int32_t rssi() const;
|
||||
int32_t bufferPercent() const;
|
||||
float vu() const;
|
||||
const char* title() const;
|
||||
const char* artist() const;
|
||||
const char* station() const;
|
||||
const char* error() const;
|
||||
|
||||
uint8_t settingsIndex() const;
|
||||
uint8_t wifiMode() const;
|
||||
uint8_t eqPreset() const;
|
||||
uint8_t brightness() const;
|
||||
bool screensaver() const;
|
||||
|
||||
const UiRemoteList& list() const;
|
||||
uint8_t listCursor() const;
|
||||
|
||||
bool consumeDirty();
|
||||
|
||||
private:
|
||||
void markDirty();
|
||||
void setPage(UiPage page);
|
||||
void toggleSource(UiOutgoingCommand* outCmd);
|
||||
bool handleNowTap(uint16_t x, uint16_t y, UiOutgoingCommand* outCmd);
|
||||
bool handleListTap(uint16_t x, uint16_t y, UiOutgoingCommand* outCmd);
|
||||
bool handleSettingsTap(uint16_t x, uint16_t y, UiOutgoingCommand* outCmd);
|
||||
bool applyListDelta(int16_t delta, UiOutgoingCommand* outCmd);
|
||||
bool applySettingsDelta(int8_t delta, UiOutgoingCommand* outCmd);
|
||||
void applySettingAction(UiOutgoingCommand* outCmd);
|
||||
bool buildDeltaCommand(int16_t delta, UiOutgoingCommand* outCmd);
|
||||
|
||||
UiPage page_ = UiPage::kNowPlaying;
|
||||
UiRemoteState state_;
|
||||
UiRemoteTick tick_;
|
||||
UiRemoteList list_;
|
||||
bool hasRemoteList_ = false;
|
||||
bool connected_ = false;
|
||||
uint32_t lastHeartbeatMs_ = 0U;
|
||||
uint32_t nextStateRequestMs_ = 0U;
|
||||
bool dirty_ = true;
|
||||
uint8_t listCursor_ = 0U;
|
||||
|
||||
uint8_t settingsIndex_ = 0U;
|
||||
uint8_t wifiMode_ = 0U;
|
||||
uint8_t eqPreset_ = 0U;
|
||||
uint8_t brightness_ = 2U;
|
||||
bool screensaver_ = false;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
|
||||
import serial
|
||||
|
||||
|
||||
MALFORMED = [
|
||||
"{",
|
||||
'{"t":"state","playing":true',
|
||||
'{"t":"state","title":"' + ("X" * 700) + '"}',
|
||||
"not_json_line",
|
||||
'{"t":123,"a":[]}',
|
||||
'{"t":"tick","vu":"bad"}',
|
||||
'{"t":"unknown","x":1}',
|
||||
]
|
||||
|
||||
VALID = [
|
||||
'{"t":"hb","ms":12345}',
|
||||
'{"t":"state","playing":true,"source":"radio","title":"Fuzz Station","artist":"","station":"U-SON","pos":0,"dur":0,"vol":33,"rssi":-70,"buffer":77,"error":""}',
|
||||
'{"t":"tick","pos":1,"buffer":76,"vu":0.42}',
|
||||
]
|
||||
|
||||
|
||||
def random_noise_line(max_len: int = 256) -> str:
|
||||
n = random.randint(1, max_len)
|
||||
return "".join(random.choice(string.printable) for _ in range(n))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="RP2040 UI protocol fuzzer")
|
||||
parser.add_argument("--port", required=True, help="Serial port")
|
||||
parser.add_argument("--baud", type=int, default=115200)
|
||||
parser.add_argument("--count", type=int, default=200)
|
||||
args = parser.parse_args()
|
||||
|
||||
ser = serial.Serial(args.port, args.baud, timeout=0.2)
|
||||
try:
|
||||
for i in range(args.count):
|
||||
if i % 10 == 0:
|
||||
line = random.choice(VALID)
|
||||
elif i % 3 == 0:
|
||||
line = random_noise_line()
|
||||
else:
|
||||
line = random.choice(MALFORMED)
|
||||
ser.write((line + "\n").encode("utf-8", errors="ignore"))
|
||||
time.sleep(0.01)
|
||||
return 0
|
||||
finally:
|
||||
ser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
|
||||
import serial
|
||||
|
||||
|
||||
def send_line(ser: serial.Serial, payload: dict) -> None:
|
||||
ser.write((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="RP2040 UI JSONL state/tick simulator")
|
||||
parser.add_argument("--port", required=True, help="Serial port (ex: /dev/tty.usbmodemXXXX)")
|
||||
parser.add_argument("--baud", type=int, default=115200)
|
||||
parser.add_argument("--source", choices=["sd", "radio"], default="radio")
|
||||
parser.add_argument("--title", default="Station Zacus Live")
|
||||
parser.add_argument("--station", default="U-SON FM")
|
||||
parser.add_argument("--artist", default="")
|
||||
parser.add_argument("--dur", type=int, default=0, help="seconds; 0 for radio/live")
|
||||
args = parser.parse_args()
|
||||
|
||||
ser = serial.Serial(args.port, args.baud, timeout=0.2)
|
||||
start = time.time()
|
||||
next_hb = 0.0
|
||||
next_state = 0.0
|
||||
next_tick = 0.0
|
||||
pos = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
now = time.time() - start
|
||||
if now >= next_state:
|
||||
send_line(
|
||||
ser,
|
||||
{
|
||||
"t": "state",
|
||||
"playing": True,
|
||||
"source": args.source,
|
||||
"title": args.title,
|
||||
"artist": args.artist,
|
||||
"station": args.station,
|
||||
"pos": pos,
|
||||
"dur": args.dur,
|
||||
"vol": 42,
|
||||
"rssi": -61,
|
||||
"buffer": 88 if args.source == "radio" else -1,
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
next_state += 1.0
|
||||
|
||||
if now >= next_tick:
|
||||
vu = 0.2 + 0.7 * abs(math.sin(now * 2.1))
|
||||
send_line(
|
||||
ser,
|
||||
{
|
||||
"t": "tick",
|
||||
"pos": pos,
|
||||
"buffer": 85 if args.source == "radio" else -1,
|
||||
"vu": round(vu, 3),
|
||||
},
|
||||
)
|
||||
next_tick += 0.2
|
||||
if args.dur > 0:
|
||||
pos = min(args.dur, pos + 1)
|
||||
|
||||
if now >= next_hb:
|
||||
send_line(ser, {"t": "hb", "ms": int(now * 1000)})
|
||||
next_hb += 1.0
|
||||
|
||||
time.sleep(0.02)
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
ser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user