diff --git a/build.sh b/build.sh index 48032e03..a826f6a3 100755 --- a/build.sh +++ b/build.sh @@ -23,12 +23,17 @@ FIRMWARE_FILENAME_INFIX="" ESP32_FULL_BUILD=0 SINGLE_TARGET_FULL_BUILD=0 RADIO_SETTINGS_API_URL="https://api.meshcore.nz/api/v1/config" -RADIO_SETTING_TITLE="" -RADIO_FREQ_OVERRIDE="" -RADIO_BW_OVERRIDE="" -RADIO_SF_OVERRIDE="" -RADIO_CR_OVERRIDE="" -FIRMWARE_PROFILE_OVERRIDE="${FIRMWARE_PROFILE_OVERRIDE:-}" +USA_CASCADIA_RADIO_TITLE="USA Cascadia" +USA_CASCADIA_FALLBACK_FREQ="910.525" +USA_CASCADIA_FALLBACK_BW="62.5" +USA_CASCADIA_FALLBACK_SF="7" +USA_CASCADIA_FALLBACK_CR="5" +RADIO_SETTING_TITLE="$USA_CASCADIA_RADIO_TITLE" +RADIO_FREQ_OVERRIDE="$USA_CASCADIA_FALLBACK_FREQ" +RADIO_BW_OVERRIDE="$USA_CASCADIA_FALLBACK_BW" +RADIO_SF_OVERRIDE="$USA_CASCADIA_FALLBACK_SF" +RADIO_CR_OVERRIDE="$USA_CASCADIA_FALLBACK_CR" +FIRMWARE_PROFILE_OVERRIDE="${FIRMWARE_PROFILE_OVERRIDE:-cascade}" BATCH_BUILD_MODE=0 OPTION3_BUILD_WORKERS="${OPTION3_BUILD_WORKERS:-2}" OPTION3_PIO_JOBS="${OPTION3_PIO_JOBS:-8}" @@ -101,8 +106,8 @@ Commands: Options: --firmware-version : Firmware version to embed. - --radio-preset : Use the numbered radio choice from the interactive menu (1 keeps target defaults). - --profile : Select the firmware settings profile. + --radio-preset : Override the USA Cascadia radio default. Stable names are usa-cascadia and target; legacy menu numbers remain accepted. + --profile : Override the default Cascade firmware settings profile. --skip-kiss|--include-kiss: Exclude (default) or include KISS modem targets in bulk builds. --clean|--resume: Clean output or resume existing Option 3/FULL-only artifacts. @@ -115,6 +120,11 @@ FULL-everything profile for supported ESP32 Option 1 targets, debug options, radio settings, firmware profile, and firmware version $ bash build.sh +Builds default to the live USA/Canada preset by name and the Cascade firmware +profile. If the preset service is offline, the radio fallback is 910.525 MHz / +BW62.5 / SF7 / CR5. To intentionally use a target's own defaults instead: +$ bash build.sh build-firmware RAK_4631_repeater --radio-preset target --profile default + Build all firmwares for device targets containing the string "RAK_4631" $ bash build.sh build-matching-firmwares @@ -672,11 +682,23 @@ clear_firmware_profile_overrides() { apply_cli_radio_preset() { local selection=$1 - local preset_output row + local preset_output row title description freq bw sf cr local -a preset_rows=() + case "${selection,,}" in + usa|usa-canada|usa-cascadia|cascadia) + resolve_usa_cascadia_radio_default + return 0 + ;; + default|target|target-defaults) + clear_radio_overrides + echo "Using target default radio settings." + return 0 + ;; + esac + if ! [[ "$selection" =~ ^[0-9]+$ ]] || [ "$selection" -lt 1 ]; then - echo "Invalid --radio-preset value: ${selection}" + echo "Invalid --radio-preset value: ${selection} (use usa-cascadia, target, or a legacy menu number)" return 1 fi clear_radio_overrides @@ -698,7 +720,7 @@ apply_cli_radio_preset() { row=${preset_rows[$((selection - 2))]} IFS=$'\t' read -r title description freq bw sf cr <<< "$row" set_radio_overrides "$title" "$freq" "$bw" "$sf" "$cr" - echo "Using radio setting ${selection}: ${RADIO_SETTING_TITLE} (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})" + echo "Using legacy numbered radio setting ${selection}: ${RADIO_SETTING_TITLE} (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})" } parse_cli_options() { @@ -819,6 +841,43 @@ for entry in entries: PY } +is_usa_cascadia_radio_title() { + local title=${1,,} + + [[ "$title" == usa*canada* ]] +} + +set_usa_cascadia_radio_fallback() { + set_radio_overrides \ + "$USA_CASCADIA_RADIO_TITLE" \ + "$USA_CASCADIA_FALLBACK_FREQ" \ + "$USA_CASCADIA_FALLBACK_BW" \ + "$USA_CASCADIA_FALLBACK_SF" \ + "$USA_CASCADIA_FALLBACK_CR" +} + +resolve_usa_cascadia_radio_default() { + local preset_output row title description freq bw sf cr + + set_usa_cascadia_radio_fallback + if ! preset_output=$(fetch_suggested_radio_settings) || [ -z "$preset_output" ]; then + echo "USA Cascadia preset lookup unavailable; using offline fallback (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})." + return 0 + fi + + while IFS= read -r row; do + [ -n "$row" ] || continue + IFS=$'\t' read -r title description freq bw sf cr <<< "$row" + if is_usa_cascadia_radio_title "$title"; then + set_radio_overrides "$USA_CASCADIA_RADIO_TITLE" "$freq" "$bw" "$sf" "$cr" + echo "Resolved USA Cascadia by preset name '${title}': ${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE}." + return 0 + fi + done <<< "$preset_output" + + echo "USA/Canada preset not found; using offline USA Cascadia fallback (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})." +} + is_valid_custom_radio_bandwidth() { python3 - "$1" <<'PY' import sys @@ -883,7 +942,15 @@ prompt_for_custom_radio_setting() { prompt_for_radio_build_settings() { local -a preset_rows=() local -a fetched_preset_rows=() - local -a options=("Keep target defaults (no radio override)") + local default_title=$RADIO_SETTING_TITLE + local default_freq=$RADIO_FREQ_OVERRIDE + local default_bw=$RADIO_BW_OVERRIDE + local default_sf=$RADIO_SF_OVERRIDE + local default_cr=$RADIO_CR_OVERRIDE + local -a options=( + "USA Cascadia (default): ${default_freq} MHz / SF${default_sf} / BW${default_bw} / CR${default_cr}" + "Keep target defaults (no radio override)" + ) local row local title local description @@ -896,8 +963,6 @@ prompt_for_radio_build_settings() { local custom_index local preset_output - clear_radio_overrides - if preset_output=$(fetch_suggested_radio_settings); then if [ -n "$preset_output" ]; then mapfile -t fetched_preset_rows <<< "$preset_output" @@ -906,6 +971,10 @@ prompt_for_radio_build_settings() { if [ -z "$row" ]; then continue fi + IFS=$'\t' read -r title description freq bw sf cr <<< "$row" + if is_usa_cascadia_radio_title "$title"; then + continue + fi preset_rows+=("$row") done else @@ -934,6 +1003,13 @@ prompt_for_radio_build_settings() { choice_index=$MENU_CHOICE if [ "$choice_index" -eq 1 ]; then + set_radio_overrides "$default_title" "$default_freq" "$default_bw" "$default_sf" "$default_cr" + echo "Using radio setting: ${RADIO_SETTING_TITLE} (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})" + return 0 + fi + + if [ "$choice_index" -eq 2 ]; then + clear_radio_overrides echo "Using target default radio settings." return 0 fi @@ -944,7 +1020,7 @@ prompt_for_radio_build_settings() { return 0 fi - preset_index=$((choice_index - 2)) + preset_index=$((choice_index - 3)) if [ "$preset_index" -ge 0 ] && [ "$preset_index" -lt "${#preset_rows[@]}" ]; then IFS=$'\t' read -r title description freq bw sf cr <<< "${preset_rows[$preset_index]}" set_radio_overrides "$title" "$freq" "$bw" "$sf" "$cr" @@ -956,12 +1032,10 @@ prompt_for_radio_build_settings() { prompt_for_firmware_profile_settings() { local -a options=( + "Cascade (default): power saving + RXPS on / WiFi power save=min / path.hash.mode=2 / loop.detect=minimal / cad=on / rxdelay=2 / agc.reset.interval=8 / advert.interval=0 / flood.advert.interval=83 / multi.acks=1 / companion.manual.add=1 / companion.autoadd=0" "Keep target defaults" - "Cascade: power saving + RXPS on / WiFi power save=min / path.hash.mode=2 / loop.detect=minimal / cad=on / rxdelay=2 / agc.reset.interval=8 / advert.interval=0 / flood.advert.interval=83 / multi.acks=1 / companion.manual.add=1 / companion.autoadd=0" ) - clear_firmware_profile_overrides - echo "Set firmware profile options:" while true; do print_numbered_menu "${options[@]}" @@ -973,12 +1047,13 @@ prompt_for_firmware_profile_settings() { case "$MENU_CHOICE" in 1) - echo "Using target default firmware profile settings." + set_firmware_profile_override "cascade" + echo "Using firmware profile: Cascade" return 0 ;; 2) - set_firmware_profile_override "cascade" - echo "Using firmware profile: Cascade" + clear_firmware_profile_overrides + echo "Using target default firmware profile settings." return 0 ;; esac @@ -4092,6 +4167,8 @@ main() { if ! apply_cli_radio_preset "$RADIO_PRESET_SELECTION"; then exit 1 fi + else + resolve_usa_cascadia_radio_default fi if [ $# -eq 0 ]; then diff --git a/docs/WiFi.md b/docs/WiFi.md index ed3062a6..088b76cc 100644 --- a/docs/WiFi.md +++ b/docs/WiFi.md @@ -419,6 +419,16 @@ receives their final reply. into a selected target; it does not change that target into another firmware role. +This fork defaults every `build.sh` target to the USA Cascadia radio preset and +the Cascade firmware profile. The script resolves the live `USA/Canada` entry +by name, so its changing number in the downloaded menu does not affect builds. +If the preset service is unavailable, it falls back to +`910.525 MHz / BW62.5 / SF7 / CR5`. Use +`--radio-preset target --profile default` only when a build intentionally needs +the target's original radio and profile defaults. `--radio-preset usa-cascadia` +is the stable explicit name; legacy numbered choices remain accepted but their +meaning can change when the service reorders or adds presets. + | Build profile | WiFi/MQTT behavior | |---|---| | Standard | Uses the selected target's role. Ordinary legacy-slot ESP32 repeater/room-server artifacts omit WebConfig when needed to fit. ESP32 MQTT observer and ESP-NOW bridge targets are automatically promoted to FULL; WiFi-companion targets keep their companion partition profile. | @@ -461,7 +471,7 @@ Fresh Cascade-profile builds default to `min`; target-default builds use highest power use. `min` and `max` reduce power but may add latency or reduce reliability on busy nodes. A saved operator setting takes precedence on an upgrade. ESP32 WiFi Companions expose the same values in their WebConfig WiFi -card. Full Companion also accepts the text commands from its USB terminal and +card and USB text terminal. Full Companion also accepts the text commands from TCP port 5002. The normal binary Companion protocol can read or write the setting over USB, BLE, or TCP port 5000 without entering terminal mode. Full Companion rejects `none` because its simultaneous BLE transport requires WiFi @@ -475,9 +485,9 @@ WiFi only, not LoRa transmit power. `get wifi.status`, `get wifi.ssid`, `get wifi.powersave`, and `get wifi.cli` are available on MQTT observers and on FULL non-MQTT repeater/room-server -builds with WebConfig. ESP32 WiFi Companions expose `wifi.powersave` through -their role-specific interfaces described above; they do not expose the full -infrastructure WiFi CLI family. +builds with WebConfig. ESP32 WiFi Companions with WebConfig expose those +commands, credential setters, and `start webconfig [ap]` through their USB text +terminal; Full Companion additionally exposes that terminal on TCP port 5002. MQTT commands such as `get mqtt.status` and `set mqtt1.preset ...` still require an MQTT observer target. Unknown settings return `Error: unknown setting: `. Older firmware that used the discontinued compact CLI can instead @@ -518,5 +528,8 @@ Common causes are: - the wrong firmware role or an older compact-CLI build without WebConfig. For a WiFi companion, find its station IP in the router, connect the client to -TCP port 5000, and use the setup AP if it cannot join the saved network. MQTT -diagnostics apply only to a `wifi_mqtt` companion target. +TCP port 5000, and use the open `MeshCore-Setup-XXXX` AP at +`http://192.168.4.1/` if it cannot join the saved network. Current firmware +normalizes both ESP32 WiFi interfaces to standard b/g/n before advertising the +setup AP, including after an ESP-NOW image used its long-range protocol mode. +MQTT diagnostics apply only to a `wifi_mqtt` companion target. diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index 5c9013f0..9307c1ab 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -126,10 +126,11 @@ does not exist on that target: - WebConfig and the `wifi.ssid`, `wifi.status`, `wifi.powersave`, and `wifi.cli` command family require an ESP32 WebConfig build. FULL standalone repeater and room-server builds support the corresponding WiFi setters and status - commands. ESP32 WiFi Companion exposes `wifi.powersave` in its WebConfig - form and binary protocol; Full Companion exposes its complete role-specific - text terminal over USB and TCP port 5002, including WiFi credentials, - connection status, WebConfig, CLI-tab, and power-save controls. + commands. ESP32 WiFi Companions with WebConfig expose WiFi credentials, + connection status, WebConfig, CLI-tab, and power-save controls from their USB + text terminal as well as power saving through WebConfig and the binary + protocol. Full Companion additionally exposes its complete role-specific + text terminal on TCP port 5002. - MQTT commands require an MQTT observer target. - `discover.scopes` requires an MQTT observer with compiled neighbor support; it does not independently require PSRAM or the FULL parser. diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 15b6a232..961a9fda 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -252,11 +252,12 @@ take effect on the next connection. `get wifi.pwd` is intentionally unavailable so the standalone password is never returned by the CLI. ESP32 WiFi Companion WebConfig exposes the same `wifi.powersave` values in its -WiFi card. Full Companion exposes the same role-specific text terminal over -USB and TCP port 5002, including the standalone `wifi.ssid`, `wifi.status`, -`wifi.powersave`, `wifi.cli`, and WebConfig command families. Credential -writes reply before restarting the WiFi station, so a TCP client should expect -to reconnect at the new address; USB password input is masked. Binary +WiFi card. Every ESP32 WiFi Companion with WebConfig exposes the standalone +`wifi.ssid`, `wifi.status`, `wifi.powersave`, `wifi.cli`, and WebConfig command +families through its USB text terminal. Full Companion exposes the same +role-specific terminal on TCP port 5002. Credential writes reply before +restarting the WiFi station, so a TCP client should expect to reconnect at the +new address; USB password input is masked. Binary Companion clients can use command bytes `0x46` and `0x47` over USB, BLE, or TCP port 5000 without the terminal-start token. WiFi-only Companions accept all three modes; Full Companion rejects diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index fe158628..415b41a8 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -13,6 +13,10 @@ #include "NodePrefs.h" +#ifndef UI_USB_AUTO_OFF_MULTIPLIER + #define UI_USB_AUTO_OFF_MULTIPLIER 5UL +#endif + enum class UIEventType { none, contactMessage, @@ -35,11 +39,14 @@ protected: bool isDisplayAutoOffDue(unsigned long configured_deadline, unsigned long configured_timeout_millis) const { unsigned long deadline = configured_deadline; +#if UI_USB_AUTO_OFF_MULTIPLIER > 1 if (_board->isUsbHostConnected()) { // The configured deadline already includes the first timeout period. - // Add four more periods for a total of 5x while attached to a computer. - deadline += configured_timeout_millis * 4UL; + // Add the remaining periods while attached to a computer. + deadline += configured_timeout_millis + * (UI_USB_AUTO_OFF_MULTIPLIER - 1UL); } +#endif return static_cast(millis() - deadline) > 0; } @@ -59,7 +66,10 @@ public: void enableBluetooth() { _interfaceManager->enableBluetooth(); } void disableBluetooth() { _interfaceManager->disableBluetooth(); } virtual void msgRead(int msgcount) = 0; - virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0; + virtual void newMsg(uint8_t path_len, const char* from_name, + const char* text, int msgcount, + int channel_idx = -1, + const char* channel_name = nullptr) = 0; virtual void notify(UIEventType t = UIEventType::none) = 0; virtual void loop() = 0; }; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b7a44c37..88354562 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -847,9 +847,26 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) { } bool MyMesh::allowPacketForward(const mesh::Packet* packet) { - return _prefs.isRepeatEn(); + if (!_prefs.isRepeatEn()) return false; +#ifdef COMPANION_MESH_CLOCK_SYNC + _clock_sync.observeAcceptedFlood(packet); +#endif + return true; } +#ifdef COMPANION_MESH_CLOCK_SYNC +void MyMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, + uint32_t timestamp, const uint8_t* app_data, + size_t app_data_len) { + BaseChatMesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); + _clock_sync.observeVerifiedAdvert(packet, id, timestamp); +} + +void MyMesh::onGroupPacketRecv(mesh::Packet* packet) { + _clock_sync.observeGroupPacket(packet); +} +#endif + bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const { if (packet == NULL) return false; // A companion may retry its own advert once, using the core's deliberately @@ -982,7 +999,13 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe if (getChannel(channel_idx, channel_details)) { channel_name = channel_details.name; } - if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len); + char channel_label[64]; + snprintf(channel_label, sizeof(channel_label), "Ch %u %s", + (unsigned int)channel_idx, channel_name); + if (_ui) { + _ui->newMsg(path_len, channel_label, text, offline_queue_len, + channel_idx, channel_name); + } #endif if (pkt->isRouteFlood() && is_emergency_channel) { @@ -1357,6 +1380,10 @@ void MyMesh::onSendTimeout() { MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui) : BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables), +#ifdef COMPANION_MESH_CLOCK_SYNC + _clock_sync(radio, _clock_sync_millis, rtc, _clock_sync_acl, sensors, + _prefs.airtime_factor), +#endif _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) { _iter_started = false; _cli_rescue = false; @@ -1606,6 +1633,12 @@ void MyMesh::begin(bool has_display, bool radio_available) { _store->saveChannels(this); } +#ifdef COMPANION_MESH_CLOCK_SYNC + // Fixed fallback policy for this Companion build. A successful host time + // update below suppresses mesh correction for the remainder of the boot. + _clock_sync.begin(nullptr); +#endif + configureRadioFromPrefs(); #if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID) @@ -1808,9 +1841,10 @@ void MyMesh::scheduleNormalRadio(char* reply, size_t reply_size) { _temp_radio_failures = 0; snprintf(reply, reply_size, "OK - normal radio restore scheduled"); } +#endif -bool MyMesh::handleFullOtaCommand(const char* command, char* reply, - size_t reply_size) { +bool MyMesh::handleLocalControlCommand(const char* command, char* reply, + size_t reply_size) { if (!command || !reply || reply_size == 0) return false; while (*command == ' ') command++; @@ -1971,6 +2005,7 @@ bool MyMesh::handleFullOtaCommand(const char* command, char* reply, } #endif +#if defined(COMPANION_RADIO_FULL) if (strcmp(command, "tempradio") == 0) { if (_temp_radio_set_at) { snprintf(reply, reply_size, "TempRadio pending: %.3f,%.2f,%u,%u", @@ -2021,10 +2056,12 @@ bool MyMesh::handleFullOtaCommand(const char* command, char* reply, snprintf(reply, reply_size, "%s", ota_reply); return true; } +#endif return false; } +#if defined(COMPANION_RADIO_FULL) void MyMesh::serviceTempRadio() { const unsigned long now = _ms->getMillis(); const bool retry_ready = !_temp_radio_retry_at @@ -2964,6 +3001,9 @@ void MyMesh::handleCmdFrame(size_t len) { uint32_t curr = getRTCClock()->getCurrentTime(); if (secs >= curr) { getRTCClock()->setCurrentTime(secs); +#ifdef COMPANION_MESH_CLOCK_SYNC + _clock_sync.onManualClockSet(); +#endif writeOKFrame(); } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); @@ -4753,13 +4793,11 @@ void MyMesh::handleTerminalCommand(char* command) { if (*command == 0) return; mesh::cli::normalizeCommandVerb(command); -#if defined(COMPANION_RADIO_FULL) - char full_reply[160]; - if (handleFullOtaCommand(command, full_reply, sizeof(full_reply))) { - terminalOutput().printf(" %s\r\n", full_reply); + char local_reply[160]; + if (handleLocalControlCommand(command, local_reply, sizeof(local_reply))) { + terminalOutput().printf(" %s\r\n", local_reply); return; } -#endif mesh::cli::TerminalChannelMessage channel_message; const mesh::cli::TerminalChannelCommandMatch channel_match = @@ -4869,6 +4907,9 @@ void MyMesh::handleTerminalCommand(char* command) { uint32_t current = getRTCClock()->getCurrentTime(); if (timestamp >= current) { getRTCClock()->setCurrentTime(timestamp); +#ifdef COMPANION_MESH_CLOCK_SYNC + _clock_sync.onManualClockSet(); +#endif terminalOutput().print(" OK - clock set\r\n"); } else { terminalOutput().print(" ERROR: clock cannot go backwards\r\n"); @@ -5518,6 +5559,9 @@ void MyMesh::loop() { serviceTempRadio(); #endif BaseChatMesh::loop(); +#ifdef COMPANION_MESH_CLOCK_SYNC + _clock_sync.loop(); +#endif #ifdef ENABLE_USB_INTERFACE serviceTerminalLogin(); serviceTerminalCommand(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 7a98e91d..b895a311 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -47,6 +47,11 @@ #include #include +#ifdef COMPANION_MESH_CLOCK_SYNC +#include +#include +#endif + /* ---------------------------------- CONFIGURATION ------------------------------------- */ #ifndef LORA_FREQ @@ -171,11 +176,10 @@ public: #endif #endif -#if defined(COMPANION_RADIO_FULL) - // Local-only control surface shared by the USB terminal and WiFi port 5002. - // Supports bounded TempRadio windows plus the serve-only `ota ...` CLI. - bool handleFullOtaCommand(const char* command, char* reply, size_t reply_size); -#endif + // Local control shared by every USB terminal. ESP32 WiFi companions expose + // setup/status commands here; Full builds add TempRadio and OTA commands. + bool handleLocalControlCommand(const char* command, char* reply, + size_t reply_size); int getRecentlyHeard(AdvertPath dest[], int max_num); @@ -200,6 +204,12 @@ protected: bool filterRecvFloodPacket(mesh::Packet* packet) override; bool allowPacketForward(const mesh::Packet* packet) override; bool allowFloodRetry(const mesh::Packet* packet) const override; +#ifdef COMPANION_MESH_CLOCK_SYNC + void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, + uint32_t timestamp, const uint8_t* app_data, + size_t app_data_len) override; + void onGroupPacketRecv(mesh::Packet* packet) override; +#endif bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis); bool sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override; @@ -362,6 +372,11 @@ private: DataStore* _store; CompanionNodePrefs _prefs; +#ifdef COMPANION_MESH_CLOCK_SYNC + ArduinoMillis _clock_sync_millis; + ClientACL _clock_sync_acl; + mesh::MeshClockSync _clock_sync; +#endif #if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID) MQTTPrefs _mqtt_prefs; MQTTBridge* _mqtt_bridge; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 9f537376..a697bc04 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -370,7 +370,7 @@ static void resetUsbMotaMode() { static void leaveUsbMotaMode(bool acknowledge) { char reply[160] = {0}; - the_mesh.handleFullOtaCommand("ota folder off", reply, sizeof(reply)); + the_mesh.handleLocalControlCommand("ota folder off", reply, sizeof(reply)); if (acknowledge) { Serial.print("\r\n"); Serial.print(reply); @@ -388,7 +388,7 @@ static void enterUsbMotaMode() { usb_mota_disconnect_armed = isUsbTerminalDataConnected(); char reply[160] = {0}; - if (!the_mesh.handleFullOtaCommand("ota folder on", reply, sizeof(reply)) + if (!the_mesh.handleLocalControlCommand("ota folder on", reply, sizeof(reply)) || strncmp(reply, "ERR", 3) == 0) { Serial.print("\r\n"); Serial.print(reply[0] ? reply : "ERR could not enter mOTA seeder mode"); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bf78fbbf..407b7072 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -18,6 +18,9 @@ #ifndef USB_MESSAGE_PREVIEW_MILLIS #define USB_MESSAGE_PREVIEW_MILLIS 15000UL #endif +#ifndef UI_RADIO_REFRESH_MILLIS + #define UI_RADIO_REFRESH_MILLIS 2000UL +#endif #ifndef BLE_PAIRING_DISPLAY_MILLIS #define BLE_PAIRING_DISPLAY_MILLIS 120000UL #endif @@ -126,7 +129,7 @@ class HomeScreen : public UIScreen { AdvertPath recent[UI_RECENT_LIST_SIZE]; - void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { + int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { // Convert millivolts to percentage #ifndef BATT_MIN_MILLIVOLTS #define BATT_MIN_MILLIVOLTS 3000 @@ -136,14 +139,19 @@ class HomeScreen : public UIScreen { #endif const int minMilliVolts = BATT_MIN_MILLIVOLTS; const int maxMilliVolts = BATT_MAX_MILLIVOLTS; - int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts); + const bool showBattery = batteryMilliVolts != 0; + int batteryPercentage = showBattery + ? ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts) + : 0; if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0% if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100% // battery icon int iconWidth = 24; int iconHeight = 10; - int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner + int iconX = showBattery + ? display.width() - iconWidth - 5 + : display.width() - 5; int iconY = 0; display.setColor(UIColor::title_txt); @@ -178,25 +186,28 @@ class HomeScreen : public UIScreen { #ifdef PIN_BUZZER if (muted) statusWidth += 9; #endif - display.setCursor(iconX - statusWidth - uptimeWidth - spaceWidth, iconY); + int statusStartX = iconX - statusWidth - uptimeWidth - spaceWidth; + display.setCursor(statusStartX, iconY); display.print(uptime); - // battery outline - display.drawRect(iconX, iconY, iconWidth, iconHeight); + if (showBattery) { + // battery outline + display.drawRect(iconX, iconY, iconWidth, iconHeight); - // battery "cap" - display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2); + // battery "cap" + display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2); - // fill the battery based on the percentage - int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; - display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4); + // fill the battery based on the percentage + int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; + display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4); + } // Show external power beside the battery. Most boards have no // charge-complete signal, so use a high percentage band for the plug. if (charging) { static constexpr int BATT_FULL_PCT = 95; const uint8_t* symbol = - batteryPercentage >= BATT_FULL_PCT ? plug_icon : charging_icon; + !showBattery || batteryPercentage >= BATT_FULL_PCT ? plug_icon : charging_icon; display.setColor(UIColor::title_txt); display.drawXbm(iconX - 9, iconY + 1, symbol, 8, 8); } @@ -208,6 +219,8 @@ class HomeScreen : public UIScreen { display.drawXbm(iconX - (charging ? 18 : 9), iconY + 1, muted_icon, 8, 8); } #endif + + return statusStartX; } CayenneLPP sensors_lpp; @@ -255,16 +268,18 @@ public: display.setColor(UIColor::title_bkg); display.fillRect(0, 0, display.width(), 12); char tmp[80]; - // node name + // status indicators display.setTextSize(1); display.setColor(UIColor::title_txt); + int statusStartX = renderBatteryIndicator(display, _task->getBattMilliVolts()); + + // node name char filtered_name[sizeof(_node_prefs->node_name)]; display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); - display.setCursor(0, 2); - display.print(filtered_name); - - // battery voltage - renderBatteryIndicator(display, _task->getBattMilliVolts()); + int availableNameWidth = statusStartX - 2; + if (availableNameWidth > 0) { + display.drawTextEllipsized(0, 2, availableNameWidth, filtered_name); + } // curr page indicator if (UIColor::title_bkg == UIColor::window_bkg) { @@ -285,8 +300,12 @@ public: if (_page == HomePage::FIRST) { display.setColor(UIColor::primary_txt); display.setTextSize(2); - sprintf(tmp, "MSG: %d", _task->getMsgCount()); + sprintf(tmp, "INBOX: %d", _task->getPreviewCount()); display.drawTextCentered(display.width() / 2, 22, tmp); + display.setTextSize(1); + display.setColor(UIColor::secondary_txt); + display.drawTextCentered(display.width() / 2, 43, + "tap center: inbox"); #ifdef UI_SHOW_CLOCK display.setTextSize(3); @@ -371,7 +390,12 @@ public: sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); display.print(tmp); display.setCursor(0, 53); - sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); + float noise_floor = radio_driver.getNoiseFloorDbm(); + if (noise_floor == 0.0f) { + strcpy(tmp, "Noise floor: measuring"); + } else { + snprintf(tmp, sizeof(tmp), "Noise floor: %.1f", noise_floor); + } display.print(tmp); } else if (_page == HomePage::BLUETOOTH) { display.setColor(UIColor::corp_blue); @@ -522,7 +546,7 @@ public: } #endif } - return 5000; // next render after 5000 ms + return _page == HomePage::RADIO ? UI_RADIO_REFRESH_MILLIS : 5000; } bool handleInput(char c) override { @@ -545,6 +569,10 @@ public: } return true; } + if (c == KEY_ENTER && _page == HomePage::FIRST) { + _task->showMessages(); + return true; + } if (c == KEY_ENTER && _page == HomePage::ADVERT) { _task->notify(UIEventType::ack); if (the_mesh.advert()) { @@ -585,42 +613,184 @@ class MsgPreviewScreen : public UIScreen { UITask* _task; mesh::RTCClock* _rtc; + static constexpr int CHANNEL_FILTER_ALL = -2; + static constexpr int CHANNEL_FILTER_DIRECT = -1; + struct MsgEntry { uint32_t timestamp; + int channel_idx; + char channel_name[32]; char origin[62]; char msg[UI_MSG_PREVIEW_SIZE]; }; #define MAX_UNREAD_MSGS 32 int num_unread; + int view_offset; + int channel_filter; int head = MAX_UNREAD_MSGS - 1; // index of latest unread message MsgEntry unread[MAX_UNREAD_MSGS]; -public: - MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc) { num_unread = 0; } + bool matchesFilter(const MsgEntry& entry) const { + return channel_filter == CHANNEL_FILTER_ALL + || entry.channel_idx == channel_filter; + } - void addPreview(uint8_t path_len, const char* from_name, const char* msg) { + int filteredCount() const { + int count = 0; + for (int age = 0; age < num_unread; ++age) { + int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS; + if (matchesFilter(unread[index])) ++count; + } + return count; + } + + const MsgEntry* filteredEntry(int offset) const { + int match = 0; + for (int age = 0; age < num_unread; ++age) { + int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS; + if (!matchesFilter(unread[index])) continue; + if (match++ == offset) return &unread[index]; + } + return nullptr; + } + + int buildChannelFilters(int* filters, int capacity) const { + int count = 0; + if (count < capacity) filters[count++] = CHANNEL_FILTER_ALL; + for (int channel_idx = 0; + channel_idx < MAX_GROUP_CHANNELS && count < capacity; + ++channel_idx) { + ChannelDetails details; + if (the_mesh.getChannel(channel_idx, details) + && details.name[0] != 0) { + filters[count++] = channel_idx; + } + } + if (count < capacity) filters[count++] = CHANNEL_FILTER_DIRECT; + return count; + } + + void cycleChannelFilter(int direction) { + int filters[MAX_GROUP_CHANNELS + 2]; + int count = buildChannelFilters( + filters, sizeof(filters) / sizeof(filters[0])); + if (count == 0) return; + + int selected = 0; + while (selected < count && filters[selected] != channel_filter) { + ++selected; + } + if (selected == count) selected = 0; + selected = (selected + direction + count) % count; + channel_filter = filters[selected]; + view_offset = 0; + } + + void channelFilterLabel(char* label, size_t size) const { + if (channel_filter == CHANNEL_FILTER_ALL) { + StrHelper::strncpy(label, "All channels", size); + return; + } + if (channel_filter == CHANNEL_FILTER_DIRECT) { + StrHelper::strncpy(label, "Direct", size); + return; + } + + ChannelDetails details; + if (the_mesh.getChannel(channel_filter, details) + && details.name[0] != 0) { + snprintf(label, size, "Ch %d %s", channel_filter, details.name); + return; + } + for (int age = 0; age < num_unread; ++age) { + int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS; + if (unread[index].channel_idx == channel_filter + && unread[index].channel_name[0] != 0) { + snprintf(label, size, "Ch %d %s", channel_filter, + unread[index].channel_name); + return; + } + } + snprintf(label, size, "Ch %d", channel_filter); + } + + void renderChannelFilter(DisplayDriver& display) const { + const int bar_height = 24; + const int bar_y = display.height() - bar_height; + display.setColor(UIColor::window_bkg); + display.fillRect(0, bar_y, display.width(), bar_height); + display.setColor(UIColor::corp_blue); + display.drawRect(0, bar_y, display.width(), 1); + + char channel[48]; + channelFilterLabel(channel, sizeof(channel)); + display.setTextSize(1); + const int text_y = bar_y + 8; + display.setCursor(8, text_y); + display.print("<"); + display.drawTextCentered(display.width() / 2, text_y, channel); + display.setCursor(display.width() - display.getTextWidth(">") - 8, + text_y); + display.print(">"); + } + +public: + MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) + : _task(task), _rtc(rtc), num_unread(0), view_offset(0), + channel_filter(CHANNEL_FILTER_ALL) {} + + bool hasMessages() const { return num_unread > 0; } + int messageCount() const { return num_unread; } + + void clear() { + num_unread = 0; + view_offset = 0; + channel_filter = CHANNEL_FILTER_ALL; + } + + void addPreview(uint8_t path_len, const char* from_name, const char* msg, + int channel_idx, const char* channel_name) { head = (head + 1) % MAX_UNREAD_MSGS; if (num_unread < MAX_UNREAD_MSGS) num_unread++; + view_offset = 0; + channel_filter = channel_idx; auto p = &unread[head]; p->timestamp = _rtc->getCurrentTime(); + p->channel_idx = channel_idx; + StrHelper::strncpy(p->channel_name, + channel_name == nullptr ? "" : channel_name, + sizeof(p->channel_name)); if (path_len == 0xFF) { - sprintf(p->origin, "(D) %s:", from_name); + snprintf(p->origin, sizeof(p->origin), "%s [direct]:", from_name); } else { - sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name); + snprintf(p->origin, sizeof(p->origin), "%s [%uh]:", from_name, + (unsigned int)path_len); } StrHelper::strncpy(p->msg, msg, sizeof(p->msg)); } int render(DisplayDriver& display) override { - char tmp[16]; + char tmp[24]; + int filtered_count = filteredCount(); + if (view_offset >= filtered_count) view_offset = 0; display.setCursor(0, 0); display.setTextSize(1); display.setColor(UIColor::corp_blue); - sprintf(tmp, "Unread: %d", num_unread); + snprintf(tmp, sizeof(tmp), "Message %d/%d", + filtered_count == 0 ? 0 : view_offset + 1, filtered_count); display.print(tmp); - auto p = &unread[head]; + const MsgEntry* p = filteredEntry(view_offset); + + if (p == nullptr) { + display.drawRect(0, 11, display.width(), 1); + display.setColor(UIColor::secondary_txt); + display.drawTextCentered(display.width() / 2, 40, + "No buffered messages"); + renderChannelFilter(display); + return 5000; + } int secs = _rtc->getCurrentTime() - p->timestamp; if (secs < 60) { @@ -647,6 +817,8 @@ public: display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); display.printWordWrap(filtered_msg, display.width()); + renderChannelFilter(display); + #if AUTO_OFF_MILLIS==0 // probably e-ink return 10000; // 10 s #else @@ -656,15 +828,22 @@ public: bool handleInput(char c) override { if (c == KEY_NEXT || c == KEY_RIGHT) { - head = (head + MAX_UNREAD_MSGS - 1) % MAX_UNREAD_MSGS; - num_unread--; - if (num_unread == 0) { - _task->gotoHomeScreen(); - } + if (view_offset + 1 < filteredCount()) ++view_offset; + return true; + } + if (c == KEY_PREV || c == KEY_LEFT) { + if (view_offset > 0) --view_offset; + return true; + } + if (c == KEY_DOWN) { + cycleChannelFilter(1); + return true; + } + if (c == KEY_UP) { + cycleChannelFilter(-1); return true; } if (c == KEY_ENTER) { - num_unread = 0; // clear unread queue _task->gotoHomeScreen(); return true; } @@ -727,6 +906,14 @@ void UITask::showAlert(const char* text, int duration_millis) { _alert_expiry = millis() + duration_millis; } +void UITask::showMessages() { + setCurrScreen(msg_preview); +} + +int UITask::getPreviewCount() const { + return static_cast(msg_preview)->messageCount(); +} + void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) switch(t){ @@ -763,14 +950,20 @@ void UITask::msgRead(int msgcount) { _deferred_msg_preview = false; const bool holding_usb_preview = curr == msg_preview && _msg_preview_until != 0 && static_cast(millis() - _msg_preview_until) < 0; - if (!holding_usb_preview) gotoHomeScreen(); + if (!holding_usb_preview) { + static_cast(msg_preview)->clear(); + gotoHomeScreen(); + } } } -void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { +void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, + int msgcount, int channel_idx, + const char* channel_name) { _msgcount = msgcount; - ((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text); + ((MsgPreviewScreen *)msg_preview) + ->addPreview(path_len, from_name, text, channel_idx, channel_name); if (isPairingScreenActive()) { // Keep the PIN visible, but retain the preview so it can be shown after // pairing completes or the pairing display window expires. @@ -781,7 +974,8 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i // A connected app drains the offline queue almost immediately, which calls // msgRead(0). While attached to a computer, retain the actual message screen - // for 15 seconds even though the app has already consumed the message. + // for the configured preview interval even though the app has already + // consumed the message. _msg_preview_until = _board->isUsbHostConnected() ? millis() + USB_MESSAGE_PREVIEW_MILLIS : 0; @@ -955,11 +1149,17 @@ void UITask::loop() { if (ev == BUTTON_EVENT_CLICK) { c = checkDisplayOn(KEY_NEXT); } else if (ev == BUTTON_EVENT_LONG_PRESS) { - c = handleLongPress(KEY_ENTER); + c = (_display != NULL && !_display->isOn()) + ? checkDisplayOn(KEY_ENTER) + : handleLongPress(KEY_ENTER); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { - c = handleDoubleClick(KEY_PREV); + c = (_display != NULL && !_display->isOn()) + ? checkDisplayOn(KEY_ENTER) + : handleDoubleClick(KEY_PREV); } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - c = handleTripleClick(KEY_SELECT); + c = (_display != NULL && !_display->isOn()) + ? checkDisplayOn(KEY_ENTER) + : handleTripleClick(KEY_SELECT); } #endif #endif @@ -988,6 +1188,39 @@ void UITask::loop() { _analogue_pin_read_millis = millis(); } #endif +#ifdef HAS_TOUCH + if (_display != NULL + && (int32_t)(millis() - next_touch_check) >= 0) { + next_touch_check = millis() + 25; + int touch_x = -1; + int touch_y = -1; + const bool touched = _display->getTouch(&touch_x, &touch_y); + const mesh::ui::TouchAction action = touch_input.update( + touched, touch_x, touch_y, _display->width(), _display->height(), + curr == msg_preview); + if (c == 0) { + switch (action) { + case mesh::ui::TouchAction::Previous: + c = checkDisplayOn(KEY_PREV); + break; + case mesh::ui::TouchAction::Next: + c = checkDisplayOn(KEY_NEXT); + break; + case mesh::ui::TouchAction::Select: + c = checkDisplayOn(KEY_ENTER); + break; + case mesh::ui::TouchAction::VerticalPrevious: + c = checkDisplayOn(KEY_UP); + break; + case mesh::ui::TouchAction::VerticalNext: + c = checkDisplayOn(KEY_DOWN); + break; + case mesh::ui::TouchAction::None: + break; + } + } + } +#endif #if defined(BACKLIGHT_BTN) if (millis() > next_backlight_btn_check) { bool touch_state = digitalRead(PIN_BUTTON2); @@ -1021,10 +1254,11 @@ void UITask::loop() { if (curr) curr->poll(); - if (curr == msg_preview && _msgcount == 0 && _msg_preview_until != 0 + if (_msgcount == 0 && _msg_preview_until != 0 && static_cast(millis() - _msg_preview_until) >= 0) { _msg_preview_until = 0; - gotoHomeScreen(); + static_cast(msg_preview)->clear(); + if (curr == msg_preview) gotoHomeScreen(); } if (_display != NULL && _display->isOn()) { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index efcda4f6..cb899896 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -7,6 +7,9 @@ #include #include #include +#ifdef HAS_TOUCH + #include +#endif #ifndef LED_STATE_ON #define LED_STATE_ON 1 @@ -41,6 +44,22 @@ class UITask : public AbstractUITask { int _msgcount; unsigned long ui_started_at, next_batt_chck; int next_backlight_btn_check = 0; +#ifdef HAS_TOUCH + #ifndef TOUCH_CENTER_ZONE_PERCENT + #define TOUCH_CENTER_ZONE_PERCENT 34 + #endif + #if defined(TOUCH_REVERSE_SWIPE) \ + && defined(TOUCH_SEPARATE_VERTICAL_SWIPES) + mesh::ui::TouchInput touch_input{true, true, TOUCH_CENTER_ZONE_PERCENT}; + #elif defined(TOUCH_REVERSE_SWIPE) + mesh::ui::TouchInput touch_input{true, false, TOUCH_CENTER_ZONE_PERCENT}; + #elif defined(TOUCH_SEPARATE_VERTICAL_SWIPES) + mesh::ui::TouchInput touch_input{false, true, TOUCH_CENTER_ZONE_PERCENT}; + #else + mesh::ui::TouchInput touch_input{false, false, TOUCH_CENTER_ZONE_PERCENT}; + #endif + unsigned long next_touch_check = 0; +#endif #ifdef PIN_STATUS_LED int led_state = 0; int next_led_change = 0; @@ -83,8 +102,10 @@ public: void serviceWiFiToggleButton(); void gotoHomeScreen() { setCurrScreen(home); } + void showMessages(); void showAlert(const char* text, int duration_millis); int getMsgCount() const { return _msgcount; } + int getPreviewCount() const; bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; @@ -103,7 +124,9 @@ public: // from AbstractUITask void msgRead(int msgcount) override; - void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override; + void newMsg(uint8_t path_len, const char* from_name, const char* text, + int msgcount, int channel_idx = -1, + const char* channel_name = nullptr) override; void notify(UIEventType t = UIEventType::none) override; void loop() override; diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 86e67d1b..a0e0e8c2 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -156,7 +156,11 @@ void UITask::clearMsgPreview() { _need_refresh = true; } -void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { +void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, + int msgcount, int channel_idx, + const char* channel_name) { + (void)channel_idx; + (void)channel_name; _msgcount = msgcount; #ifdef HAS_DRV2605 diff --git a/examples/companion_radio/ui-orig/UITask.h b/examples/companion_radio/ui-orig/UITask.h index d99048fa..d336f98e 100644 --- a/examples/companion_radio/ui-orig/UITask.h +++ b/examples/companion_radio/ui-orig/UITask.h @@ -78,7 +78,9 @@ public: // from AbstractUITask void msgRead(int msgcount) override; - void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override; + void newMsg(uint8_t path_len, const char* from_name, const char* text, + int msgcount, int channel_idx = -1, + const char* channel_name = nullptr) override; void notify(UIEventType t = UIEventType::none) override; void loop() override; diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 6207e0f0..ca3ee49e 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -515,7 +515,11 @@ void UITask::msgRead(int msgcount) { } } -void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { +void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, + int msgcount, int channel_idx, + const char* channel_name) { + (void)channel_idx; + (void)channel_name; _msgcount = msgcount; if (_display != NULL) { diff --git a/examples/companion_radio/ui-tiny/UITask.h b/examples/companion_radio/ui-tiny/UITask.h index aeb3a90e..a4a49753 100644 --- a/examples/companion_radio/ui-tiny/UITask.h +++ b/examples/companion_radio/ui-tiny/UITask.h @@ -106,7 +106,9 @@ public: // from AbstractUITask void msgRead(int msgcount) override; - void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override; + void newMsg(uint8_t path_len, const char* from_name, const char* text, + int msgcount, int channel_idx = -1, + const char* channel_name = nullptr) override; void notify(UIEventType t = UIEventType::none) override; void loop() override; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 73a4a1c3..a5230154 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -94,6 +94,9 @@ public: } virtual int getNoiseFloor() const { return 0; } + virtual float getNoiseFloorDbm() const { + return static_cast(getNoiseFloor()); + } virtual void triggerNoiseFloorCalibrate(int threshold) { } diff --git a/src/helpers/MeshClockSync.cpp b/src/helpers/MeshClockSync.cpp index 92dbc2b8..e2a9cd84 100644 --- a/src/helpers/MeshClockSync.cpp +++ b/src/helpers/MeshClockSync.cpp @@ -19,14 +19,22 @@ #ifndef FIRMWARE_BUILD_EPOCH #define FIRMWARE_BUILD_EPOCH 0UL #endif +#ifndef MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT + #define MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT 9 +#endif +#ifndef MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS + #define MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS (30ULL * 60ULL * 1000ULL) +#endif namespace { constexpr char PREFS_FILE[] = "/clock_sync"; constexpr uint8_t REQUIRED_SAMPLES_MIN = 3; constexpr uint8_t REQUIRED_SAMPLES_MAX = 16; -constexpr uint8_t REQUIRED_SAMPLES_DEFAULT = 9; -constexpr uint64_t STARTUP_DELAY_MILLIS = 30ULL * 60ULL * 1000ULL; +constexpr uint8_t REQUIRED_SAMPLES_DEFAULT = + MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT; +constexpr uint64_t STARTUP_DELAY_MILLIS = + MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS; constexpr uint64_t RETRY_INTERVAL_MILLIS = 30ULL * 60ULL * 1000ULL; constexpr uint64_t RESYNC_INTERVAL_MILLIS = 7ULL * 24ULL * 60ULL * 60ULL * 1000ULL; constexpr uint32_t SAMPLE_MAX_AGE_MILLIS = 2UL * 60UL * 60UL * 1000UL; @@ -35,6 +43,12 @@ constexpr uint32_t DRIFT_MIN_SECONDS = 30UL; constexpr uint32_t DRIFT_MAX_SECONDS = 86400UL; constexpr uint16_t VALID_YEARS = 10; +static_assert(REQUIRED_SAMPLES_DEFAULT >= REQUIRED_SAMPLES_MIN + && REQUIRED_SAMPLES_DEFAULT <= REQUIRED_SAMPLES_MAX, + "mesh clock-sync sample default is outside the supported range"); +static_assert(STARTUP_DELAY_MILLIS > 0, + "mesh clock-sync startup delay must be positive"); + // Public-channel AES key, zero-padded to the shared-secret buffer width used // by Utils::MACThenDecrypt(). const uint8_t PUBLIC_CHANNEL_SECRET[PUB_KEY_SIZE] = { diff --git a/src/helpers/WiFiSetupPortal.cpp b/src/helpers/WiFiSetupPortal.cpp index 9e3a47bf..17d71c3b 100644 --- a/src/helpers/WiFiSetupPortal.cpp +++ b/src/helpers/WiFiSetupPortal.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -296,7 +297,15 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi WiFi.mode(WIFI_AP_STA); if (!WiFi.softAPConfig(SETUP_IP, SETUP_IP, SETUP_MASK) - || !WiFi.softAP(impl->ap_name, nullptr)) { + || !WiFi.softAP(impl->ap_name, nullptr) + || esp_wifi_set_protocol( + WIFI_IF_AP, + WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N) + != ESP_OK + || esp_wifi_set_protocol( + WIFI_IF_STA, + WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N) + != ESP_OK) { WiFi.softAPdisconnect(true); return false; } diff --git a/src/helpers/esp32/ESPNOWRadio.cpp b/src/helpers/esp32/ESPNOWRadio.cpp index f264c887..415dae24 100644 --- a/src/helpers/esp32/ESPNOWRadio.cpp +++ b/src/helpers/esp32/ESPNOWRadio.cpp @@ -42,6 +42,10 @@ static void OnDataRecv(const uint8_t *mac, const uint8_t *data, int len) { void ESPNOWRadio::init() { // Set device as a Wi-Fi Station + // ESP-NOW's LR protocol is local to this radio transport. Keeping WiFi + // driver writes in RAM prevents a later conventional WiFi image from + // inheriting the proprietary protocol bit through NVS. + WiFi.persistent(false); WiFi.mode(WIFI_STA); // Long Range mode esp_wifi_set_protocol(WIFI_IF_STA, WIFI_PROTOCOL_LR); diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 12338775..4d0e8356 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -603,7 +603,22 @@ bool WebConfigServer::startSetupMode(char reply[]) { #else ap_ok = WiFi.softAP(_ap_ssid); #endif - if (ap_ok) break; + if (ap_ok) { + // ESP-NOW can leave the persistent AP protocol mask with the proprietary + // LR bit set. The driver then reports a healthy SoftAP, but ordinary + // phones and laptops cannot discover its beacon. A WiFi companion must + // advertise using the interoperable 2.4 GHz protocol set. + const esp_err_t ap_protocol_result = esp_wifi_set_protocol( + WIFI_IF_AP, + WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N); + const esp_err_t sta_protocol_result = esp_wifi_set_protocol( + WIFI_IF_STA, + WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N); + if (ap_protocol_result == ESP_OK && sta_protocol_result == ESP_OK) break; + Serial.printf("WebConfig protocol reset failed: AP=%d STA=%d\n", + (int)ap_protocol_result, (int)sta_protocol_result); + ap_ok = false; + } Serial.printf( "WebConfig AP attempt %u failed: mode_ok=%d disconnect_ok=%d mode=%d heap=%u largest=%u\n", @@ -677,6 +692,13 @@ bool WebConfigServer::startAutoMode(char reply[]) { } WiFi.mode(WIFI_STA); + if (esp_wifi_set_protocol( + WIFI_IF_STA, + WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N) + != ESP_OK) { + strcpy(reply, "Err: failed to reset WiFi station protocol"); + return false; + } WiFi.setAutoReconnect(true); _retry_saved_wifi_in_setup = false; _setup_reconnect_in_progress = false; diff --git a/src/helpers/ota/OtaTargets.h b/src/helpers/ota/OtaTargets.h index db40036b..722e751a 100644 --- a/src/helpers/ota/OtaTargets.h +++ b/src/helpers/ota/OtaTargets.h @@ -2,7 +2,7 @@ #include // AUTO-GENERATED by tools/mota/gen_targets.py - do not edit by hand. -// 577 OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32) +// 594 OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32) // to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT // transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes. // Size-constrained receivers can set OTA_TARGET_NAME_TABLE=0. They still match targets by ID; @@ -68,6 +68,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xb12e6e01, "Heltec_E290_room_server" }, { 0xf6f835ec, "Heltec_mesh_solar_repeater" }, { 0x7fab017f, "heltec_rc32_companion_radio_ble" }, + { 0x9e23928a, "heltec_rc32_companion_radio_full" }, { 0xf310a4d1, "heltec_rc32_companion_radio_usb" }, { 0xdd5953ec, "heltec_rc32_companion_radio_wifi" }, { 0x97574bc2, "heltec_rc32_kiss_modem" }, @@ -77,6 +78,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x57f69746, "heltec_rc32_sensor" }, { 0xbc05d6a7, "heltec_rc32_terminal_chat" }, { 0x5bad80f1, "heltec_rc32_without_display_companion_radio_ble" }, + { 0xb4f727a8, "heltec_rc32_without_display_companion_radio_full" }, { 0x112d34c4, "heltec_rc32_without_display_companion_radio_usb" }, { 0xf10a26df, "heltec_rc32_without_display_companion_radio_wifi" }, { 0xc5409ced, "heltec_rc32_without_display_repeater" }, @@ -120,6 +122,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xa3e8d8ce, "heltec_tracker_v1_1_room_server_observer_mqtt" }, { 0xb16009a0, "heltec_tracker_v2_companion_radio_ble_femoff" }, { 0xdc780e80, "heltec_tracker_v2_companion_radio_ble_femon" }, + { 0x28b8007c, "heltec_tracker_v2_companion_radio_full_femon" }, { 0x4aa63180, "heltec_tracker_v2_companion_radio_usb_femoff" }, { 0x6fddcbf1, "heltec_tracker_v2_companion_radio_usb_femon" }, { 0xf4fb5484, "heltec_tracker_v2_companion_radio_wifi_femoff" }, @@ -155,13 +158,16 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xceeddfaf, "Heltec_v3_room_server_observer_mqtt" }, { 0x21519537, "Heltec_v3_sensor" }, { 0x382bb181, "Heltec_v3_terminal_chat" }, + { 0xad1bcd63, "heltec_v4_2_v4_3_companion_radio_full_femon" }, { 0x5a2980e6, "heltec_v4_3_companion_radio_ble_femoff" }, { 0x90581c83, "heltec_v4_3_companion_radio_ble_ps_femoff" }, + { 0x90a6a854, "heltec_v4_3_companion_radio_full_femoff" }, { 0x356df17c, "heltec_v4_3_companion_radio_usb_femoff" }, { 0x99a084fc, "heltec_v4_3_companion_radio_wifi_femoff" }, { 0x64c9dfc9, "heltec_v4_3_companion_radio_wifi_mqtt_femoff" }, { 0x2a2ffc92, "heltec_v4_3_expansionkit_tft_companion_radio_ble_femoff" }, { 0x2c13b8e9, "heltec_v4_3_tft_companion_radio_ble_femoff" }, + { 0x1ee7d867, "heltec_v4_3_tft_companion_radio_full_femoff" }, { 0xb016979a, "heltec_v4_3_tft_companion_radio_usb_femoff" }, { 0xb49b6b55, "heltec_v4_3_tft_companion_radio_wifi_femoff" }, { 0x01d8f124, "heltec_v4_companion_radio_ble" }, @@ -181,6 +187,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xf75feb3e, "heltec_v4_kiss_modem" }, { 0xbc9cdc70, "heltec_v4_r8_companion_radio_ble" }, { 0x19e31fc6, "heltec_v4_r8_companion_radio_ble_ps" }, + { 0xa01b330e, "heltec_v4_r8_companion_radio_full" }, { 0x36c78d86, "heltec_v4_r8_companion_radio_usb" }, { 0xdf722326, "heltec_v4_r8_companion_radio_wifi" }, { 0xfa901c87, "heltec_v4_r8_kiss_modem" }, @@ -189,6 +196,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xb4cb035a, "heltec_v4_r8_sensor" }, { 0xbb429dde, "heltec_v4_r8_terminal_chat" }, { 0x883e3edc, "heltec_v4_r8_tft_companion_radio_ble" }, + { 0x4a9d1c6c, "heltec_v4_r8_tft_companion_radio_full" }, { 0xa0965a6b, "heltec_v4_r8_tft_companion_radio_usb" }, { 0x59ea25fa, "heltec_v4_r8_tft_companion_radio_wifi" }, { 0x1758bdb9, "heltec_v4_r8_tft_kiss_modem" }, @@ -204,6 +212,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x0b6847ed, "heltec_v4_sensor" }, { 0xab3e1ad3, "heltec_v4_terminal_chat" }, { 0x04a8b763, "heltec_v4_tft_companion_radio_ble_femon" }, + { 0xc456437e, "heltec_v4_tft_companion_radio_full_femon" }, { 0xb3ecafca, "heltec_v4_tft_companion_radio_usb_femon" }, { 0xf3afcde6, "heltec_v4_tft_companion_radio_wifi_femon" }, { 0x87513d56, "heltec_v4_tft_repeater" }, @@ -311,6 +320,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x07907102, "LilyGo_T_Impulse_Plus_repeater" }, { 0x3898ea56, "LilyGo_TBeam_1W_companion_radio_ble" }, { 0x2fb7e218, "LilyGo_TBeam_1W_companion_radio_ble_ps" }, + { 0x8c61d4c8, "LilyGo_TBeam_1W_companion_radio_full" }, { 0x4f722e70, "LilyGo_TBeam_1W_companion_radio_usb" }, { 0x694eed25, "LilyGo_TBeam_1W_companion_radio_wifi" }, { 0x85b8b9d7, "LilyGo_TBeam_1W_kiss_modem" }, @@ -364,6 +374,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xd45277ff, "Meshimi_companion_radio_ble_" }, { 0x2cad9a9d, "Meshimi_repeater_" }, { 0x02a4a8c1, "meshnology_w12_companion_radio_ble" }, + { 0x4a709348, "meshnology_w12_companion_radio_full" }, { 0x35d960bd, "meshnology_w12_companion_radio_usb" }, { 0x62977023, "meshnology_w12_companion_radio_wifi" }, { 0x17a7ed8c, "meshnology_w12_kiss_modem" }, @@ -376,6 +387,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x11b131e7, "Minewsemi_me25ls01_repeater_lora_ota_no_external_sensors" }, { 0xf8259880, "Nano_G2_Ultra_repeater" }, { 0x063f1f00, "nibble_screen_connect_companion_radio_ble_" }, + { 0x70d71f92, "nibble_screen_connect_companion_radio_full_" }, { 0xa8a79da5, "nibble_screen_connect_companion_radio_usb_" }, { 0xc0d3815b, "nibble_screen_connect_companion_radio_wifi_" }, { 0x966ac88e, "nibble_screen_connect_kiss_modem_" }, @@ -384,6 +396,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xc42cc90d, "nibble_screen_connect_room_server_" }, { 0xc24791c3, "nibble_screen_connect_terminal_chat_" }, { 0x15e11067, "nibble_zero_connect_companion_radio_ble_" }, + { 0x2dbb2669, "nibble_zero_connect_companion_radio_full_" }, { 0x90799c32, "nibble_zero_connect_companion_radio_usb_" }, { 0x881a6a48, "nibble_zero_connect_companion_radio_wifi_" }, { 0xf26af51c, "nibble_zero_connect_repeater_" }, @@ -408,6 +421,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x4d0b1601, "R1Neo_sensor" }, { 0xab2ac09e, "R1Neo_terminal_chat" }, { 0xa17fc4f7, "RAK_3112_companion_radio_ble" }, + { 0xe4b2bd22, "RAK_3112_companion_radio_full" }, { 0x90c4eb3f, "RAK_3112_companion_radio_usb" }, { 0x19d9ce91, "RAK_3112_companion_radio_wifi" }, { 0x121cc72f, "RAK_3112_kiss_modem" }, @@ -438,6 +452,8 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x51fa1a49, "RAK_WisMesh_Tag_sensor" }, { 0xdb73638b, "SenseCap_Solar_repeater" }, { 0xd904c2d5, "SenseCapIndicator-ESPNow_comp_radio_usb" }, + { 0x620569f4, "SenseCapIndicator-LoRa_comp_radio_usb" }, + { 0x08483f6b, "SenseCapIndicator-LoRa_comp_radio_usb_wifi" }, { 0x0db48f35, "solarxiao_30S_companion_radio_ble" }, { 0x20532f6c, "solarxiao_30S_companion_radio_usb" }, { 0x293ced32, "solarxiao_30S_kiss_modem" }, @@ -455,6 +471,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0xf2128c81, "Station_G2_repeater" }, { 0x1b34e7b4, "Station_G2_repeater_bridge_espnow" }, { 0x8e549407, "Station_G3_ESP32_companion_radio_ble" }, + { 0x6ca142f4, "Station_G3_ESP32_companion_radio_full" }, { 0x1deac038, "Station_G3_ESP32_companion_radio_usb" }, { 0x2a2f303b, "Station_G3_ESP32_companion_radio_wifi" }, { 0x0ba4453d, "Station_G3_ESP32_kiss_modem" }, @@ -584,6 +601,7 @@ inline const char* ota_target_env_name(uint32_t target_id) { { 0x60f5fdc2, "Xiao_S3_sensor" }, { 0xe8f79d22, "Xiao_S3_WIO_companion_radio_ble" }, { 0xb97bbb15, "Xiao_S3_WIO_companion_radio_ble_ps" }, + { 0xd466590c, "Xiao_S3_WIO_companion_radio_full" }, { 0x9f27be15, "Xiao_S3_WIO_companion_radio_serial" }, { 0x396fecc7, "Xiao_S3_WIO_companion_radio_usb" }, { 0xf94e3407, "Xiao_S3_WIO_companion_radio_wifi" }, diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index cac7ca37..899649c8 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -62,6 +62,7 @@ void RadioLibWrapper::begin() { } _noise_floor = 0; + _noise_floor_centi_dbm = 0; _noise_floor_valid = false; _threshold = 0; _cad_enabled = false; @@ -71,7 +72,7 @@ void RadioLibWrapper::begin() { // start average out some samples _num_floor_samples = 0; - _floor_sample_sum = 0; + _floor_sample_sum_centi_dbm = 0; _nf_calib_active = false; _nf_last_calib = 0; _nf_sample_from = 0; @@ -232,7 +233,7 @@ void RadioLibWrapper::recalibrateNoiseFloor() { _nf_refresh_requested = true; _nf_last_calib = 0; _num_floor_samples = 0; - _floor_sample_sum = 0; + _floor_sample_sum_centi_dbm = 0; const unsigned long now = millis(); _nf_sample_from = now + NF_CALIB_SETTLE_MS; @@ -251,7 +252,7 @@ void RadioLibWrapper::requestNoiseFloorRefresh() { if (_nf_refresh_requested) return; _nf_refresh_requested = true; _num_floor_samples = 0; - _floor_sample_sum = 0; + _floor_sample_sum_centi_dbm = 0; _nf_calib_deadline = 0; // starts when continuous RX is actually available } @@ -425,7 +426,7 @@ void RadioLibWrapper::noiseFloorCalibCheck(unsigned long now) { _nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS; _nf_sample_from = now + NF_CALIB_SETTLE_MS; _num_floor_samples = 0; // start a fresh batch for this window - _floor_sample_sum = 0; + _floor_sample_sum_centi_dbm = 0; if (!isPacketPendingOrReceiving()) { requestRestartRecv(); // startReceiveMode() selects continuous RX } @@ -463,22 +464,29 @@ void RadioLibWrapper::loop() { _nf_calib_deadline = now + NF_CONTINUOUS_TIMEOUT_MS; } if (_nf_refresh_requested && _num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES - && _floor_sample_sum != 0) { - int16_t sampled_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; - if (sampled_floor < -120) { - sampled_floor = -120; // clamp to lower bound of -120dBi + && _floor_sample_sum_centi_dbm != 0) { + int32_t sampled_floor_centi_dbm = + _floor_sample_sum_centi_dbm / NUM_NOISE_FLOOR_SAMPLES; + if (sampled_floor_centi_dbm < -12000) { + sampled_floor_centi_dbm = -12000; } if (_noise_floor_valid) { // Favor the fresh high-rate batch while retaining a small amount of - // history: 25% previous floor + 75% newly sampled floor. Round the - // negative dBm result to the nearest integer instead of toward zero. - int32_t weighted_floor = (int32_t)_noise_floor + 3L * sampled_floor; - _noise_floor = weighted_floor < 0 ? (weighted_floor - 2) / 4 - : (weighted_floor + 2) / 4; + // history: 25% previous floor + 75% newly sampled floor. + int32_t weighted_floor = _noise_floor_centi_dbm + + 3L * sampled_floor_centi_dbm; + _noise_floor_centi_dbm = weighted_floor < 0 + ? (weighted_floor - 2) / 4 + : (weighted_floor + 2) / 4; } else { - _noise_floor = sampled_floor; + _noise_floor_centi_dbm = sampled_floor_centi_dbm; } - _floor_sample_sum = 0; + // Preserve the existing whole-dB API and wire formats while exposing the + // fractional 64-sample mean to local displays. + _noise_floor = _noise_floor_centi_dbm < 0 + ? (_noise_floor_centi_dbm - 50) / 100 + : (_noise_floor_centi_dbm + 50) / 100; + _floor_sample_sum_centi_dbm = 0; _noise_floor_valid = true; _nf_refresh_requested = false; _nf_last_calib = now; @@ -503,7 +511,7 @@ void RadioLibWrapper::loop() { _nf_last_calib = now; _nf_calib_deadline = 0; _num_floor_samples = 0; - _floor_sample_sum = 0; + _floor_sample_sum_centi_dbm = 0; } if (_nf_refresh_requested && state == STATE_RX @@ -516,13 +524,15 @@ void RadioLibWrapper::loop() { if (!_rx_ps_armed && !(_nf_sample_from != 0 && (long)(now - _nf_sample_from) < 0) && !isReceivingPacket()) { - int rssi = getCurrentRSSI(); - if (!_noise_floor_valid || rssi < _noise_floor + SAMPLING_THRESHOLD) { + float rssi = getCurrentRSSI(); + if (!_noise_floor_valid + || rssi < getNoiseFloorDbm() + SAMPLING_THRESHOLD) { // With no valid baseline (startup, AGC reset, or gain change), seed // unconditionally. Otherwise reject likely traffic above the current // floor plus the sampling margin. _num_floor_samples++; - _floor_sample_sum += rssi; + _floor_sample_sum_centi_dbm += + static_cast(rssi * 100.0f); } } } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 549853ae..f011159d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -30,12 +30,13 @@ protected: mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; int16_t _noise_floor, _threshold; + int32_t _noise_floor_centi_dbm; float _last_rssi, _last_snr; bool _cad_enabled; bool _noise_floor_valid; bool _nf_refresh_requested; uint16_t _num_floor_samples; - int32_t _floor_sample_sum; + int32_t _floor_sample_sum_centi_dbm; unsigned long last_recv_millis; unsigned long last_radio_interrupt_millis; // updated on any ISR event, even CRC errors bool _rx_ps_enabled; @@ -203,6 +204,9 @@ public: virtual int16_t performChannelScan(); int getNoiseFloor() const override { return _noise_floor; } + float getNoiseFloorDbm() const override { + return _noise_floor_centi_dbm / 100.0f; + } void triggerNoiseFloorCalibrate(int threshold) override; void recalibrateNoiseFloor() override; void setCADEnabled(bool enable) override { _cad_enabled = enable; } diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 98fca471..651989b2 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -691,12 +691,17 @@ bool EnvironmentSensorManager::begin() { MESH_DEBUG_PRINTLN("Second I2C initialized on pins SDA: %d SCL: %d", ENV_PIN_SDA, ENV_PIN_SCL); #endif + _active_sensor_count = 0; + // Avoid touching a shared I2C bus when no environmental drivers were built. + if (SENSOR_TABLE_SIZE == 0) { + return true; + } + // Scan the I2C bus before touching any sensor library. bool detected[128] = {}; scanI2CBus(TELEM_WIRE, detected); // Walk the sensor table and initialize only detected devices. - _active_sensor_count = 0; for (size_t i = 0; i < SENSOR_TABLE_SIZE && _active_sensor_count < MAX_ACTIVE_SENSORS; i++) { const SensorDef& def = SENSOR_TABLE[i]; // One static driver instance per type: an alternate address is a fallback, not a second device. diff --git a/src/helpers/ui/ColorEmojiAtlas.h b/src/helpers/ui/ColorEmojiAtlas.h new file mode 100644 index 00000000..c8bf8760 --- /dev/null +++ b/src/helpers/ui/ColorEmojiAtlas.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include + +namespace mesh { +namespace ui { + +class ColorEmojiAtlas { + static constexpr size_t FOOTER_SIZE = 40; + static constexpr size_t ATLAS_HEADER_SIZE = 12; + static constexpr uint16_t FIRST_EMOJI = 0xE000; + + const uint8_t* _pixels = nullptr; + uint16_t _count = 0; + uint16_t _stride = 0; + uint8_t _width = 0; + uint8_t _height = 0; + uint8_t _transparent = 0; + uint8_t _text_scale = 1; + + static uint16_t read16(const uint8_t* p) { + return (uint16_t)p[0] | ((uint16_t)p[1] << 8); + } + + static uint32_t read32(const uint8_t* p) { + return (uint32_t)p[0] + | ((uint32_t)p[1] << 8) + | ((uint32_t)p[2] << 16) + | ((uint32_t)p[3] << 24); + } + + static uint32_t crc32(const uint8_t* data, size_t size) { + uint32_t crc = 0xFFFFFFFFUL; + for (size_t i = 0; i < size; ++i) { + crc ^= data[i]; + for (uint8_t bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U))); + } + } + return ~crc; + } + +public: + void reset() { + _pixels = nullptr; + _count = 0; + _stride = 0; + _width = 0; + _height = 0; + _transparent = 0; + _text_scale = 1; + } + + bool begin(const uint8_t* font_data, size_t font_size) { + reset(); + if (font_data == nullptr || font_size < FOOTER_SIZE) return false; + + const uint8_t* footer = font_data + font_size - FOOTER_SIZE; + static const uint8_t FOOTER_MAGIC[8] = { + 'M', 'C', 'E', 'M', 'A', 'P', '2', 0 + }; + if (memcmp(footer, FOOTER_MAGIC, sizeof(FOOTER_MAGIC)) != 0) { + return false; + } + + uint32_t atlas_offset = read32(footer + 20); + uint32_t atlas_size = read32(footer + 24); + uint32_t atlas_crc = read32(footer + 28); + if (atlas_offset > font_size - FOOTER_SIZE + || atlas_size > font_size - FOOTER_SIZE - atlas_offset + || atlas_size < ATLAS_HEADER_SIZE) { + return false; + } + + const uint8_t* atlas = font_data + atlas_offset; + if (crc32(atlas, atlas_size) != atlas_crc + || memcmp(atlas, "CE01", 4) != 0) { + return false; + } + + uint16_t count = read16(atlas + 4); + uint8_t width = atlas[6]; + uint8_t height = atlas[7]; + uint8_t transparent = atlas[8]; + uint8_t text_scale = atlas[9]; + uint16_t stride = read16(atlas + 10); + if (count == 0 || width == 0 || height == 0 + || width > 64 || height > 64 + || text_scale == 0 || text_scale > 8 + || stride != (uint16_t)width * height + || (size_t)count * stride != atlas_size - ATLAS_HEADER_SIZE) { + return false; + } + + _pixels = atlas + ATLAS_HEADER_SIZE; + _count = count; + _stride = stride; + _width = width; + _height = height; + _transparent = transparent; + _text_scale = text_scale; + return true; + } + + bool isReady() const { return _pixels != nullptr; } + uint8_t width() const { return _width; } + uint8_t height() const { return _height; } + uint8_t transparent() const { return _transparent; } + uint8_t textScale() const { return _text_scale; } + uint16_t count() const { return _count; } + + const uint8_t* glyph(uint16_t mapped_codepoint) const { + if (!isReady() || mapped_codepoint < FIRST_EMOJI) return nullptr; + uint16_t index = mapped_codepoint - FIRST_EMOJI; + if (index >= _count) return nullptr; + return _pixels + (size_t)index * _stride; + } +}; + +} // namespace ui +} // namespace mesh diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 05f8f152..47f59210 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -15,6 +15,14 @@ class DisplayDriver { int _w, _h; protected: DisplayDriver(int w, int h) { _w = w; _h = h; } + + static size_t trimLastUTF8Codepoint(char* str, size_t length) { + if (length == 0) return 0; + size_t start = length - 1; + while (start > 0 && ((uint8_t)str[start] & 0xC0) == 0x80) --start; + str[start] = 0; + return start; + } public: //enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light @@ -37,6 +45,11 @@ public: virtual void drawRect(int x, int y, int w, int h) = 0; virtual void drawXbm(int x, int y, const uint8_t* bits, int w, int h) = 0; virtual uint16_t getTextWidth(const char* str) = 0; + virtual bool getTouch(int* x, int* y) { + (void)x; + (void)y; + return false; + } virtual void drawTextCentered(int mid_x, int y, const char* str) { // helper method (override to optimise) int w = getTextWidth(str); setCursor(mid_x - w/2, y); @@ -72,9 +85,16 @@ public: virtual void drawTextEllipsized(int x, int y, int max_width, const char* str) { char temp_str[256]; // reasonable buffer size size_t len = strlen(str); - if (len >= sizeof(temp_str)) len = sizeof(temp_str) - 1; + if (len >= sizeof(temp_str)) { + len = sizeof(temp_str) - 1; + // If the fixed buffer cuts through a UTF-8 sequence, omit that whole + // codepoint instead of passing malformed text to the display driver. + while (len > 0 && ((uint8_t)str[len] & 0xC0) == 0x80) --len; + } memcpy(temp_str, str, len); temp_str[len] = 0; + + if (max_width <= 0) return; if (getTextWidth(temp_str) <= max_width) { setCursor(x, y); @@ -95,12 +115,19 @@ public: } int ellipsis_width = getTextWidth(ellipsis); - int str_len = strlen(temp_str); + size_t ellipsis_len = strlen(ellipsis); + size_t str_len = strlen(temp_str); - while (str_len > 0 && getTextWidth(temp_str) > max_width - ellipsis_width) { - temp_str[--str_len] = 0; + while (str_len > 0 + && (getTextWidth(temp_str) > max_width - ellipsis_width + || str_len + ellipsis_len >= sizeof(temp_str))) { + str_len = trimLastUTF8Codepoint(temp_str, str_len); + } + if (ellipsis_width <= max_width) { + memcpy(temp_str + str_len, ellipsis, ellipsis_len + 1); + } else { + temp_str[0] = 0; } - strcat(temp_str, ellipsis); setCursor(x, y); print(temp_str); diff --git a/src/helpers/ui/EmojiFontMap.h b/src/helpers/ui/EmojiFontMap.h new file mode 100644 index 00000000..529df818 --- /dev/null +++ b/src/helpers/ui/EmojiFontMap.h @@ -0,0 +1,178 @@ +#pragma once + +#include +#include +#include + +namespace mesh { +namespace ui { + +class EmojiFontMap { + static constexpr size_t MAP_HEADER_SIZE = 12; + static constexpr size_t NODE_SIZE = 6; + static constexpr size_t EDGE_SIZE = 3; + static constexpr size_t FOOTER_V1_SIZE = 24; + static constexpr size_t FOOTER_V2_SIZE = 40; + + const uint8_t* _nodes = nullptr; + const uint8_t* _edges = nullptr; + uint16_t _node_count = 0; + uint16_t _edge_count = 0; + + static uint16_t read16(const uint8_t* p) { + return (uint16_t)p[0] | ((uint16_t)p[1] << 8); + } + + static uint32_t read32(const uint8_t* p) { + return (uint32_t)p[0] + | ((uint32_t)p[1] << 8) + | ((uint32_t)p[2] << 16) + | ((uint32_t)p[3] << 24); + } + + static uint32_t crc32(const uint8_t* data, size_t size) { + uint32_t crc = 0xFFFFFFFFUL; + for (size_t i = 0; i < size; ++i) { + crc ^= data[i]; + for (uint8_t bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U))); + } + } + return ~crc; + } + + const uint8_t* node(uint16_t index) const { + return _nodes + (size_t)index * NODE_SIZE; + } + + const uint8_t* edge(uint16_t index) const { + return _edges + (size_t)index * EDGE_SIZE; + } + + bool findChild(uint16_t node_index, uint8_t value, + uint16_t& child) const { + const uint8_t* current = node(node_index); + uint16_t first = read16(current); + uint16_t count = read16(current + 2); + uint16_t low = 0; + uint16_t high = count; + while (low < high) { + uint16_t middle = low + (high - low) / 2; + const uint8_t* candidate = edge(first + middle); + if (candidate[0] < value) { + low = middle + 1; + } else { + high = middle; + } + } + if (low >= count) return false; + const uint8_t* match = edge(first + low); + if (match[0] != value) return false; + child = read16(match + 1); + return child < _node_count; + } + +public: + void reset() { + _nodes = nullptr; + _edges = nullptr; + _node_count = 0; + _edge_count = 0; + } + + bool begin(const uint8_t* font_data, size_t font_size) { + reset(); + if (font_data == nullptr || font_size < FOOTER_V1_SIZE) return false; + + static const uint8_t FOOTER_V1_MAGIC[8] = { + 'M', 'C', 'E', 'M', 'A', 'P', '1', 0 + }; + static const uint8_t FOOTER_V2_MAGIC[8] = { + 'M', 'C', 'E', 'M', 'A', 'P', '2', 0 + }; + size_t footer_size = FOOTER_V1_SIZE; + const uint8_t* footer = font_data + font_size - footer_size; + if (font_size >= FOOTER_V2_SIZE + && memcmp(font_data + font_size - FOOTER_V2_SIZE, + FOOTER_V2_MAGIC, sizeof(FOOTER_V2_MAGIC)) == 0) { + footer_size = FOOTER_V2_SIZE; + footer = font_data + font_size - footer_size; + } else if (memcmp(footer, FOOTER_V1_MAGIC, + sizeof(FOOTER_V1_MAGIC)) != 0) { + return false; + } + + uint32_t map_offset = read32(footer + 8); + uint32_t map_size = read32(footer + 12); + uint32_t map_crc = read32(footer + 16); + if (map_offset > font_size - footer_size + || map_size > font_size - footer_size - map_offset + || map_size < MAP_HEADER_SIZE) { + return false; + } + + const uint8_t* map = font_data + map_offset; + if (crc32(map, map_size) != map_crc + || memcmp(map, "EM01", 4) != 0) { + return false; + } + + uint16_t node_count = read16(map + 4); + uint16_t edge_count = read16(map + 6); + size_t expected_size = MAP_HEADER_SIZE + + (size_t)node_count * NODE_SIZE + + (size_t)edge_count * EDGE_SIZE; + if (node_count == 0 || expected_size != map_size) return false; + + const uint8_t* nodes = map + MAP_HEADER_SIZE; + const uint8_t* edges = nodes + (size_t)node_count * NODE_SIZE; + for (uint16_t i = 0; i < node_count; ++i) { + const uint8_t* current = nodes + (size_t)i * NODE_SIZE; + uint16_t first = read16(current); + uint16_t count = read16(current + 2); + uint16_t output = read16(current + 4); + if (first > edge_count || count > edge_count - first) return false; + if (output != 0 && (output < 0xE000 || output > 0xF8FF)) return false; + int previous = -1; + for (uint16_t j = 0; j < count; ++j) { + const uint8_t* current_edge = edges + (size_t)(first + j) * EDGE_SIZE; + if (current_edge[0] <= previous + || read16(current_edge + 1) >= node_count) { + return false; + } + previous = current_edge[0]; + } + } + + _nodes = nodes; + _edges = edges; + _node_count = node_count; + _edge_count = edge_count; + return true; + } + + bool isReady() const { return _nodes != nullptr; } + + size_t longestMatch(const uint8_t* text, size_t length, + uint16_t& mapped_codepoint) const { + mapped_codepoint = 0; + if (!isReady() || text == nullptr || length == 0) return 0; + + uint16_t node_index = 0; + size_t best_length = 0; + for (size_t i = 0; i < length; ++i) { + uint16_t child; + if (!findChild(node_index, text[i], child)) break; + node_index = child; + uint16_t output = read16(node(node_index) + 4); + if (output != 0) { + mapped_codepoint = output; + best_length = i + 1; + } + } + return best_length; + } +}; + +} // namespace ui +} // namespace mesh diff --git a/src/helpers/ui/LGFXDisplay.cpp b/src/helpers/ui/LGFXDisplay.cpp index 7bdd52a2..152b469d 100644 --- a/src/helpers/ui/LGFXDisplay.cpp +++ b/src/helpers/ui/LGFXDisplay.cpp @@ -1,5 +1,108 @@ #include "LGFXDisplay.h" +#ifndef DISPLAY_ROTATION + #define DISPLAY_ROTATION 1 +#endif +#ifndef DISPLAY_BRIGHTNESS + #define DISPLAY_BRIGHTNESS 64 +#endif + +namespace { + +uint32_t readBigEndian32(const uint8_t* data) { + return ((uint32_t)data[0] << 24) + | ((uint32_t)data[1] << 16) + | ((uint32_t)data[2] << 8) + | (uint32_t)data[3]; +} + +uint32_t readLittleEndian32(const uint8_t* data) { + return (uint32_t)data[0] + | ((uint32_t)data[1] << 8) + | ((uint32_t)data[2] << 16) + | ((uint32_t)data[3] << 24); +} + +bool validateVlw(const uint8_t* data, size_t size) { + static const size_t HEADER_SIZE = 24; + static const size_t RECORD_SIZE = 28; + static const size_t FOOTER_V1_SIZE = 24; + static const size_t FOOTER_V2_SIZE = 40; + static const uint8_t FOOTER_V1_MAGIC[8] = { + 'M', 'C', 'E', 'M', 'A', 'P', '1', 0 + }; + static const uint8_t FOOTER_V2_MAGIC[8] = { + 'M', 'C', 'E', 'M', 'A', 'P', '2', 0 + }; + if (data == nullptr || size < HEADER_SIZE + FOOTER_V1_SIZE) { + return false; + } + + size_t footer_size = FOOTER_V1_SIZE; + const uint8_t* footer = data + size - footer_size; + if (size >= HEADER_SIZE + FOOTER_V2_SIZE + && memcmp(data + size - FOOTER_V2_SIZE, FOOTER_V2_MAGIC, + sizeof(FOOTER_V2_MAGIC)) == 0) { + footer_size = FOOTER_V2_SIZE; + footer = data + size - footer_size; + } else if (memcmp(footer, FOOTER_V1_MAGIC, + sizeof(FOOTER_V1_MAGIC)) != 0) { + return false; + } + + uint32_t glyph_count = readBigEndian32(data); + uint32_t map_offset = readLittleEndian32(footer + 8); + if (glyph_count == 0 || glyph_count > 10000 + || map_offset < HEADER_SIZE + || map_offset > size - footer_size + || glyph_count > (map_offset - HEADER_SIZE) / RECORD_SIZE) { + return false; + } + + size_t bitmap_offset = HEADER_SIZE + (size_t)glyph_count * RECORD_SIZE; + uint32_t previous_codepoint = 0; + for (uint32_t i = 0; i < glyph_count; ++i) { + const uint8_t* record = data + HEADER_SIZE + (size_t)i * RECORD_SIZE; + uint32_t codepoint = readBigEndian32(record); + uint32_t height = readBigEndian32(record + 4); + uint32_t width = readBigEndian32(record + 8); + uint32_t advance = readBigEndian32(record + 12); + if ((i != 0 && codepoint <= previous_codepoint) + || codepoint > 0xFFFF + || width > 64 + || height > 64 + || advance > 64 + || width > SIZE_MAX / (height == 0 ? 1 : height) + || bitmap_offset > map_offset + || width * height > map_offset - bitmap_offset) { + return false; + } + previous_codepoint = codepoint; + bitmap_offset += width * height; + } + return bitmap_offset == map_offset; +} + +LGFXDisplay* activeEmojiDisplay = nullptr; + +static const uint32_t UI_PALETTE[16] = { + 0x000000, 0xFFFFFF, 0x0000FF, 0x929292, + 0xFFAA00, 0x00FFFF, 0x0000D6, 0xE53935, + 0x43A047, 0x1E88E5, 0x8E24AA, 0xFDD835, + 0x6D4C41, 0x00ACC1, 0xF06292, 0xFF00FF, +}; + +static constexpr uint8_t TRANSPARENT_EMOJI_PALETTE_INDEX = 15; +static constexpr uint32_t TRANSPARENT_EMOJI_COLOR = 0xFF00FF; + +bool isSequence(const uint8_t* text, size_t remaining, + const uint8_t* sequence, size_t sequence_size) { + return remaining >= sequence_size + && memcmp(text, sequence, sequence_size) == 0; +} + +} // namespace + // Color scheme ColorVal UIColor::window_bkg = 0xFFFF; ColorVal UIColor::title_bkg = 0x001F; @@ -12,31 +115,44 @@ ColorVal UIColor::popup_txt = 0x0000; ColorVal UIColor::corp_blue = 0x001A; bool LGFXDisplay::begin() { - turnOn(); - display->init(); - display->setRotation(1); - display->setBrightness(64); + if (!display->init()) return false; + _isOn = true; + display->setRotation(DISPLAY_ROTATION); + display->setBrightness(DISPLAY_BRIGHTNESS); display->setColorDepth(8); display->setTextColor(TFT_WHITE); - buffer.setColorDepth(8); - buffer.setPsram(true); - buffer.createSprite(width(), height()); + buffer.setColorDepth(UI_BUFFER_COLOR_DEPTH); + // The RGB scanout framebuffer must live in PSRAM. Keep this much smaller + // logical UI sprite in internal DMA-capable RAM so page redraws do not + // compete with scanout and permanently shift the panel after an underflow. + buffer.setPsram(false); + if (buffer.createSprite(width() * UI_COORD_SCALE, + height() * UI_COORD_SCALE) == nullptr) { + return false; + } + configurePalette(); return true; } void LGFXDisplay::turnOn() { -// display->wakeup(); if (!_isOn) { - display->wakeup(); + // Keep the panel initialized while blanked so touch remains available as + // a wake source. Restoring brightness is sufficient for RGB panels. + display->setBrightness(DISPLAY_BRIGHTNESS); +#ifdef HAS_TOUCH + if (display->touch() != nullptr) display->touch()->wakeup(); +#endif } _isOn = true; } void LGFXDisplay::turnOff() { if (_isOn) { - display->sleep(); + // Do not use LGFX sleep here: a sleeping touch controller cannot provide + // the event that wakes a touch-capable UI. Blank only the backlight. + display->setBrightness(0); } _isOn = false; } @@ -44,68 +160,433 @@ void LGFXDisplay::turnOff() { void LGFXDisplay::clear() { // display->clearDisplay(); buffer.clearDisplay(); + _presentedEmojiOverlayCount = 0; + _hasLastFrame = false; } void LGFXDisplay::startFrame(ColorVal bkg) { // display->startWrite(); // display->getScanLine(); - _color = bkg; + _emojiOverlayCount = 0; + _hasTransparentEmojiPixels = false; + _color = renderColor(bkg); + buffer.setBaseColor(_color); buffer.fillScreen(_color); - buffer.setTextColor(_color = UIColor::primary_txt); + setColor(UIColor::primary_txt); } void LGFXDisplay::setTextSize(int sz) { - buffer.setTextSize(sz); + int scaled = (sz * UI_COORD_SCALE + _fontNativeScale / 2) + / _fontNativeScale; + if (scaled < 1) scaled = 1; + buffer.setTextSize(scaled); } void LGFXDisplay::setColor(ColorVal c) { - buffer.setTextColor(_color = c); + _color = renderColor(c); + // Every frame starts from a freshly cleared canvas, so transparent glyph + // backgrounds are both sufficient and unambiguous. In particular, + // primary_txt and popup_txt are both black and cannot be distinguished by + // their RGB565 value alone. + buffer.setTextColor(_color); } void LGFXDisplay::setCursor(int x, int y) { - buffer.setCursor(x, y); + buffer.setCursor(x * UI_COORD_SCALE, y * UI_COORD_SCALE); } void LGFXDisplay::print(const char* str) { - buffer.println(str); + String mapped = mapText(str); + buffer.println(mapped.c_str()); // Serial.println(str); } void LGFXDisplay::fillRect(int x, int y, int w, int h) { - buffer.fillRect(x, y, w, h, _color); + buffer.fillRect(x * UI_COORD_SCALE, y * UI_COORD_SCALE, + w * UI_COORD_SCALE, h * UI_COORD_SCALE, _color); } void LGFXDisplay::drawRect(int x, int y, int w, int h) { - buffer.drawRect(x, y, w, h, _color); + if (w <= 0 || h <= 0) return; + if (UI_COORD_SCALE == 1) { + buffer.drawRect(x, y, w, h, _color); + return; + } + int left = x * UI_COORD_SCALE; + int top = y * UI_COORD_SCALE; + int scaled_width = w * UI_COORD_SCALE; + int scaled_height = h * UI_COORD_SCALE; + buffer.fillRect(left, top, scaled_width, UI_COORD_SCALE, _color); + buffer.fillRect(left, top + scaled_height - UI_COORD_SCALE, + scaled_width, UI_COORD_SCALE, _color); + buffer.fillRect(left, top, UI_COORD_SCALE, scaled_height, _color); + buffer.fillRect(left + scaled_width - UI_COORD_SCALE, top, + UI_COORD_SCALE, scaled_height, _color); } void LGFXDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { - buffer.drawBitmap(x, y, bits, w, h, _color); + if (UI_COORD_SCALE == 1) { + buffer.drawBitmap(x, y, bits, w, h, _color); + return; + } + const int row_bytes = (w + 7) / 8; + for (int row = 0; row < h; ++row) { + int column = 0; + while (column < w) { + while (column < w + && !(bits[row * row_bytes + column / 8] + & (0x80 >> (column & 7)))) { + ++column; + } + int first = column; + while (column < w + && (bits[row * row_bytes + column / 8] + & (0x80 >> (column & 7)))) { + ++column; + } + if (first != column) { + buffer.fillRect((x + first) * UI_COORD_SCALE, + (y + row) * UI_COORD_SCALE, + (column - first) * UI_COORD_SCALE, + UI_COORD_SCALE, _color); + } + } + } } uint16_t LGFXDisplay::getTextWidth(const char* str) { - return buffer.textWidth(str); + String mapped = mapText(str); + return (buffer.textWidth(mapped.c_str()) + UI_COORD_SCALE - 1) + / UI_COORD_SCALE; +} + +void LGFXDisplay::translateUTF8ToBlocks(char* dest, const char* src, + size_t dest_size) { + if (!_emojiMap.isReady()) { + DisplayDriver::translateUTF8ToBlocks(dest, src, dest_size); + return; + } + if (dest_size == 0) return; + + size_t output = 0; + for (size_t input = 0; src[input] != 0 && output + 1 < dest_size;) { + uint8_t first = (uint8_t)src[input]; + size_t length = 1; + if (first >= 0xC2 && first <= 0xDF) { + length = 2; + } else if (first >= 0xE0 && first <= 0xEF) { + length = 3; + } else if (first >= 0xF0 && first <= 0xF4) { + length = 4; + } else if (first < 32 || first > 126) { + ++input; + continue; + } + bool valid = true; + for (size_t i = 1; i < length; ++i) { + if (src[input + i] == 0 + || ((uint8_t)src[input + i] & 0xC0) != 0x80) { + valid = false; + break; + } + } + if (!valid || output + length >= dest_size) break; + memcpy(dest + output, src + input, length); + output += length; + input += length; + } + dest[output] = 0; } void LGFXDisplay::endFrame() { + uint32_t hash = frameHash(); + if (_hasLastFrame && hash == _lastFrameHash) return; + _lastFrameHash = hash; + _hasLastFrame = true; + display->startWrite(); if (UI_ZOOM != 1) { - buffer.pushRotateZoom(display, display->width()/2, display->height()/2 , 0, UI_ZOOM, UI_ZOOM); + if (_hasTransparentEmojiPixels) { + buffer.pushRotateZoom(display, display->width() / 2, + display->height() / 2, 0, UI_ZOOM, UI_ZOOM, + TRANSPARENT_EMOJI_COLOR); + } else { + buffer.pushRotateZoom(display, display->width() / 2, + display->height() / 2, 0, UI_ZOOM, UI_ZOOM); + } } else { - buffer.pushSprite(display, 0, 0); + if (_hasTransparentEmojiPixels) { + buffer.pushSprite(display, 0, 0, TRANSPARENT_EMOJI_COLOR); + } else { + buffer.pushSprite(display, 0, 0); + } + } + if (_emojiAtlas.isReady()) { + const float source_x = (_emojiAtlas.width() - 1) * 0.5f; + const float source_y = (_emojiAtlas.height() - 1) * 0.5f; + for (size_t i = 0; i < _emojiOverlayCount; ++i) { + const EmojiOverlay& overlay = _emojiOverlays[i]; + const uint8_t* pixels = _emojiAtlas.glyph(overlay.codepoint); + if (pixels == nullptr) continue; + float size = overlay.size * UI_ZOOM; + float zoom_x = size / _emojiAtlas.width(); + float zoom_y = size / _emojiAtlas.height(); + float x = overlay.x * UI_ZOOM + (size - 1) * 0.5f; + float y = overlay.y * UI_ZOOM + (size - 1) * 0.5f; + display->pushImageRotateZoom( + x, y, source_x, source_y, 0, zoom_x, zoom_y, + _emojiAtlas.width(), _emojiAtlas.height(), + reinterpret_cast(pixels), + lgfx::rgb332_t(_emojiAtlas.transparent())); + } } display->endWrite(); + + _presentedEmojiOverlayCount = _emojiOverlayCount; + memcpy(_presentedEmojiOverlays, _emojiOverlays, + _emojiOverlayCount * sizeof(EmojiOverlay)); } -bool LGFXDisplay::getTouch(int *x, int *y) { - lgfx::v1::touch_point_t point; - display->getTouch(&point); - if (UI_ZOOM != 1) { - *x = point.x / UI_ZOOM; - *y = point.y / UI_ZOOM; +bool LGFXDisplay::getTouch(int* x, int* y) { + lgfx::v1::touch_point_t point = {}; + if (display->getTouch(&point) == 0) return false; + if (UI_ZOOM * UI_COORD_SCALE != 1) { + *x = point.x / (UI_ZOOM * UI_COORD_SCALE); + *y = point.y / (UI_ZOOM * UI_COORD_SCALE); } else { *x = point.x; *y = point.y; } - return (*x >= 0) && (*y >= 0); -} \ No newline at end of file + return *x >= 0 && *x < width() && *y >= 0 && *y < height(); +} + +bool LGFXDisplay::installRuntimeFont(uint8_t* data, size_t size) { + mesh::ui::EmojiFontMap map; + mesh::ui::ColorEmojiAtlas atlas; + if (!validateVlw(data, size) + || !map.begin(data, size) + || !buffer.loadFont(data)) { + free(data); + return false; + } + + if (_fontData != nullptr) free(_fontData); + _fontData = data; + _fontDataSize = size; + _emojiMap = map; + if (atlas.begin(data, size)) { + _emojiAtlas = atlas; + _fontNativeScale = atlas.textScale(); + activeEmojiDisplay = this; + buffer.setEmojiCallback(drawEmoji); + } else { + _emojiAtlas.reset(); + _fontNativeScale = 1; + buffer.setEmojiCallback(nullptr); + if (activeEmojiDisplay == this) activeEmojiDisplay = nullptr; + } + return true; +} + +uint32_t LGFXDisplay::renderColor(ColorVal color) const { +#if UI_BUFFER_COLOR_DEPTH < 8 + if (color == UIColor::primary_txt) return 0; + if (color == UIColor::window_bkg || color == UIColor::title_txt) return 1; + if (color == UIColor::title_bkg) return 2; + if (color == UIColor::secondary_txt) return 3; + if (color == UIColor::warning_txt) return 4; + if (color == UIColor::popup_bkg) return 5; + if (color == UIColor::popup_txt) return 0; + if (color == UIColor::corp_blue) return 6; + return color & 0x0F; +#else + return color; +#endif +} + +void LGFXDisplay::configurePalette() { +#if UI_BUFFER_COLOR_DEPTH < 8 + buffer.createPalette(UI_PALETTE, + sizeof(UI_PALETTE) / sizeof(UI_PALETTE[0])); +#endif +} + +uint32_t LGFXDisplay::frameHash() const { + const uint8_t* data = static_cast(buffer.getBuffer()); + size_t length = buffer.bufferLength(); + uint32_t hash = 2166136261UL; + for (size_t i = 0; i < length; ++i) { + hash = (hash ^ data[i]) * 16777619UL; + } + for (size_t i = 0; i < _emojiOverlayCount; ++i) { + const EmojiOverlay& overlay = _emojiOverlays[i]; + const uint16_t values[] = { + overlay.codepoint, + static_cast(overlay.x), + static_cast(overlay.y), + overlay.size, + }; + for (uint16_t value : values) { + hash = (hash ^ static_cast(value)) * 16777619UL; + hash = (hash ^ static_cast(value >> 8)) * 16777619UL; + } + } + return hash; +} + +bool LGFXDisplay::wasEmojiPresented(const EmojiOverlay& overlay) const { + for (size_t i = 0; i < _presentedEmojiOverlayCount; ++i) { + const EmojiOverlay& presented = _presentedEmojiOverlays[i]; + if (presented.codepoint == overlay.codepoint + && presented.x == overlay.x + && presented.y == overlay.y + && presented.size == overlay.size) { + return true; + } + } + return false; +} + +void LGFXDisplay::drawEmojiMask(int32_t x, int32_t y, int32_t size, + const uint8_t* pixels) { +#if UI_BUFFER_COLOR_DEPTH < 8 + if (pixels == nullptr || size <= 0) return; + const int source_width = _emojiAtlas.width(); + const int source_height = _emojiAtlas.height(); + const uint8_t transparent = _emojiAtlas.transparent(); + for (int source_y = 0; source_y < source_height; ++source_y) { + int top = y + source_y * size / source_height; + int bottom = y + (source_y + 1) * size / source_height; + for (int source_x = 0; source_x < source_width; ++source_x) { + if (pixels[source_y * source_width + source_x] == transparent) continue; + int left = x + source_x * size / source_width; + int right = x + (source_x + 1) * size / source_width; + buffer.fillRect(left, top, right - left, bottom - top, + TRANSPARENT_EMOJI_PALETTE_INDEX); + } + } + _hasTransparentEmojiPixels = true; +#else + (void)x; + (void)y; + (void)size; + (void)pixels; +#endif +} + +void LGFXDisplay::drawEmojiUnderlay(int32_t x, int32_t y, int32_t size, + const uint8_t* pixels) { +#if UI_BUFFER_COLOR_DEPTH < 8 + if (pixels == nullptr || size <= 0) return; + const int source_width = _emojiAtlas.width(); + const int source_height = _emojiAtlas.height(); + const uint8_t transparent = _emojiAtlas.transparent(); + for (int source_y = 0; source_y < source_height; ++source_y) { + int top = y + source_y * size / source_height; + int bottom = y + (source_y + 1) * size / source_height; + for (int source_x = 0; source_x < source_width; ++source_x) { + uint8_t color = pixels[source_y * source_width + source_x]; + if (color == transparent) continue; + int red = ((color >> 5) & 7) * 255 / 7; + int green = ((color >> 2) & 7) * 255 / 7; + int blue = (color & 3) * 255 / 3; + int best_index = 0; + uint32_t best_distance = UINT32_MAX; + // The final palette entry is reserved as the transparent color used to + // preserve an unchanged full-color emoji in the panel framebuffer. + for (int index = 0; index < TRANSPARENT_EMOJI_PALETTE_INDEX; ++index) { + int delta_red = red - (int)((UI_PALETTE[index] >> 16) & 0xFF); + int delta_green = green - (int)((UI_PALETTE[index] >> 8) & 0xFF); + int delta_blue = blue - (int)(UI_PALETTE[index] & 0xFF); + uint32_t distance = delta_red * delta_red + + delta_green * delta_green + delta_blue * delta_blue; + if (distance < best_distance) { + best_distance = distance; + best_index = index; + } + } + int left = x + source_x * size / source_width; + int right = x + (source_x + 1) * size / source_width; + buffer.fillRect(left, top, right - left, bottom - top, best_index); + } + } +#else + (void)x; + (void)y; + (void)size; + (void)pixels; +#endif +} + +int32_t LGFXDisplay::queueEmoji(lgfx::v1::LGFXBase* gfx, + int32_t x, int32_t y, + uint32_t codepoint, + int32_t font_height) { + if (gfx != &buffer || codepoint > 0xFFFF || font_height <= 0 + || _emojiAtlas.glyph((uint16_t)codepoint) == nullptr) { + return 0; + } + if (_emojiOverlayCount >= MAX_EMOJI_OVERLAYS) return font_height; + + EmojiOverlay& overlay = _emojiOverlays[_emojiOverlayCount++]; + overlay.codepoint = (uint16_t)codepoint; + overlay.x = x; + overlay.y = y; + overlay.size = (uint16_t)font_height; + const uint8_t* pixels = _emojiAtlas.glyph(overlay.codepoint); + if (wasEmojiPresented(overlay)) { + // Do not overwrite an unchanged full-color emoji with the indexed canvas + // during periodic message-age redraws. The following direct overlay is + // then idempotent instead of visibly blinking once per second. + drawEmojiMask(x, y, font_height, pixels); + } else { + drawEmojiUnderlay(x, y, font_height, pixels); + } + return font_height; +} + +int32_t LGFXDisplay::drawEmoji(lgfx::v1::LGFXBase* gfx, + int32_t x, int32_t y, + uint32_t codepoint, + int32_t font_height) { + if (activeEmojiDisplay == nullptr) return 0; + return activeEmojiDisplay->queueEmoji(gfx, x, y, codepoint, font_height); +} + +String LGFXDisplay::mapText(const char* str) const { + if (str == nullptr || !_emojiMap.isReady()) return String(str == nullptr ? "" : str); + + const uint8_t* text = (const uint8_t*)str; + size_t length = strlen(str); + String mapped; + mapped.reserve(length + 8); + static const uint8_t VS15[] = {0xEF, 0xB8, 0x8E}; + static const uint8_t VS16[] = {0xEF, 0xB8, 0x8F}; + static const uint8_t ZWJ[] = {0xE2, 0x80, 0x8D}; + + for (size_t offset = 0; offset < length;) { + uint16_t codepoint; + size_t match = _emojiMap.longestMatch(text + offset, length - offset, codepoint); + if (match != 0) { + mapped += (char)(0xE0 | (codepoint >> 12)); + mapped += (char)(0x80 | ((codepoint >> 6) & 0x3F)); + mapped += (char)(0x80 | (codepoint & 0x3F)); + offset += match; + continue; + } + if (text[offset] == ' ') { + mapped += (char)0xC2; + mapped += (char)0xA0; + ++offset; + continue; + } + if (isSequence(text + offset, length - offset, VS15, sizeof(VS15)) + || isSequence(text + offset, length - offset, VS16, sizeof(VS16)) + || isSequence(text + offset, length - offset, ZWJ, sizeof(ZWJ))) { + offset += 3; + continue; + } + mapped += (char)text[offset++]; + } + return mapped; +} diff --git a/src/helpers/ui/LGFXDisplay.h b/src/helpers/ui/LGFXDisplay.h index a2d660b2..a1291841 100644 --- a/src/helpers/ui/LGFXDisplay.h +++ b/src/helpers/ui/LGFXDisplay.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #define LGFX_USE_V1 #include @@ -8,6 +10,12 @@ #ifndef UI_ZOOM #define UI_ZOOM 1 #endif +#ifndef UI_COORD_SCALE + #define UI_COORD_SCALE 1 +#endif +#ifndef UI_BUFFER_COLOR_DEPTH + #define UI_BUFFER_COLOR_DEPTH 8 +#endif class LGFXDisplay : public DisplayDriver { protected: @@ -16,10 +24,45 @@ protected: bool _isOn = false; int _color = TFT_WHITE; + uint8_t* _fontData = nullptr; + size_t _fontDataSize = 0; + mesh::ui::EmojiFontMap _emojiMap; + mesh::ui::ColorEmojiAtlas _emojiAtlas; + uint8_t _fontNativeScale = 1; + + struct EmojiOverlay { + uint16_t codepoint; + int16_t x; + int16_t y; + uint16_t size; + }; + static constexpr size_t MAX_EMOJI_OVERLAYS = 64; + EmojiOverlay _emojiOverlays[MAX_EMOJI_OVERLAYS]; + size_t _emojiOverlayCount = 0; + EmojiOverlay _presentedEmojiOverlays[MAX_EMOJI_OVERLAYS]; + size_t _presentedEmojiOverlayCount = 0; + bool _hasTransparentEmojiPixels = false; + uint32_t _lastFrameHash = 0; + bool _hasLastFrame = false; + + String mapText(const char* str) const; + uint32_t renderColor(ColorVal color) const; + void configurePalette(); + uint32_t frameHash() const; + bool wasEmojiPresented(const EmojiOverlay& overlay) const; + void drawEmojiMask(int32_t x, int32_t y, int32_t size, + const uint8_t* pixels); + void drawEmojiUnderlay(int32_t x, int32_t y, int32_t size, + const uint8_t* pixels); + int32_t queueEmoji(lgfx::v1::LGFXBase* gfx, int32_t x, int32_t y, + uint32_t codepoint, int32_t font_height); + static int32_t drawEmoji(lgfx::v1::LGFXBase* gfx, int32_t x, int32_t y, + uint32_t codepoint, int32_t font_height); public: LGFXDisplay(int w, int h, LGFX_Device &disp) - : DisplayDriver(w/UI_ZOOM, h/UI_ZOOM), display(&disp) {} + : DisplayDriver(w/(UI_ZOOM*UI_COORD_SCALE), + h/(UI_ZOOM*UI_COORD_SCALE)), display(&disp) {} bool begin(); bool isOn() override { return _isOn; } void turnOn() override; @@ -34,6 +77,9 @@ public: void drawRect(int x, int y, int w, int h) override; void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override; uint16_t getTextWidth(const char* str) override; + void translateUTF8ToBlocks(char* dest, const char* src, + size_t dest_size) override; void endFrame() override; - virtual bool getTouch(int *x, int *y); + bool getTouch(int* x, int* y) override; + bool installRuntimeFont(uint8_t* data, size_t size); }; diff --git a/src/helpers/ui/TouchInput.h b/src/helpers/ui/TouchInput.h new file mode 100644 index 00000000..207733ab --- /dev/null +++ b/src/helpers/ui/TouchInput.h @@ -0,0 +1,135 @@ +#pragma once + +#include + +namespace mesh { +namespace ui { + +enum class TouchAction : uint8_t { + None, + Previous, + Next, + Select, + VerticalPrevious, + VerticalNext, +}; + +// Converts one-finger touch samples into the three actions understood by the +// button-oriented companion UI. An action is emitted only after release so a +// drag cannot activate the page under the user's finger. +class TouchInput { + bool _active = false; + bool _reverse_swipes; + bool _separate_vertical_swipes; + uint8_t _center_zone_percent; + uint8_t _touch_samples = 0; + uint8_t _release_samples = 0; + int _start_x = 0; + int _start_y = 0; + int _last_x = 0; + int _last_y = 0; + + static int magnitude(int value) { return value < 0 ? -value : value; } + + TouchAction swipeAction(bool negative_direction) const { + const bool next = negative_direction != _reverse_swipes; + return next ? TouchAction::Next : TouchAction::Previous; + } + + TouchAction verticalSwipeAction(bool negative_direction) const { + if (!_separate_vertical_swipes) return swipeAction(negative_direction); + const bool next = negative_direction != _reverse_swipes; + return next ? TouchAction::VerticalNext + : TouchAction::VerticalPrevious; + } + +public: + explicit TouchInput(bool reverse_swipes = false, + bool separate_vertical_swipes = false, + uint8_t center_zone_percent = 34) + : _reverse_swipes(reverse_swipes), + _separate_vertical_swipes(separate_vertical_swipes), + _center_zone_percent(center_zone_percent > 100 + ? 100 + : center_zone_percent) {} + + TouchAction update(bool touched, int x, int y, int width, int height, + bool bottom_selector = false) { + if (touched) { + _release_samples = 0; + if (!_active) { + _active = true; + _start_x = x; + _start_y = y; + _touch_samples = 0; + } + if (_touch_samples != UINT8_MAX) ++_touch_samples; + _last_x = x; + _last_y = y; + return TouchAction::None; + } + + if (!_active) return TouchAction::None; + // A moving FT5x06-family controller can briefly report no points between + // two valid samples. Require a stable release so that gap cannot split one + // swipe into a swipe followed by an endpoint tap. + if (++_release_samples < 2) return TouchAction::None; + _active = false; + _release_samples = 0; + + // A contact seen only once has no measurable direction. Ignoring it is + // safer than treating the endpoint of a fast swipe as an opposite tap. + if (_touch_samples < 2 || width <= 0 || height <= 0) { + return TouchAction::None; + } + + const int dx = _last_x - _start_x; + const int dy = _last_y - _start_y; + const int abs_dx = magnitude(dx); + const int abs_dy = magnitude(dy); + const int horizontal_threshold = width / 8 > 8 ? width / 8 : 8; + const int vertical_threshold = height / 8 > 8 ? height / 8 : 8; + + if (abs_dx >= abs_dy && abs_dx >= horizontal_threshold) { + return swipeAction(dx < 0); + } + if (abs_dy > abs_dx && abs_dy >= vertical_threshold) { + return verticalSwipeAction(dy < 0); + } + + // Message screens may reserve the otherwise empty ends of their bottom + // status bar as forgiving arrow buttons. Keep the label in the middle + // inert so an imprecise arrow tap cannot accidentally close the screen. + if (bottom_selector && _separate_vertical_swipes + && _start_y >= (height * 3) / 4) { + if (_start_x < width / 4) { + return TouchAction::VerticalPrevious; + } + if (_start_x >= (width * 3) / 4) { + return TouchAction::VerticalNext; + } + return TouchAction::None; + } + + // Taps use three broad zones so every companion action remains available: + // left = previous, center = select, right = next. + // Use the initial position for a tap. If a short swipe falls just below + // the movement threshold, its endpoint may be in the opposite tap zone on + // displays whose touch X axis is inverted. + const int side_percent = (100 - _center_zone_percent) / 2; + const int center_left = (width * side_percent) / 100; + const int center_right = width - center_left; + if (_start_x < center_left) return TouchAction::Previous; + if (_start_x >= center_right) return TouchAction::Next; + return TouchAction::Select; + } + + void reset() { + _active = false; + _touch_samples = 0; + _release_samples = 0; + } +}; + +} // namespace ui +} // namespace mesh diff --git a/test/test_color_emoji_atlas/test_color_emoji_atlas.cpp b/test/test_color_emoji_atlas/test_color_emoji_atlas.cpp new file mode 100644 index 00000000..3fa2519b --- /dev/null +++ b/test/test_color_emoji_atlas/test_color_emoji_atlas.cpp @@ -0,0 +1,93 @@ +#include + +#include + +#include +#include + +using mesh::ui::ColorEmojiAtlas; + +namespace { + +void append16(std::vector& data, uint16_t value) { + data.push_back((uint8_t)value); + data.push_back((uint8_t)(value >> 8)); +} + +void append32(std::vector& data, uint32_t value) { + data.push_back((uint8_t)value); + data.push_back((uint8_t)(value >> 8)); + data.push_back((uint8_t)(value >> 16)); + data.push_back((uint8_t)(value >> 24)); +} + +uint32_t crc32(const uint8_t* data, size_t size) { + uint32_t crc = 0xFFFFFFFFUL; + for (size_t i = 0; i < size; ++i) { + crc ^= data[i]; + for (uint8_t bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U))); + } + } + return ~crc; +} + +std::vector sampleFont() { + std::vector font(24, 0x55); + const uint32_t map_offset = font.size(); + std::vector map = { + 'E', 'M', '0', '1', 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, + }; + font.insert(font.end(), map.begin(), map.end()); + + const uint32_t atlas_offset = font.size(); + std::vector atlas = { + 'C', 'E', '0', '1', + 2, 0, 2, 2, 0xE3, 3, 4, 0, + 1, 2, 3, 4, + 5, 6, 7, 8, + }; + font.insert(font.end(), atlas.begin(), atlas.end()); + + font.insert(font.end(), {'M', 'C', 'E', 'M', 'A', 'P', '2', 0}); + append32(font, map_offset); + append32(font, map.size()); + append32(font, crc32(map.data(), map.size())); + append32(font, atlas_offset); + append32(font, atlas.size()); + append32(font, crc32(atlas.data(), atlas.size())); + append32(font, 0); + append32(font, 0); + return font; +} + +} // namespace + +TEST(ColorEmojiAtlas, ReadsGlyphsAndMetrics) { + std::vector font = sampleFont(); + ColorEmojiAtlas atlas; + ASSERT_TRUE(atlas.begin(font.data(), font.size())); + EXPECT_EQ(2, atlas.width()); + EXPECT_EQ(2, atlas.height()); + EXPECT_EQ(3, atlas.textScale()); + EXPECT_EQ(0xE3, atlas.transparent()); + ASSERT_NE(nullptr, atlas.glyph(0xE000)); + EXPECT_EQ(1, atlas.glyph(0xE000)[0]); + EXPECT_EQ(8, atlas.glyph(0xE001)[3]); + EXPECT_EQ(nullptr, atlas.glyph(0xDFFF)); + EXPECT_EQ(nullptr, atlas.glyph(0xE002)); +} + +TEST(ColorEmojiAtlas, RejectsCorruptAtlas) { + std::vector font = sampleFont(); + font[50] ^= 0x80; + ColorEmojiAtlas atlas; + EXPECT_FALSE(atlas.begin(font.data(), font.size())); + EXPECT_FALSE(atlas.isReady()); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_display_driver/test_display_driver.cpp b/test/test_display_driver/test_display_driver.cpp new file mode 100644 index 00000000..1d88f0f2 --- /dev/null +++ b/test/test_display_driver/test_display_driver.cpp @@ -0,0 +1,83 @@ +#include + +#include + +#include + +namespace { + +bool isValidUTF8(const char* text) { + for (size_t offset = 0; text[offset] != 0;) { + uint8_t first = (uint8_t)text[offset]; + size_t length = 1; + if (first >= 0xC2 && first <= 0xDF) { + length = 2; + } else if (first >= 0xE0 && first <= 0xEF) { + length = 3; + } else if (first >= 0xF0 && first <= 0xF4) { + length = 4; + } else if (first >= 0x80) { + return false; + } + for (size_t i = 1; i < length; ++i) { + if (text[offset + i] == 0 + || ((uint8_t)text[offset + i] & 0xC0) != 0x80) { + return false; + } + } + offset += length; + } + return true; +} + +class TestDisplay : public DisplayDriver { +public: + std::string printed; + + TestDisplay() : DisplayDriver(100, 100) {} + + bool isOn() override { return true; } + void turnOn() override {} + void turnOff() override {} + void clear() override {} + void startFrame(ColorVal) override {} + void setTextSize(int) override {} + void setColor(ColorVal) override {} + void setCursor(int, int) override {} + void print(const char* str) override { printed = str; } + void fillRect(int, int, int, int) override {} + void drawRect(int, int, int, int) override {} + void drawXbm(int, int, const uint8_t*, int, int) override {} + uint16_t getTextWidth(const char* str) override { + uint16_t width = 0; + for (size_t i = 0; str[i] != 0; ++i) { + if (((uint8_t)str[i] & 0xC0) != 0x80) ++width; + } + return width; + } + void endFrame() override {} +}; + +} // namespace + +TEST(DisplayDriver, EllipsizesOnlyAtUTF8CodepointBoundaries) { + TestDisplay display; + display.drawTextEllipsized(0, 0, 5, "AB\xF0\x9F\x98\x80" "CDE"); + EXPECT_EQ("AB...", display.printed); + EXPECT_TRUE(isValidUTF8(display.printed.c_str())); +} + +TEST(DisplayDriver, FixedBufferDoesNotSplitUTF8Codepoint) { + TestDisplay display; + std::string text(254, 'A'); + text += "\xF0\x9F\x98\x80"; + text += 'B'; + display.drawTextEllipsized(0, 0, 255, text.c_str()); + EXPECT_EQ(std::string(254, 'A'), display.printed); + EXPECT_TRUE(isValidUTF8(display.printed.c_str())); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_emoji_font_map/test_emoji_font_map.cpp b/test/test_emoji_font_map/test_emoji_font_map.cpp new file mode 100644 index 00000000..36c16959 --- /dev/null +++ b/test/test_emoji_font_map/test_emoji_font_map.cpp @@ -0,0 +1,131 @@ +#include + +#include + +#include +#include +#include + +using mesh::ui::EmojiFontMap; + +namespace { + +void append16(std::vector& data, uint16_t value) { + data.push_back((uint8_t)value); + data.push_back((uint8_t)(value >> 8)); +} + +void append32(std::vector& data, uint32_t value) { + data.push_back((uint8_t)value); + data.push_back((uint8_t)(value >> 8)); + data.push_back((uint8_t)(value >> 16)); + data.push_back((uint8_t)(value >> 24)); +} + +uint32_t crc32(const uint8_t* data, size_t size) { + uint32_t crc = 0xFFFFFFFFUL; + for (size_t i = 0; i < size; ++i) { + crc ^= data[i]; + for (uint8_t bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U))); + } + } + return ~crc; +} + +std::vector sampleFont() { + std::vector font(32, 0x55); + const uint32_t map_offset = font.size(); + std::vector map; + map.insert(map.end(), {'E', 'M', '0', '1'}); + append16(map, 6); // nodes + append16(map, 5); // edges + append32(map, 0); + + // Root: F0 -> 1 + append16(map, 0); append16(map, 1); append16(map, 0); + // F0: 9F -> 2 + append16(map, 1); append16(map, 1); append16(map, 0); + // F0 9F: 98 -> 3 + append16(map, 2); append16(map, 1); append16(map, 0); + // F0 9F 98: 80 -> 4 + append16(map, 3); append16(map, 1); append16(map, 0); + // Grinning face, also prefix of the test sequence below. + append16(map, 4); append16(map, 1); append16(map, 0xE000); + // Grinning face followed by '!'. + append16(map, 5); append16(map, 0); append16(map, 0xE001); + + map.push_back(0xF0); append16(map, 1); + map.push_back(0x9F); append16(map, 2); + map.push_back(0x98); append16(map, 3); + map.push_back(0x80); append16(map, 4); + map.push_back('!'); append16(map, 5); + + font.insert(font.end(), map.begin(), map.end()); + font.insert(font.end(), {'M', 'C', 'E', 'M', 'A', 'P', '1', 0}); + append32(font, map_offset); + append32(font, map.size()); + append32(font, crc32(map.data(), map.size())); + append32(font, 0); + return font; +} + +std::vector sampleVersion2Font() { + std::vector font = sampleFont(); + font.resize(font.size() - 24); + const uint32_t map_offset = 32; + const uint32_t map_size = font.size() - map_offset; + const uint32_t map_crc = crc32(font.data() + map_offset, map_size); + font.insert(font.end(), {'M', 'C', 'E', 'M', 'A', 'P', '2', 0}); + append32(font, map_offset); + append32(font, map_size); + append32(font, map_crc); + for (int i = 0; i < 5; ++i) append32(font, 0); + return font; +} + +} // namespace + +TEST(EmojiFontMap, UsesLongestSequenceMatch) { + std::vector font = sampleFont(); + EmojiFontMap map; + ASSERT_TRUE(map.begin(font.data(), font.size())); + + const uint8_t text[] = {0xF0, 0x9F, 0x98, 0x80, '!', 'x'}; + uint16_t output = 0; + EXPECT_EQ(5U, map.longestMatch(text, sizeof(text), output)); + EXPECT_EQ(0xE001, output); + + EXPECT_EQ(4U, map.longestMatch(text, 4, output)); + EXPECT_EQ(0xE000, output); +} + +TEST(EmojiFontMap, RejectsCorruptMappingData) { + std::vector font = sampleFont(); + font[40] ^= 0x80; + EmojiFontMap map; + EXPECT_FALSE(map.begin(font.data(), font.size())); + EXPECT_FALSE(map.isReady()); +} + +TEST(EmojiFontMap, RejectsMissingFooter) { + uint8_t data[64] = {}; + EmojiFontMap map; + EXPECT_FALSE(map.begin(data, sizeof(data))); +} + +TEST(EmojiFontMap, AcceptsVersion2Footer) { + std::vector font = sampleVersion2Font(); + EmojiFontMap map; + ASSERT_TRUE(map.begin(font.data(), font.size())); + + const uint8_t text[] = {0xF0, 0x9F, 0x98, 0x80}; + uint16_t output = 0; + EXPECT_EQ(sizeof(text), map.longestMatch(text, sizeof(text), output)); + EXPECT_EQ(0xE000, output); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_touch_input/test_touch_input.cpp b/test/test_touch_input/test_touch_input.cpp new file mode 100644 index 00000000..bd968156 --- /dev/null +++ b/test/test_touch_input/test_touch_input.cpp @@ -0,0 +1,181 @@ +#include + +#include + +using mesh::ui::TouchAction; +using mesh::ui::TouchInput; + +namespace { + +TouchAction release(TouchInput& input, int width = 137, int height = 137) { + EXPECT_EQ(input.update(false, -1, -1, width, height), TouchAction::None); + return input.update(false, -1, -1, width, height); +} + +} // namespace + +TEST(TouchInput, EmitsOnlyAfterRelease) { + TouchInput input; + EXPECT_EQ(input.update(true, 70, 50, 137, 137), TouchAction::None); + EXPECT_EQ(input.update(true, 72, 51, 137, 137), TouchAction::None); + EXPECT_EQ(release(input), TouchAction::Select); + EXPECT_EQ(input.update(false, -1, -1, 137, 137), TouchAction::None); +} + +TEST(TouchInput, MapsTapZonesToAllActions) { + TouchInput input; + + input.update(true, 10, 60, 137, 137); + input.update(true, 10, 60, 137, 137); + EXPECT_EQ(release(input), TouchAction::Previous); + + input.update(true, 68, 60, 137, 137); + input.update(true, 68, 60, 137, 137); + EXPECT_EQ(release(input), TouchAction::Select); + + input.update(true, 125, 60, 137, 137); + input.update(true, 125, 60, 137, 137); + EXPECT_EQ(release(input), TouchAction::Next); +} + +TEST(TouchInput, CanUseAWiderCenterTapZone) { + TouchInput input(false, false, 70); + + input.update(true, 25, 60, 137, 137); + input.update(true, 25, 60, 137, 137); + EXPECT_EQ(release(input), TouchAction::Select); + + input.update(true, 10, 60, 137, 137); + input.update(true, 10, 60, 137, 137); + EXPECT_EQ(release(input), TouchAction::Previous); + + input.update(true, 127, 60, 137, 137); + input.update(true, 127, 60, 137, 137); + EXPECT_EQ(release(input), TouchAction::Next); +} + +TEST(TouchInput, MapsHorizontalSwipesToPageNavigation) { + TouchInput input; + + input.update(true, 110, 60, 137, 137); + input.update(true, 40, 62, 137, 137); + EXPECT_EQ(release(input), TouchAction::Next); + + input.update(true, 30, 60, 137, 137); + input.update(true, 105, 58, 137, 137); + EXPECT_EQ(release(input), TouchAction::Previous); +} + +TEST(TouchInput, MapsVerticalSwipesToPageNavigation) { + TouchInput input; + + input.update(true, 60, 110, 137, 137); + input.update(true, 62, 35, 137, 137); + EXPECT_EQ(release(input), TouchAction::Next); + + input.update(true, 60, 25, 137, 137); + input.update(true, 58, 105, 137, 137); + EXPECT_EQ(release(input), TouchAction::Previous); +} + +TEST(TouchInput, CanRouteVerticalSwipesToASeparateSelector) { + TouchInput input(true, true); + + input.update(true, 60, 110, 137, 137); + input.update(true, 62, 35, 137, 137); + EXPECT_EQ(release(input), TouchAction::VerticalPrevious); + + input.update(true, 60, 25, 137, 137); + input.update(true, 58, 105, 137, 137); + EXPECT_EQ(release(input), TouchAction::VerticalNext); + + input.update(true, 110, 60, 137, 137); + input.update(true, 40, 62, 137, 137); + EXPECT_EQ(release(input), TouchAction::Previous); +} + +TEST(TouchInput, BottomSelectorHasLargeArrowTapTargets) { + TouchInput input(true, true, 70); + + input.update(true, 15, 125, 137, 137, true); + input.update(true, 15, 125, 137, 137, true); + EXPECT_EQ(input.update(false, -1, -1, 137, 137, true), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 137, 137, true), + TouchAction::VerticalPrevious); + + input.update(true, 122, 125, 137, 137, true); + input.update(true, 122, 125, 137, 137, true); + EXPECT_EQ(input.update(false, -1, -1, 137, 137, true), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 137, 137, true), + TouchAction::VerticalNext); + + input.update(true, 68, 125, 137, 137, true); + input.update(true, 68, 125, 137, 137, true); + EXPECT_EQ(input.update(false, -1, -1, 137, 137, true), + TouchAction::None); + EXPECT_EQ(input.update(false, -1, -1, 137, 137, true), + TouchAction::None); +} + +TEST(TouchInput, CanReverseSwipesWithoutReversingTapZones) { + TouchInput input(true); + + input.update(true, 110, 60, 137, 137); + input.update(true, 40, 62, 137, 137); + EXPECT_EQ(release(input), TouchAction::Previous); + + input.update(true, 30, 60, 137, 137); + input.update(true, 105, 58, 137, 137); + EXPECT_EQ(release(input), TouchAction::Next); + + input.update(true, 10, 60, 137, 137); + input.update(true, 10, 60, 137, 137); + EXPECT_EQ(release(input), TouchAction::Previous); + + input.update(true, 125, 60, 137, 137); + input.update(true, 125, 60, 137, 137); + EXPECT_EQ(release(input), TouchAction::Next); +} + +TEST(TouchInput, KeepsGestureAcrossOneMissingTouchSample) { + TouchInput input; + + input.update(true, 110, 60, 137, 137); + input.update(true, 80, 61, 137, 137); + EXPECT_EQ(input.update(false, -1, -1, 137, 137), TouchAction::None); + input.update(true, 40, 62, 137, 137); + EXPECT_EQ(release(input), TouchAction::Next); +} + +TEST(TouchInput, IgnoresContactWithNoDirectionSample) { + TouchInput input; + + input.update(true, 120, 60, 137, 137); + EXPECT_EQ(release(input), TouchAction::None); +} + +TEST(TouchInput, UsesStartZoneForShortAmbiguousMovement) { + TouchInput input(true); + + input.update(true, 40, 60, 137, 137); + input.update(true, 52, 60, 137, 137); + EXPECT_EQ(release(input), TouchAction::Previous); +} + +TEST(TouchInput, RepeatedSwipesKeepTheSameDirection) { + TouchInput input(true); + + for (int attempt = 0; attempt < 10; ++attempt) { + input.update(true, 110, 60, 137, 137); + input.update(true, 72, 61, 137, 137); + input.update(true, 35, 62, 137, 137); + EXPECT_EQ(release(input), TouchAction::Previous); + } +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tools/sensecap_indicator_font/OFL-1.1.txt b/tools/sensecap_indicator_font/OFL-1.1.txt new file mode 100644 index 00000000..e03b82db --- /dev/null +++ b/tools/sensecap_indicator_font/OFL-1.1.txt @@ -0,0 +1,94 @@ +Copyright 2013 Google LLC +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font Software, +subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/tools/sensecap_indicator_font/README.md b/tools/sensecap_indicator_font/README.md new file mode 100644 index 00000000..6c9cf7a4 --- /dev/null +++ b/tools/sensecap_indicator_font/README.md @@ -0,0 +1,35 @@ +# SenseCAP Indicator SD font + +`generate_font.py` creates the native-resolution font used by the SenseCAP +Indicator. Text is Noto Sans Mono Bold rendered into an 18x24 monospaced cell. +Every text pixel is binary (`0` or `255`), so the font remains sharp and has no +antialiasing. + +The complete Unicode Emoji 17.0 RGI set is rendered from Noto Color Emoji into +12x12 RGB332 cells. Transparency is binary, and each cell is enlarged 2x with +nearest-neighbor scaling for a crisp 24x24 result on the 480x480 panel. Emoji +sequences are mapped into the BMP private-use range by a trie appended to the +font file because the display library's runtime font interface accepts 16-bit +glyph identifiers. The private-use glyph is intercepted at draw time and the +corresponding color cell is overlaid after the UI canvas is copied. +On later UI refreshes, unchanged emoji pixels are excluded from the indexed +canvas transfer. This preserves the already-presented color cell and avoids a +visible fallback-to-color flash. + +The generated `ui-font.vlw` is stored on the Indicator's SD card. The RP2040 +font service streams it to the ESP32-S3 at boot; the application falls back to +its built-in font if the card, service, file, checksum, or mapping is invalid. + +To reproduce the checked-in asset: + +```bash +python3 -m venv /tmp/meshcore-font-venv +/tmp/meshcore-font-venv/bin/pip install -r tools/sensecap_indicator_font/requirements.txt +/tmp/meshcore-font-venv/bin/python tools/sensecap_indicator_font/generate_font.py \ + --output variants/sensecap_indicator-espnow/sd/ui-font.vlw \ + --preview /tmp/meshcore-indicator-font-preview.png +``` + +The source fonts are licensed under the SIL Open Font License 1.1; see +`OFL-1.1.txt`. The Unicode data source, pinned color-font revision, and exact +input hashes are recorded in the generated JSON manifest. diff --git a/tools/sensecap_indicator_font/generate_font.py b/tools/sensecap_indicator_font/generate_font.py new file mode 100644 index 00000000..5be95d64 --- /dev/null +++ b/tools/sensecap_indicator_font/generate_font.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""Build the Indicator's native-resolution text and color emoji font.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import struct +import urllib.request +import zlib +from dataclasses import dataclass, field +from pathlib import Path + +from fontTools.ttLib import TTFont +from PIL import Image, ImageDraw, ImageFont + + +SOURCES = { + "NotoColorEmoji.ttf": ( + "https://raw.githubusercontent.com/googlefonts/noto-emoji/" + "8998f5dd683424a73e2314a8c1f1e359c19e8742/fonts/" + "NotoColorEmoji.ttf", + "72a635cb3d2f3524c51620cdde406b217204e8a6a06c6a096ff8ed4b5fd6e27b", + ), + "NotoSansMono-wdth-wght.ttf": ( + "https://raw.githubusercontent.com/google/fonts/main/ofl/notosansmono/" + "NotoSansMono%5Bwdth%2Cwght%5D.ttf", + "2cb2adb378a8f574213e23df697050b83c54c27df465a2015552740b2769a081", + ), + "emoji-test-17.0.txt": ( + "https://www.unicode.org/Public/17.0.0/emoji/emoji-test.txt", + "1d8a944f88d7952f7ef7c5167fef3c67995bcae24543949710231b03a201acda", + ), +} + +TEXT_CELL_WIDTH = 18 +EMOJI_CELL_WIDTH = 12 +CELL_HEIGHT = 24 +EMOJI_CELL_HEIGHT = 12 +BASELINE = 18 +TEXT_NATIVE_SCALE = 3 +FIRST_EMOJI_GLYPH = 0xE000 +LAST_EMOJI_GLYPH = 0xF8FF +FOOTER_MAGIC = b"MCEMAP2\0" +ATLAS_MAGIC = b"CE01" +TRANSPARENT_RGB332 = 0xE3 +PIXEL_OFF = 0 +PIXEL_ON = 255 +TEXT_THRESHOLD = 96 +EMOJI_ALPHA_THRESHOLD = 48 +EMOJI_REGRESSION_MIN_ON_PIXELS = { + "\U0001f44b": 60, # waving hand + "\U0001f642": 70, # slightly smiling face + "\U0001f60a": 70, # smiling face with smiling eyes + "\U0001f44d": 60, # thumbs up +} + + +def digest(path: Path) -> str: + sha = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + sha.update(block) + return sha.hexdigest() + + +def fetch_sources(cache_dir: Path) -> dict[str, Path]: + cache_dir.mkdir(parents=True, exist_ok=True) + paths: dict[str, Path] = {} + for name, (url, expected_sha) in SOURCES.items(): + path = cache_dir / name + if not path.exists() or digest(path) != expected_sha: + print(f"Downloading {name}") + with urllib.request.urlopen(url, timeout=60) as response: + contents = response.read() + if hashlib.sha256(contents).hexdigest() != expected_sha: + raise RuntimeError(f"source checksum mismatch: {name}") + path.write_bytes(contents) + paths[name] = path + return paths + + +def parse_emoji_test(path: Path) -> tuple[list[tuple[str, int, str]], dict[bytes, int]]: + glyphs: list[tuple[str, int, str]] = [] + aliases: dict[bytes, int] = {} + last_output = 0 + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + data, comment = line.split("#", 1) + codepoints, status = data.split(";", 1) + sequence = "".join(chr(int(value, 16)) for value in codepoints.split()) + status = status.strip() + description_fields = comment.strip().split(" ", 2) + description = description_fields[2] if len(description_fields) == 3 else sequence + encoded = sequence.encode("utf-8") + if status in ("fully-qualified", "component"): + output = FIRST_EMOJI_GLYPH + len(glyphs) + if output > LAST_EMOJI_GLYPH: + raise RuntimeError("emoji glyphs exceed the BMP private-use range") + glyphs.append((sequence, output, description)) + aliases[encoded] = output + last_output = output + elif status in ("minimally-qualified", "unqualified"): + if last_output == 0: + raise RuntimeError("emoji qualification alias has no canonical glyph") + aliases[encoded] = last_output + + # Accept clients that omit the emoji presentation selector even when the + # qualification file does not list that spelling separately. + for sequence, output, _ in glyphs: + without_vs16 = sequence.replace("\ufe0f", "").encode("utf-8") + aliases.setdefault(without_vs16, output) + return glyphs, aliases + + +def text_codepoints(font_path: Path) -> list[int]: + font = TTFont(font_path, lazy=True) + supported: set[int] = set() + for table in font["cmap"].tables: + if table.isUnicode(): + supported.update(table.cmap) + font.close() + + ranges = ( + (0x21, 0x7E), # ASCII (space is encoded as NBSP at runtime) + (0x00A0, 0x024F), # Latin and phonetic extensions + (0x0370, 0x052F), # Greek and Cyrillic + (0x2000, 0x206F), # punctuation and format marks + (0x20A0, 0x20CF), # currency + (0x2100, 0x214F), # letter-like symbols + (0x2190, 0x21FF), # arrows + (0x2500, 0x259F), # box drawing, blocks, and geometric fill + ) + return [ + codepoint + for first, last in ranges + for codepoint in range(first, last + 1) + if codepoint in supported and not 0xE000 <= codepoint <= 0xF8FF + ] + + +def make_binary(image: Image.Image, threshold: int) -> Image.Image: + return image.point( + lambda value: PIXEL_ON if value >= threshold else PIXEL_OFF, + mode="L", + ) + + +def render_text_glyph(font: ImageFont.FreeTypeFont, codepoint: int) -> bytes: + image = Image.new("L", (TEXT_CELL_WIDTH, CELL_HEIGHT), 0) + draw = ImageDraw.Draw(image) + draw.text( + (TEXT_CELL_WIDTH // 2, BASELINE - 1), + chr(codepoint), + font=font, + fill=255, + anchor="ms", + ) + return make_binary(image, TEXT_THRESHOLD).tobytes() + + +def render_empty_emoji_fallback(sequence: str) -> Image.Image: + image = Image.new( + "RGBA", (EMOJI_CELL_WIDTH, EMOJI_CELL_HEIGHT), (0, 0, 0, 0) + ) + if sequence == "\u2796": + ImageDraw.Draw(image).line( + ( + 1, + EMOJI_CELL_HEIGHT // 2, + EMOJI_CELL_WIDTH - 2, + EMOJI_CELL_HEIGHT // 2, + ), + fill=(50, 120, 220, 255), + width=2, + ) + return image + raise RuntimeError(f"emoji rendered blank: {sequence!r}") + + +def render_emoji_image(font: ImageFont.FreeTypeFont, sequence: str) -> Image.Image: + bbox = font.getbbox(sequence, anchor="lt") + if bbox is None or bbox[2] <= bbox[0] or bbox[3] <= bbox[1]: + return render_empty_emoji_fallback(sequence) + source = Image.new( + "RGBA", (bbox[2] - bbox[0], bbox[3] - bbox[1]), (0, 0, 0, 0) + ) + ImageDraw.Draw(source).text( + (-bbox[0], -bbox[1]), + sequence, + font=font, + embedded_color=True, + ) + cropped_bbox = source.getchannel("A").getbbox() + if cropped_bbox is None: + return render_empty_emoji_fallback(sequence) + source = source.crop(cropped_bbox) + scale = min( + EMOJI_CELL_WIDTH / source.width, + EMOJI_CELL_HEIGHT / source.height, + ) + width = max(1, round(source.width * scale)) + height = max(1, round(source.height * scale)) + source = source.resize((width, height), Image.Resampling.LANCZOS) + image = Image.new( + "RGBA", (EMOJI_CELL_WIDTH, EMOJI_CELL_HEIGHT), (0, 0, 0, 0) + ) + image.paste( + source, + ( + (EMOJI_CELL_WIDTH - width) // 2, + (EMOJI_CELL_HEIGHT - height) // 2, + ), + source, + ) + if image.getchannel("A").getbbox() is None: + return render_empty_emoji_fallback(sequence) + return image + + +def pack_rgb332(image: Image.Image) -> bytes: + packed = bytearray() + for red, green, blue, alpha in image.getdata(): + if alpha < EMOJI_ALPHA_THRESHOLD: + packed.append(TRANSPARENT_RGB332) + continue + value = (red & 0xE0) | ((green >> 3) & 0x1C) | (blue >> 6) + if value == TRANSPARENT_RGB332: + value ^= 1 + packed.append(value) + return bytes(packed) + + +@dataclass +class TrieNode: + children: dict[int, "TrieNode"] = field(default_factory=dict) + output: int = 0 + index: int = 0 + + +def build_map(aliases: dict[bytes, int]) -> bytes: + root = TrieNode() + for sequence, output in sorted(aliases.items()): + node = root + for value in sequence: + node = node.children.setdefault(value, TrieNode()) + if node.output not in (0, output): + raise RuntimeError("conflicting emoji aliases") + node.output = output + + nodes: list[TrieNode] = [root] + for node in nodes: + for _, child in sorted(node.children.items()): + if child.index == 0 and child is not root: + child.index = len(nodes) + nodes.append(child) + if len(nodes) > 0xFFFF: + raise RuntimeError("emoji trie has too many nodes") + + node_records = bytearray() + edge_records = bytearray() + edge_index = 0 + for node in nodes: + children = sorted(node.children.items()) + node_records += struct.pack(" 0xFFFF: + raise RuntimeError("emoji trie has too many edges") + + return ( + b"EM01" + + struct.pack(" bytes: + return struct.pack( + ">7I", + codepoint, + CELL_HEIGHT, + width, + width, + BASELINE, + 0, + 0, + ) + + +def rgb332_to_rgba(value: int) -> tuple[int, int, int, int]: + if value == TRANSPARENT_RGB332: + return (0, 0, 0, 0) + red = (value >> 5) & 7 + green = (value >> 2) & 7 + blue = value & 3 + return ( + (red * 255 + 3) // 7, + (green * 255 + 3) // 7, + (blue * 255 + 1) // 3, + 255, + ) + + +def make_preview(path: Path, emoji_bitmaps: list[tuple[str, bytes]]) -> None: + scale = 6 + columns = 16 + rows = (len(emoji_bitmaps) + columns - 1) // columns + preview = Image.new( + "RGBA", + ( + columns * EMOJI_CELL_WIDTH * scale, + rows * EMOJI_CELL_HEIGHT * scale, + ), + (28, 28, 28, 255), + ) + for index, (_, bitmap) in enumerate(emoji_bitmaps): + glyph = Image.new("RGBA", (EMOJI_CELL_WIDTH, EMOJI_CELL_HEIGHT)) + glyph.putdata([rgb332_to_rgba(value) for value in bitmap]) + glyph = glyph.resize( + (EMOJI_CELL_WIDTH * scale, EMOJI_CELL_HEIGHT * scale), + Image.Resampling.NEAREST, + ) + x = (index % columns) * EMOJI_CELL_WIDTH * scale + y = (index // columns) * EMOJI_CELL_HEIGHT * scale + preview.alpha_composite(glyph, (x, y)) + preview.save(path) + + +def build_font(output: Path, cache_dir: Path, preview: Path | None) -> None: + sources = fetch_sources(cache_dir) + emoji_glyphs, aliases = parse_emoji_test(sources["emoji-test-17.0.txt"]) + text_points = text_codepoints(sources["NotoSansMono-wdth-wght.ttf"]) + + text_font = ImageFont.truetype( + str(sources["NotoSansMono-wdth-wght.ttf"]), 20 + ) + text_font.set_variation_by_name("Bold") + emoji_font = ImageFont.truetype(str(sources["NotoColorEmoji.ttf"]), 109) + + glyphs: list[tuple[int, int, bytes]] = [ + ( + codepoint, + TEXT_CELL_WIDTH, + render_text_glyph(text_font, codepoint), + ) + for codepoint in text_points + ] + rendered_emoji: list[tuple[str, bytes]] = [] + color_emoji: list[bytes] = [] + for index, (sequence, _, _) in enumerate(emoji_glyphs, start=1): + bitmap = pack_rgb332(render_emoji_image(emoji_font, sequence)) + minimum_pixels = EMOJI_REGRESSION_MIN_ON_PIXELS.get(sequence) + if minimum_pixels is not None: + on_pixels = sum(pixel != TRANSPARENT_RGB332 for pixel in bitmap) + if on_pixels < minimum_pixels: + raise RuntimeError( + f"emoji lost identifying pixels: {sequence!r} " + f"has {on_pixels}, expected at least {minimum_pixels}" + ) + color_emoji.append(bitmap) + if preview is not None and (index <= 128 or index % 97 == 0): + rendered_emoji.append((sequence, bitmap)) + if index % 500 == 0: + print(f"Rendered {index}/{len(emoji_glyphs)} emoji") + + if any( + pixel not in (PIXEL_OFF, PIXEL_ON) + for _, _, bitmap in glyphs + for pixel in bitmap + ): + raise RuntimeError("font contains antialiased pixels") + + glyphs.sort(key=lambda item: item[0]) + header = struct.pack(">6I", len(glyphs), 11, CELL_HEIGHT, 0, BASELINE, 1) + records = b"".join( + glyph_record(codepoint, width) for codepoint, width, _ in glyphs + ) + bitmaps = b"".join(bitmap for _, _, bitmap in glyphs) + mapping = build_map(aliases) + map_offset = len(header) + len(records) + len(bitmaps) + atlas_header = ATLAS_MAGIC + struct.pack( + " None: + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path, required=True) + parser.add_argument( + "--cache-dir", + type=Path, + default=Path.home() / ".cache" / "meshcore-indicator-font", + ) + parser.add_argument("--preview", type=Path) + args = parser.parse_args() + build_font(args.output, args.cache_dir, args.preview) + + +if __name__ == "__main__": + main() diff --git a/tools/sensecap_indicator_font/requirements.txt b/tools/sensecap_indicator_font/requirements.txt new file mode 100644 index 00000000..11beccaf --- /dev/null +++ b/tools/sensecap_indicator_font/requirements.txt @@ -0,0 +1,2 @@ +fonttools==4.59.1 +Pillow==10.2.0 diff --git a/tools/sensecap_indicator_rp2040/README.md b/tools/sensecap_indicator_rp2040/README.md new file mode 100644 index 00000000..8a6030f7 --- /dev/null +++ b/tools/sensecap_indicator_rp2040/README.md @@ -0,0 +1,33 @@ +# SenseCAP Indicator SD font service + +The Indicator connects its SD card to the RP2040 rather than the ESP32-S3. +This small RP2040 image owns the card and streams `/meshcore/ui-font.vlw` to +the main application over the internal UART at boot. The ESP32-S3 verifies the +whole-file CRC and falls back to its built-in font if the service, card, file, +or emoji map is unavailable or invalid. + +Build and flash the service through the RP2040 USB connector: + +```bash +pio run -d tools/sensecap_indicator_rp2040 +pio run -d tools/sensecap_indicator_rp2040 -t upload --upload-port /dev/ttyACM2 +``` + +Then upload the generated font to the SD card through that same USB serial +port. The update is checksummed and uses temporary and backup files so an +interrupted upload keeps the last complete font: + +```bash +python3 -m pip install pyserial +python3 tools/sensecap_indicator_rp2040/upload_font.py --port /dev/ttyACM2 +``` + +The checked-in asset contains binary 18x24 Noto Sans Mono Bold text and a +12x12 RGB332 Unicode Emoji 17.0 color atlas. Text and emoji transparency have +no antialiasing; the ESP32-S3 renders the asset at the panel's native 480x480 +resolution. See +`../sensecap_indicator_font/README.md` to regenerate the asset. + +For diagnostics, send `MCFONT STATUS` over RP2040 USB serial. The response +reports internal-UART INFO requests, GET attempts, completed streams, and +bytes sent since the RP2040 last booted. diff --git a/tools/sensecap_indicator_rp2040/platformio.ini b/tools/sensecap_indicator_rp2040/platformio.ini new file mode 100644 index 00000000..a9b9cb9f --- /dev/null +++ b/tools/sensecap_indicator_rp2040/platformio.ini @@ -0,0 +1,13 @@ +[platformio] +default_envs = sensecap_indicator_rp2040_font +src_dir = src + +[env:sensecap_indicator_rp2040_font] +platform = https://github.com/maxgerhardt/platform-raspberrypi.git#4e22a0dcdd94eead72fdf56ea59a3d7c8aa2f379 +board = seeed_indicator_rp2040 +framework = arduino +monitor_speed = 115200 +upload_protocol = picotool +build_flags = + -Wall + -Wextra diff --git a/tools/sensecap_indicator_rp2040/src/main.cpp b/tools/sensecap_indicator_rp2040/src/main.cpp new file mode 100644 index 00000000..3976345c --- /dev/null +++ b/tools/sensecap_indicator_rp2040/src/main.cpp @@ -0,0 +1,385 @@ +#include +#include +#include + +namespace { + +constexpr uint32_t FONT_UART_BAUD = 1000000; +constexpr uint32_t SD_CLOCK_HZ = 1000000; +constexpr uint32_t RECEIVE_IDLE_TIMEOUT_MS = 10000; +constexpr size_t MAX_FONT_BYTES = 1536 * 1024; + +constexpr int FONT_UART_TX = 16; +constexpr int FONT_UART_RX = 17; +constexpr int SD_SCK = 10; +constexpr int SD_MOSI = 11; +constexpr int SD_MISO = 12; +constexpr int SD_CS = 13; + +constexpr const char* FONT_DIRECTORY = "/meshcore"; +constexpr const char* FONT_PATH = "/meshcore/ui-font.vlw"; +constexpr const char* FONT_META_PATH = "/meshcore/ui-font.meta"; +constexpr const char* TEMP_FONT_PATH = "/meshcore/ui-font.tmp"; +constexpr const char* TEMP_META_PATH = "/meshcore/ui-font-meta.tmp"; +constexpr const char* BACKUP_FONT_PATH = "/meshcore/ui-font.bak"; +constexpr const char* BACKUP_META_PATH = "/meshcore/ui-font-meta.bak"; + +bool sdReady = false; +size_t fontSize = 0; +uint32_t fontCrc = 0; +uint32_t espInfoRequests = 0; +uint32_t espGetAttempts = 0; +uint32_t espGetCompleted = 0; +size_t espLastBytes = 0; + +uint32_t updateCrc32(uint32_t crc, const uint8_t* data, size_t size) { + for (size_t i = 0; i < size; ++i) { + crc ^= data[i]; + for (uint8_t bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U))); + } + } + return crc; +} + +bool readLine(File& file, char* line, size_t capacity) { + size_t length = 0; + while (file.available()) { + int value = file.read(); + if (value < 0) break; + if (value == '\n') { + line[length] = 0; + return true; + } + if (value != '\r' && length + 1 < capacity) { + line[length++] = (char)value; + } + } + line[length] = 0; + return length != 0; +} + +bool parseMetadata(const char* line, size_t& size, uint32_t& crc) { + unsigned long parsedSize = 0; + unsigned long parsedCrc = 0; + if (sscanf(line, "MCFONT 1 %lu %lx", &parsedSize, &parsedCrc) != 2 + || parsedSize < 64 + || parsedSize > MAX_FONT_BYTES) { + return false; + } + size = (size_t)parsedSize; + crc = (uint32_t)parsedCrc; + return true; +} + +bool validatePair(const char* fontPath, const char* metadataPath, + size_t& size, uint32_t& crc) { + File metadata = SD.open(metadataPath, FILE_READ); + if (!metadata) return false; + + char line[64]; + bool valid = readLine(metadata, line, sizeof(line)) + && parseMetadata(line, size, crc); + metadata.close(); + if (!valid) return false; + + File font = SD.open(fontPath, FILE_READ); + if (!font) return false; + valid = font.size() == size; + font.close(); + return valid; +} + +void removeIfPresent(const char* path) { + if (SD.exists(path)) SD.remove(path); +} + +void cleanTransactionFiles() { + removeIfPresent(TEMP_FONT_PATH); + removeIfPresent(TEMP_META_PATH); + removeIfPresent(BACKUP_FONT_PATH); + removeIfPresent(BACKUP_META_PATH); +} + +bool promotePair(const char* sourceFont, const char* sourceMetadata) { + removeIfPresent(FONT_PATH); + removeIfPresent(FONT_META_PATH); + if (!SD.rename(sourceFont, FONT_PATH)) return false; + if (SD.rename(sourceMetadata, FONT_META_PATH)) return true; + removeIfPresent(FONT_PATH); + return false; +} + +void recoverFontTransaction() { + size_t size; + uint32_t crc; + if (validatePair(FONT_PATH, FONT_META_PATH, size, crc)) { + cleanTransactionFiles(); + return; + } + + // A reset between the two final renames can leave the new font next to its + // temporary metadata. Complete that transaction when the sizes still agree. + if (validatePair(FONT_PATH, TEMP_META_PATH, size, crc)) { + removeIfPresent(FONT_META_PATH); + if (SD.rename(TEMP_META_PATH, FONT_META_PATH)) { + cleanTransactionFiles(); + return; + } + } + + if (validatePair(TEMP_FONT_PATH, TEMP_META_PATH, size, crc) + && promotePair(TEMP_FONT_PATH, TEMP_META_PATH)) { + cleanTransactionFiles(); + return; + } + + if (validatePair(BACKUP_FONT_PATH, BACKUP_META_PATH, size, crc) + && promotePair(BACKUP_FONT_PATH, BACKUP_META_PATH)) { + cleanTransactionFiles(); + return; + } + + removeIfPresent(FONT_PATH); + removeIfPresent(FONT_META_PATH); + cleanTransactionFiles(); +} + +bool refreshFontInfo() { + fontSize = 0; + fontCrc = 0; + return sdReady && validatePair(FONT_PATH, FONT_META_PATH, fontSize, fontCrc); +} + +void sendInfo(Print& output, bool trackEspRequest) { + if (trackEspRequest) ++espInfoRequests; + if (!refreshFontInfo()) { + output.print("MCFONT 0 0 00000000\n"); + return; + } + output.printf("MCFONT 1 %lu %08lx\n", (unsigned long)fontSize, + (unsigned long)fontCrc); +} + +void sendFont(Print& output, bool trackEspRequest) { + if (trackEspRequest) { + ++espGetAttempts; + espLastBytes = 0; + } + if (!refreshFontInfo()) { + output.print("MCFONT 0 0 00000000\n"); + return; + } + + File font = SD.open(FONT_PATH, FILE_READ); + if (!font) { + output.print("MCFONT 0 0 00000000\n"); + return; + } + + output.printf("MCFONT 1 %lu %08lx\n", (unsigned long)fontSize, + (unsigned long)fontCrc); + uint8_t buffer[512]; + size_t sent = 0; + while (font.available()) { + size_t count = font.read(buffer, sizeof(buffer)); + if (count == 0) break; + size_t written = output.write(buffer, count); + sent += written; + if (written != count) break; + } + font.close(); + if (trackEspRequest) { + espLastBytes = sent; + if (sent == fontSize) ++espGetCompleted; + } +} + +bool writeMetadata(const char* path, size_t size, uint32_t crc) { + removeIfPresent(path); + File metadata = SD.open(path, "w"); + if (!metadata) return false; + metadata.printf("MCFONT 1 %lu %08lx\n", (unsigned long)size, + (unsigned long)crc); + metadata.flush(); + metadata.close(); + return true; +} + +bool moveCurrentToBackup() { + removeIfPresent(BACKUP_FONT_PATH); + removeIfPresent(BACKUP_META_PATH); + + bool movedFont = false; + if (SD.exists(FONT_PATH)) { + if (!SD.rename(FONT_PATH, BACKUP_FONT_PATH)) return false; + movedFont = true; + } + if (SD.exists(FONT_META_PATH) + && !SD.rename(FONT_META_PATH, BACKUP_META_PATH)) { + if (movedFont) SD.rename(BACKUP_FONT_PATH, FONT_PATH); + return false; + } + return true; +} + +bool installTemporaryPair(size_t expectedSize, uint32_t expectedCrc) { + if (!moveCurrentToBackup()) return false; + if (!SD.rename(TEMP_FONT_PATH, FONT_PATH)) { + recoverFontTransaction(); + return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc; + } + if (!SD.rename(TEMP_META_PATH, FONT_META_PATH)) { + recoverFontTransaction(); + return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc; + } + cleanTransactionFiles(); + return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc; +} + +void receiveFont(Stream& input, Print& output, size_t expectedSize, + uint32_t expectedCrc) { + if (!sdReady) { + output.print("ERROR SD\n"); + return; + } + + removeIfPresent(TEMP_FONT_PATH); + removeIfPresent(TEMP_META_PATH); + File font = SD.open(TEMP_FONT_PATH, "w"); + if (!font) { + output.print("ERROR OPEN\n"); + return; + } + + output.print("READY\n"); + uint8_t buffer[512]; + size_t received = 0; + uint32_t crc = 0xFFFFFFFFUL; + uint32_t lastProgress = millis(); + bool writeFailed = false; + while (received < expectedSize + && millis() - lastProgress < RECEIVE_IDLE_TIMEOUT_MS) { + int available = input.available(); + if (available <= 0) { + delay(1); + continue; + } + size_t count = (size_t)available; + if (count > sizeof(buffer)) count = sizeof(buffer); + if (count > expectedSize - received) count = expectedSize - received; + size_t actual = input.readBytes(buffer, count); + if (actual == 0) continue; + if (font.write(buffer, actual) != actual) { + writeFailed = true; + break; + } + crc = updateCrc32(crc, buffer, actual); + received += actual; + lastProgress = millis(); + } + font.flush(); + font.close(); + + if (writeFailed || received != expectedSize || ~crc != expectedCrc) { + removeIfPresent(TEMP_FONT_PATH); + output.print(writeFailed ? "ERROR WRITE\n" : "ERROR CHECKSUM\n"); + return; + } + if (!writeMetadata(TEMP_META_PATH, expectedSize, expectedCrc) + || !installTemporaryPair(expectedSize, expectedCrc)) { + recoverFontTransaction(); + output.print("ERROR INSTALL\n"); + return; + } + output.print("OK\n"); +} + +struct CommandReader { + Stream& input; + Print& output; + bool allowUpload; + char line[96] = {}; + size_t length = 0; +}; + +void processCommand(CommandReader& reader) { + if (strcmp(reader.line, "MCFONT INFO") == 0) { + sendInfo(reader.output, !reader.allowUpload); + return; + } + if (strcmp(reader.line, "MCFONT STATUS") == 0) { + reader.output.printf("MCFONT STATUS %lu %lu %lu %lu\n", + (unsigned long)espInfoRequests, + (unsigned long)espGetAttempts, + (unsigned long)espGetCompleted, + (unsigned long)espLastBytes); + return; + } + if (strcmp(reader.line, "MCFONT GET") == 0) { + sendFont(reader.output, !reader.allowUpload); + return; + } + + unsigned long size = 0; + unsigned long crc = 0; + if (sscanf(reader.line, "MCFONT PUT %lu %lx", &size, &crc) == 2) { + if (!reader.allowUpload) { + reader.output.print("ERROR READONLY\n"); + } else if (size < 64 || size > MAX_FONT_BYTES) { + reader.output.print("ERROR SIZE\n"); + } else { + receiveFont(reader.input, reader.output, (size_t)size, (uint32_t)crc); + } + return; + } + reader.output.print("ERROR COMMAND\n"); +} + +void pollCommands(CommandReader& reader) { + while (reader.input.available()) { + int value = reader.input.read(); + if (value < 0) break; + if (value == '\n') { + reader.line[reader.length] = 0; + processCommand(reader); + reader.length = 0; + } else if (value != '\r') { + if (reader.length + 1 < sizeof(reader.line)) { + reader.line[reader.length++] = (char)value; + } else { + reader.length = 0; + } + } + } +} + +CommandReader usbCommands = {Serial, Serial, true}; +CommandReader espCommands = {Serial1, Serial1, false}; + +} // namespace + +void setup() { + Serial.begin(115200); + + Serial1.setRX(FONT_UART_RX); + Serial1.setTX(FONT_UART_TX); + Serial1.setFIFOSize(1024); + Serial1.begin(FONT_UART_BAUD); + + SPI1.setSCK(SD_SCK); + SPI1.setTX(SD_MOSI); + SPI1.setRX(SD_MISO); + sdReady = SD.begin(SD_CS, SD_CLOCK_HZ, SPI1); + if (sdReady) { + if (!SD.exists(FONT_DIRECTORY)) SD.mkdir(FONT_DIRECTORY); + recoverFontTransaction(); + refreshFontInfo(); + } +} + +void loop() { + pollCommands(espCommands); + pollCommands(usbCommands); + delay(1); +} diff --git a/tools/sensecap_indicator_rp2040/upload_font.py b/tools/sensecap_indicator_rp2040/upload_font.py new file mode 100644 index 00000000..73371b6e --- /dev/null +++ b/tools/sensecap_indicator_rp2040/upload_font.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Upload a checked font asset to the Indicator RP2040 SD service.""" + +from __future__ import annotations + +import argparse +import sys +import time +import zlib +from pathlib import Path + +import serial + + +MAX_FONT_BYTES = 1536 * 1024 + + +def read_line(port: serial.Serial, timeout: float) -> str: + deadline = time.monotonic() + timeout + line = bytearray() + while time.monotonic() < deadline: + value = port.read(1) + if not value: + continue + if value == b"\n": + return line.rstrip(b"\r").decode("ascii", errors="replace") + line += value + raise TimeoutError("font service did not respond") + + +def upload(port_name: str, font_path: Path, timeout: float) -> None: + data = font_path.read_bytes() + if not 64 <= len(data) <= MAX_FONT_BYTES: + raise ValueError(f"font size must be between 64 and {MAX_FONT_BYTES} bytes") + crc = zlib.crc32(data) & 0xFFFFFFFF + + with serial.Serial( + port_name, + 115200, + timeout=0.2, + write_timeout=timeout, + ) as port: + time.sleep(0.25) + # Terminate any partial command left by an interrupted terminal or + # uploader before beginning the transactional transfer. + port.write(b"\n") + port.flush() + time.sleep(0.05) + port.reset_input_buffer() + port.write(f"MCFONT PUT {len(data)} {crc:08x}\n".encode("ascii")) + port.flush() + response = read_line(port, timeout) + if response != "READY": + raise RuntimeError(f"font service refused upload: {response}") + + sent = 0 + while sent < len(data): + sent += port.write(data[sent : sent + 4096]) + port.flush() + + response = read_line(port, timeout) + if response != "OK": + raise RuntimeError(f"font service failed to install font: {response}") + + port.write(b"MCFONT INFO\n") + port.flush() + expected = f"MCFONT 1 {len(data)} {crc:08x}" + response = read_line(port, timeout) + if response != expected: + raise RuntimeError( + f"font verification failed: expected {expected!r}, got {response!r}" + ) + + print(f"Installed {font_path} ({len(data)} bytes, CRC32 {crc:08x})") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--port", required=True, help="RP2040 USB serial port") + parser.add_argument( + "--font", + type=Path, + default=Path("variants/sensecap_indicator-espnow/sd/ui-font.vlw"), + ) + parser.add_argument("--timeout", type=float, default=30.0) + args = parser.parse_args() + try: + upload(args.port, args.font, args.timeout) + except (OSError, ValueError, RuntimeError, TimeoutError, serial.SerialException) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/variants/sensecap_indicator-espnow/IndicatorFontClient.cpp b/variants/sensecap_indicator-espnow/IndicatorFontClient.cpp new file mode 100644 index 00000000..adcb4512 --- /dev/null +++ b/variants/sensecap_indicator-espnow/IndicatorFontClient.cpp @@ -0,0 +1,133 @@ +#include "IndicatorFontClient.h" + +#include +#include + +namespace { + +static const uint32_t FONT_UART_BAUD = 1000000; +static const int FONT_UART_RX = 20; +static const int FONT_UART_TX = 19; +static const size_t MAX_FONT_BYTES = 1536 * 1024; + +uint32_t updateCrc32(uint32_t crc, const uint8_t* data, size_t size) { + for (size_t i = 0; i < size; ++i) { + crc ^= data[i]; + for (uint8_t bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U))); + } + } + return crc; +} + +bool readLine(HardwareSerial& serial, char* line, size_t capacity, + uint32_t timeout_millis) { + size_t length = 0; + uint32_t started = millis(); + while (millis() - started < timeout_millis) { + while (serial.available()) { + int value = serial.read(); + if (value < 0) break; + if (value == '\n') { + line[length] = 0; + return true; + } + if (value != '\r' && length + 1 < capacity) { + line[length++] = (char)value; + } + } + delay(1); + } + line[0] = 0; + return false; +} + +bool parseInfo(const char* line, size_t& size, uint32_t& crc) { + unsigned long parsed_size = 0; + unsigned long parsed_crc = 0; + int present = 0; + if (sscanf(line, "MCFONT 1 %lu %lx", &parsed_size, &parsed_crc) != 2) { + if (sscanf(line, "MCFONT %d", &present) == 1 && present == 0) return false; + return false; + } + if (parsed_size < 64 || parsed_size > MAX_FONT_BYTES) return false; + size = (size_t)parsed_size; + crc = (uint32_t)parsed_crc; + return true; +} + +bool requestInfo(HardwareSerial& serial, size_t& size, uint32_t& crc) { + char line[64]; + for (int attempt = 0; attempt < 3; ++attempt) { + while (serial.available()) serial.read(); + serial.print("MCFONT INFO\n"); + serial.flush(); + if (readLine(serial, line, sizeof(line), 1000) + && parseInfo(line, size, crc)) { + return true; + } + delay(100); + } + return false; +} + +bool receiveFont(HardwareSerial& serial, uint8_t* data, size_t size, + uint32_t expected_crc) { + char line[64]; + serial.print("MCFONT GET\n"); + serial.flush(); + + size_t response_size; + uint32_t response_crc; + if (!readLine(serial, line, sizeof(line), 1500) + || !parseInfo(line, response_size, response_crc) + || response_size != size + || response_crc != expected_crc) { + return false; + } + + size_t received = 0; + uint32_t crc = 0xFFFFFFFFUL; + uint32_t last_progress = millis(); + while (received < size && millis() - last_progress < 2000) { + int available = serial.available(); + if (available <= 0) { + delay(1); + continue; + } + size_t count = (size_t)available; + if (count > size - received) count = size - received; + size_t actual = serial.readBytes(data + received, count); + if (actual == 0) continue; + crc = updateCrc32(crc, data + received, actual); + received += actual; + last_progress = millis(); + } + return received == size && ~crc == expected_crc; +} + +} // namespace + +uint8_t* IndicatorFontClient::load(size_t& size) { + size = 0; + if (!psramFound()) return nullptr; + + Serial2.begin(FONT_UART_BAUD, SERIAL_8N1, FONT_UART_RX, FONT_UART_TX); + delay(50); + + uint32_t expected_crc; + if (!requestInfo(Serial2, size, expected_crc)) { + Serial2.end(); + size = 0; + return nullptr; + } + + uint8_t* data = (uint8_t*)ps_malloc(size); + if (data == nullptr || !receiveFont(Serial2, data, size, expected_crc)) { + free(data); + data = nullptr; + size = 0; + } + Serial2.end(); + return data; +} diff --git a/variants/sensecap_indicator-espnow/IndicatorFontClient.h b/variants/sensecap_indicator-espnow/IndicatorFontClient.h new file mode 100644 index 00000000..34281a7b --- /dev/null +++ b/variants/sensecap_indicator-espnow/IndicatorFontClient.h @@ -0,0 +1,9 @@ +#pragma once + +#include +#include + +class IndicatorFontClient { +public: + static uint8_t* load(size_t& size); +}; diff --git a/variants/sensecap_indicator-espnow/IndicatorRadioHal.cpp b/variants/sensecap_indicator-espnow/IndicatorRadioHal.cpp new file mode 100644 index 00000000..e55966cc --- /dev/null +++ b/variants/sensecap_indicator-espnow/IndicatorRadioHal.cpp @@ -0,0 +1,166 @@ +#include "IndicatorRadioHal.h" + +#ifdef SENSECAP_INDICATOR_LORA + +IndicatorRadioHal::IndicatorRadioHal(SPIClass& spi) : ArduinoHal(spi) {} + +bool IndicatorRadioHal::isExpanderPin(uint32_t pin) { + return pin != RADIOLIB_NC && (pin & EXPANDER_FLAG) != 0; +} + +uint8_t IndicatorRadioHal::expanderIndex(uint32_t pin) { + return static_cast(pin & ~EXPANDER_FLAG); +} + +bool IndicatorRadioHal::readRegister(uint8_t reg, uint16_t& value) { + uint8_t data[2]; + if (!lgfx::i2c::readRegister(I2C_PORT, EXPANDER_ADDRESS, reg, data, + sizeof(data), I2C_FREQUENCY) + .has_value()) { + return false; + } + value = data[0]; + value |= static_cast(data[1]) << 8; + return true; +} + +bool IndicatorRadioHal::writeRegister(uint8_t reg, uint16_t value) { + uint8_t data[] = { + reg, + static_cast(value), + static_cast(value >> 8), + }; + return lgfx::i2c::transactionWrite(I2C_PORT, EXPANDER_ADDRESS, data, + sizeof(data), I2C_FREQUENCY) + .has_value(); +} + +bool IndicatorRadioHal::beginExpander() { + if (_expanderReady) return true; + + uint16_t inputs; + if (!readRegister(OUTPUT_REGISTER, _output) + || !readRegister(CONFIG_REGISTER, _config) + || !readRegister(INPUT_REGISTER, inputs)) { + // The display normally initializes this shared bus first. Reinitialize it + // here as a fallback when display or touch probing ended early. + if (!lgfx::i2c::init(I2C_PORT, PIN_BOARD_SDA, PIN_BOARD_SCL).has_value() + || !readRegister(OUTPUT_REGISTER, _output) + || !readRegister(CONFIG_REGISTER, _config) + || !readRegister(INPUT_REGISTER, inputs)) { + return false; + } + } + + ::pinMode(EXPANDER_INTERRUPT_GPIO, INPUT_PULLUP); + _irqLevel = (inputs & (1U << RADIO_IRQ_INDEX)) != 0; + _expanderReady = true; + return true; +} + +bool IndicatorRadioHal::readInputs(uint16_t& value) { + if (!beginExpander() || !readRegister(INPUT_REGISTER, value)) return false; + processInterruptLevel(value); + return true; +} + +void IndicatorRadioHal::processInterruptLevel(uint16_t inputs) { + bool level = (inputs & (1U << RADIO_IRQ_INDEX)) != 0; + if (level == _irqLevel) return; + + _irqLevel = level; + if (_irqCallback == nullptr) return; + if ((_irqMode == GpioInterruptRising && level) + || (_irqMode == GpioInterruptFalling && !level)) { + _irqCallback(); + } +} + +void IndicatorRadioHal::serviceInterrupt() { + if (_irqCallback == nullptr || !_expanderReady + || ::digitalRead(EXPANDER_INTERRUPT_GPIO) != LOW) { + return; + } + + uint16_t inputs; + readInputs(inputs); +} + +void IndicatorRadioHal::pinMode(uint32_t pin, uint32_t mode) { + if (!isExpanderPin(pin)) { + ArduinoHal::pinMode(pin, mode); + return; + } + + uint8_t index = expanderIndex(pin); + if (index >= 16 || !beginExpander()) return; + uint16_t next = _config; + if (mode == GpioModeOutput) { + next &= ~(1U << index); + } else { + next |= 1U << index; + } + if (next != _config && writeRegister(CONFIG_REGISTER, next)) _config = next; +} + +void IndicatorRadioHal::digitalWrite(uint32_t pin, uint32_t value) { + if (!isExpanderPin(pin)) { + ArduinoHal::digitalWrite(pin, value); + return; + } + + uint8_t index = expanderIndex(pin); + if (index >= 16 || !beginExpander()) return; + uint16_t next = value == GpioLevelHigh ? _output | (1U << index) + : _output & ~(1U << index); + if (next != _output && writeRegister(OUTPUT_REGISTER, next)) _output = next; +} + +uint32_t IndicatorRadioHal::digitalRead(uint32_t pin) { + if (!isExpanderPin(pin)) return ArduinoHal::digitalRead(pin); + + uint8_t index = expanderIndex(pin); + uint16_t inputs; + if (index >= 16 || !readInputs(inputs)) { + // A failed BUSY read must prevent an unsafe SPI command. Other failed + // reads are treated as inactive. + return pin == P_LORA_BUSY ? GpioLevelHigh : GpioLevelLow; + } + return (inputs & (1U << index)) != 0 ? GpioLevelHigh : GpioLevelLow; +} + +void IndicatorRadioHal::attachInterrupt(uint32_t interrupt_num, + void (*callback)(void), uint32_t mode) { + if (!isExpanderPin(interrupt_num)) { + ArduinoHal::attachInterrupt(interrupt_num, callback, mode); + return; + } + + if (expanderIndex(interrupt_num) != RADIO_IRQ_INDEX + || !beginExpander()) { + return; + } + + _irqCallback = callback; + _irqMode = mode; + uint16_t inputs; + if (readRegister(INPUT_REGISTER, inputs)) { + _irqLevel = (inputs & (1U << RADIO_IRQ_INDEX)) != 0; + } +} + +void IndicatorRadioHal::detachInterrupt(uint32_t interrupt_num) { + if (!isExpanderPin(interrupt_num)) { + ArduinoHal::detachInterrupt(interrupt_num); + return; + } + if (expanderIndex(interrupt_num) == RADIO_IRQ_INDEX) { + _irqCallback = nullptr; + } +} + +uint32_t IndicatorRadioHal::pinToInterrupt(uint32_t pin) { + return isExpanderPin(pin) ? pin : ArduinoHal::pinToInterrupt(pin); +} + +#endif diff --git a/variants/sensecap_indicator-espnow/IndicatorRadioHal.h b/variants/sensecap_indicator-espnow/IndicatorRadioHal.h new file mode 100644 index 00000000..3c47396e --- /dev/null +++ b/variants/sensecap_indicator-espnow/IndicatorRadioHal.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#define LGFX_USE_V1 +#include +#include + +// RadioLib HAL for the Indicator's SX1262. Four radio signals are routed +// through a TCA9535 instead of ordinary ESP32 GPIOs. The expander's shared, +// active-low interrupt output is polled from normal task context so I2C is +// never touched by an ISR. +class IndicatorRadioHal : public ArduinoHal { +public: + explicit IndicatorRadioHal(SPIClass& spi); + + bool beginExpander(); + void serviceInterrupt(); + + void pinMode(uint32_t pin, uint32_t mode) override; + void digitalWrite(uint32_t pin, uint32_t value) override; + uint32_t digitalRead(uint32_t pin) override; + void attachInterrupt(uint32_t interrupt_num, void (*callback)(void), + uint32_t mode) override; + void detachInterrupt(uint32_t interrupt_num) override; + uint32_t pinToInterrupt(uint32_t pin) override; + +private: + static constexpr uint8_t EXPANDER_ADDRESS = 0x20; + static constexpr uint8_t I2C_PORT = 0; + static constexpr uint32_t I2C_FREQUENCY = 400000; + static constexpr uint32_t EXPANDER_FLAG = 0x40; + static constexpr uint8_t INPUT_REGISTER = 0x00; + static constexpr uint8_t OUTPUT_REGISTER = 0x02; + static constexpr uint8_t CONFIG_REGISTER = 0x06; + static constexpr uint8_t RADIO_IRQ_INDEX = 3; + static constexpr uint8_t EXPANDER_INTERRUPT_GPIO = 42; + + bool _expanderReady = false; + uint16_t _output = 0xFFFF; + uint16_t _config = 0xFFFF; + bool _irqLevel = false; + void (*_irqCallback)(void) = nullptr; + uint32_t _irqMode = RISING; + + static bool isExpanderPin(uint32_t pin); + static uint8_t expanderIndex(uint32_t pin); + bool readRegister(uint8_t reg, uint16_t& value); + bool writeRegister(uint8_t reg, uint16_t value); + bool readInputs(uint16_t& value); + void processInterruptLevel(uint16_t inputs); +}; diff --git a/variants/sensecap_indicator-espnow/IndicatorSX1262Wrapper.h b/variants/sensecap_indicator-espnow/IndicatorSX1262Wrapper.h new file mode 100644 index 00000000..4754ce84 --- /dev/null +++ b/variants/sensecap_indicator-espnow/IndicatorSX1262Wrapper.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include "IndicatorRadioHal.h" + +class IndicatorSX1262Wrapper : public CustomSX1262Wrapper { +public: + IndicatorSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board, + IndicatorRadioHal& hal) + : CustomSX1262Wrapper(radio, board), _hal(hal) {} + + void loop() override { + _hal.serviceInterrupt(); + CustomSX1262Wrapper::loop(); + } + +private: + IndicatorRadioHal& _hal; +}; diff --git a/variants/sensecap_indicator-espnow/IndicatorTouch.h b/variants/sensecap_indicator-espnow/IndicatorTouch.h new file mode 100644 index 00000000..5bcad118 --- /dev/null +++ b/variants/sensecap_indicator-espnow/IndicatorTouch.h @@ -0,0 +1,91 @@ +#pragma once + +#define LGFX_USE_V1 +#include +#include + +// This FT5x06-compatible controller exposes valid point data at register 0x02, +// but some units return zero for all optional identity registers. Probe the +// point register instead of rejecting those units by vendor ID. +class IndicatorTouch : public lgfx::Touch_FT5x06 { + static constexpr size_t POINT_DATA_SIZE = 5; + + bool readPointData(uint8_t* data, size_t size) { + const uint8_t point_count_reg = 0x02; + return lgfx::i2c::transactionWriteRead( + _cfg.i2c_port, _cfg.i2c_addr, &point_count_reg, 1, + data, size, _cfg.freq) + .has_value(); + } + + bool readStablePointData(uint8_t* data) { + uint8_t samples[2][POINT_DATA_SIZE] = {}; + if (!readPointData(samples[0], sizeof(samples[0]))) return false; + if ((samples[0][0] & 0x0F) == 0) { + memcpy(data, samples[0], POINT_DATA_SIZE); + return true; + } + + // Coordinates may change while the controller is being read. Match two + // consecutive snapshots, as the standard driver does, so X/Y bytes from + // different positions cannot reverse a gesture. If the finger keeps + // moving through every retry, use the newest complete snapshot. + uint8_t current = 0; + for (uint8_t attempt = 0; attempt < 5; ++attempt) { + const uint8_t next = current ^ 1U; + if (!readPointData(samples[next], sizeof(samples[next]))) return false; + if (memcmp(samples[current], samples[next], POINT_DATA_SIZE) == 0) { + memcpy(data, samples[next], POINT_DATA_SIZE); + return true; + } + current = next; + } + memcpy(data, samples[current], POINT_DATA_SIZE); + return true; + } + +public: + bool init() override { + _inited = false; + if (!lgfx::i2c::init(_cfg.i2c_port, _cfg.pin_sda, _cfg.pin_scl) + .has_value()) { + return false; + } + + uint8_t point_count = 0; + if (!readPointData(&point_count, 1)) { + return false; + } + + // Select normal operation and polling mode, matching the generic driver. + if (!lgfx::i2c::writeRegister8( + _cfg.i2c_port, _cfg.i2c_addr, 0x00, 0x00, 0, _cfg.freq) + .has_value() + || !lgfx::i2c::writeRegister8( + _cfg.i2c_port, _cfg.i2c_addr, 0xA4, 0x00, 0, _cfg.freq) + .has_value()) { + return false; + } + + _inited = true; + return true; + } + + uint_fast8_t getTouchRaw(lgfx::touch_point_t* point, + uint_fast8_t count) override { + if (point == nullptr || count == 0 || (!_inited && !init())) return 0; + + uint8_t data[POINT_DATA_SIZE] = {}; + if (!readStablePointData(data)) { + _inited = false; + return 0; + } + if ((data[0] & 0x0F) == 0) return 0; + + point[0].x = ((data[1] & 0x0F) << 8) | data[2]; + point[0].y = ((data[3] & 0x0F) << 8) | data[4]; + point[0].size = 1; + point[0].id = data[3] >> 4; + return 1; + } +}; diff --git a/variants/sensecap_indicator-espnow/README.md b/variants/sensecap_indicator-espnow/README.md new file mode 100644 index 00000000..001b04f4 --- /dev/null +++ b/variants/sensecap_indicator-espnow/README.md @@ -0,0 +1,102 @@ +# SenseCAP Indicator controls + +The Indicator uses its full 480x480 panel resolution while preserving the +existing 160x160 UI coordinate system. A 4-bit internal canvas avoids PSRAM +contention with RGB scanout; text and icons are rendered directly at 3x detail +instead of enlarging a completed 160x160 frame. The screen blanks after five +minutes on USB power; the hardware button or a touch wakes it without +selecting anything. + +## Touch navigation + +- Swipe horizontally to move between home pages. The dots under the status bar + show the current page. +- The middle 70% of the panel is the Select tap target. The outer 15% on each + side performs Previous or Next; horizontal swipes remain available anywhere. +- A gesture is committed after a stable release. Brief missing touch samples + are ignored so one swipe cannot become a second, opposite endpoint tap. + +## Reading messages + +The message preview is a non-destructive local inbox, newest first: + +- The first home page shows the retained local `INBOX` count, which remains + useful when a USB host has already drained the protocol's unread count. Tap + the center to open the inbox and channel selector even when the count is 0. +- `Message 1/N` is the newest buffered preview. +- Each preview retains the complete 160-byte maximum chat payload. Long UTF-8 + messages wrap through the available middle of the screen instead of being + cut at the small-display 78-byte limit. +- When a USB host drains the radio queue, the displayed preview is retained + for five minutes rather than disappearing immediately. +- Swipe left for an older preview and right for a newer preview. Navigation + stops at the oldest and newest entries instead of wrapping. +- The bottom bar shows the active inbox filter. Its large `<` and `>` end + buttons select the previous or next filter; swipe up or down does the same. + Filters include `All channels`, `Direct`, and configured channels such as + `Ch 1 #testing`. A newly received message automatically selects its source + channel. +- Tap the center to return home without deleting the previews. +- On the first home page, tap the wide center target to reopen the previews, + including when `INBOX` is zero. + +Channel messages identify both the configured slot and name, such as +`Ch 0 Public` or `Ch 1 #testing`. The value in brackets after it is the route, +for example `[0h]` for a zero-hop flood or `[direct]` for a direct route. + +The bottom selector filters received messages; it does not change the outbound +channel because the device UI does not include a text composer. Select the +outbound channel in the connected application, or list and address channel +slots from the CLI with `get_channels`, `public `, or `chan +`. Channel 0 is conventionally Public; always use `get_channels` +before assuming the other slot numbers. + +## Radio page + +The radio page redraws once per second. Noise-floor calibration publishes a +new 64-sample average about every two seconds, so a faster redraw would usually +repeat the same value. The display shows the fractional sample mean to one +decimal place; individual SX1262 instantaneous RSSI samples are still +quantized to 0.5 dB, so the decimals are useful for comparing averages rather +than claiming hundredth-dB measurement accuracy. + +## USB + WiFi Companion + +Build `SenseCapIndicator-LoRa_comp_radio_usb_wifi` to retain the USB Companion +link and expose the same framed Companion protocol over WiFi TCP port `5000`. +Fresh devices advertise an open `MeshCore-Setup-XXXX` access point, where the +four-character suffix identifies the device; join it and open +`http://192.168.4.1/` to save WiFi credentials. Saved credentials and the WiFi +on/off preference survive application-only reflashes. Setup mode explicitly +restores standard 2.4 GHz b/g/n operation, so a previously installed ESP-NOW +image cannot leave the setup AP hidden behind its long-range protocol setting. + +The combined profile uses minimum WiFi modem power saving by default. USB +remains available for recovery and management if the configured network is +unavailable. TCP port `5001` serves host-backed LoRa OTA files, while port +`5002` provides the bounded OTA management console. The USB text terminal also +accepts `get wifi.status`, `get wifi.ssid`, `get wifi.cli`, and +`start webconfig ap`; enter it with `+++MESHCORE-TERM-START` and return to the +framed Companion protocol with `+++MESHCORE-TERM-STOP`. + +If a Companion client does not supply device time over USB or WiFi, this build +starts evaluating LoRa time after two minutes. It requires three independent, +agreeing sources from signed adverts or authenticated Public-channel messages; +one repeated sender cannot set the clock alone. A successful Companion or +local CLI time update always wins and disables the LoRa fallback until reboot. + +The combined image uses two 2.5 MiB OTA application slots. Its partition map +keeps the prior NVS and SPIFFS addresses, so installing the partition table and +application over an existing Indicator preserves identity, channels, radio +settings, and saved WiFi credentials. + +## SD-backed font + +The RP2040 streams the checked-in pixel-font asset from the SD card to the +ESP32-S3 at boot. Text is binary 18x24 Noto Sans Mono Bold. Emoji use 12x12 +RGB332 color cells with binary transparency and are enlarged 2x with +nearest-neighbor scaling. Periodic status updates preserve unchanged emoji in +the panel framebuffer so the full-color cell is not replaced and redrawn once +per second. See +[`../../tools/sensecap_indicator_rp2040/README.md`](../../tools/sensecap_indicator_rp2040/README.md) +for installation and diagnostics. diff --git a/variants/sensecap_indicator-espnow/SCIndicatorDisplay.h b/variants/sensecap_indicator-espnow/SCIndicatorDisplay.h index aabedd24..871ef463 100644 --- a/variants/sensecap_indicator-espnow/SCIndicatorDisplay.h +++ b/variants/sensecap_indicator-espnow/SCIndicatorDisplay.h @@ -1,6 +1,9 @@ #pragma once #include +#include "IndicatorFontClient.h" +#include "IndicatorTouch.h" +#include #define LGFX_USE_V1 #include @@ -13,7 +16,7 @@ class LGFX : public lgfx::LGFX_Device lgfx::Panel_ST7701 _panel_instance; lgfx::Bus_RGB _bus_instance; lgfx::Light_PWM _light_instance; - lgfx::Touch_FT5x06 _touch_instance; + IndicatorTouch _touch_instance; public: const uint16_t screenWidth = 480; @@ -37,7 +40,8 @@ public: { auto cfg = _panel_instance.config_detail(); - cfg.pin_cs = 4 | IO_EXPANDER; + // Chip-select is driven through the board's I2C expander below. + cfg.pin_cs = GPIO_NUM_NC; cfg.pin_sclk = 41; cfg.pin_mosi = 48; cfg.use_psram = 1; @@ -105,8 +109,8 @@ public: cfg.y_max = 479; cfg.pin_int = GPIO_NUM_NC; cfg.pin_rst = GPIO_NUM_NC; - cfg.bus_shared = true; - cfg.offset_rotation = 0; + cfg.bus_shared = false; + cfg.offset_rotation = 2; cfg.i2c_port = 0; cfg.i2c_addr = 0x48; @@ -123,6 +127,131 @@ public: class SCIndicatorDisplay : public LGFXDisplay { LGFX disp; + uint8_t expander_address = 0; + uint16_t expander_output = 0; + + static constexpr gpio_num_t BACKLIGHT_PIN = GPIO_NUM_45; + static constexpr uint8_t I2C_PORT = 0; + static constexpr uint32_t I2C_FREQUENCY = 400000; + static constexpr uint8_t EXPANDER_OUTPUT_REGISTER = 0x02; + static constexpr uint8_t EXPANDER_CONFIG_REGISTER = 0x06; + static constexpr uint16_t PANEL_CS_MASK = 1U << 4; + static constexpr uint16_t PANEL_RESET_MASK = 1U << 5; + static constexpr uint16_t TOUCH_INTERRUPT_MASK = 1U << 6; + static constexpr uint16_t TOUCH_RESET_MASK = 1U << 7; + + static void setBacklight(bool enabled) { + // Detach any stale LEDC routing before changing the level. Reasserting a + // plain GPIO level makes wake reliable even if a previous image left the + // backlight PWM channel stopped or assigned elsewhere. + gpio_reset_pin(BACKLIGHT_PIN); + gpio_set_direction(BACKLIGHT_PIN, GPIO_MODE_OUTPUT); + gpio_set_level(BACKLIGHT_PIN, enabled ? 1 : 0); + } + + static bool readExpanderRegister(uint8_t address, uint8_t reg, + uint16_t& value) { + uint8_t data[2]; + if (!lgfx::i2c::readRegister(I2C_PORT, address, reg, data, sizeof(data), + I2C_FREQUENCY) + .has_value()) { + return false; + } + value = data[0]; + value |= static_cast(data[1]) << 8; + return true; + } + + static bool writeExpanderRegister(uint8_t address, uint8_t reg, + uint16_t value) { + uint8_t data[] = { + reg, + static_cast(value), + static_cast(value >> 8), + }; + return lgfx::i2c::transactionWrite(I2C_PORT, address, data, sizeof(data), + I2C_FREQUENCY) + .has_value(); + } + + bool prepareControllersAt(uint8_t address) { + uint16_t config; + if (!readExpanderRegister(address, EXPANDER_OUTPUT_REGISTER, + expander_output) + || !readExpanderRegister(address, EXPANDER_CONFIG_REGISTER, config)) { + return false; + } + + // Set safe output latches before changing pin directions. Keep LCD CS + // inactive while both controllers are held in reset. + expander_output |= PANEL_CS_MASK; + expander_output &= ~(PANEL_RESET_MASK | TOUCH_RESET_MASK); + if (!writeExpanderRegister(address, EXPANDER_OUTPUT_REGISTER, + expander_output)) { + return false; + } + + config &= ~(PANEL_CS_MASK | PANEL_RESET_MASK | TOUCH_RESET_MASK); + config |= TOUCH_INTERRUPT_MASK; + if (!writeExpanderRegister(address, EXPANDER_CONFIG_REGISTER, config)) { + return false; + } + delay(10); + + expander_output |= PANEL_RESET_MASK | TOUCH_RESET_MASK; + if (!writeExpanderRegister(address, EXPANDER_OUTPUT_REGISTER, + expander_output)) { + return false; + } + delay(120); + + // Hold LCD CS active while the graphics driver emits the ST7701 init + // sequence over the shared clock and data pins. + expander_output &= ~PANEL_CS_MASK; + if (!writeExpanderRegister(address, EXPANDER_OUTPUT_REGISTER, + expander_output)) { + return false; + } + expander_address = address; + return true; + } + + bool prepareControllers() { + if (!lgfx::i2c::init(I2C_PORT, PIN_BOARD_SDA, PIN_BOARD_SCL).has_value()) { + return false; + } + return prepareControllersAt(0x20) || prepareControllersAt(0x39); + } + + bool releasePanelChipSelect() { + if (expander_address == 0) return false; + expander_output |= PANEL_CS_MASK; + return writeExpanderRegister(expander_address, EXPANDER_OUTPUT_REGISTER, + expander_output); + } + public: SCIndicatorDisplay() : LGFXDisplay(480, 480, disp) {} + + bool begin() { + if (!prepareControllers()) return false; + const bool initialized = LGFXDisplay::begin(); + const bool released = releasePanelChipSelect(); + if (!initialized || !released) return false; + setBacklight(true); + size_t fontSize; + uint8_t* fontData = IndicatorFontClient::load(fontSize); + if (fontData != nullptr) installRuntimeFont(fontData, fontSize); + return true; + } + + void turnOn() override { + setBacklight(true); + _isOn = true; + } + + void turnOff() override { + setBacklight(false); + _isOn = false; + } }; diff --git a/variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv b/variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv new file mode 100644 index 00000000..b820c714 --- /dev/null +++ b/variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv @@ -0,0 +1,10 @@ +# Two 2.5 MiB OTA slots for the 8 MiB SenseCAP Indicator. +# NVS, SPIFFS, and coredump retain Arduino default.csv's addresses so an +# in-place partition-table migration does not discard configured node data. +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x280000, +spiffs, data, spiffs, 0x290000, 0x160000, +coredump, data, coredump,0x3f0000, 0x10000, +app1, app, ota_1, 0x400000, 0x280000, diff --git a/variants/sensecap_indicator-espnow/platformio.ini b/variants/sensecap_indicator-espnow/platformio.ini index a5952d6e..b86941b1 100644 --- a/variants/sensecap_indicator-espnow/platformio.ini +++ b/variants/sensecap_indicator-espnow/platformio.ini @@ -11,17 +11,41 @@ build_flags = ${esp32_base.build_flags} -D PIN_BOARD_SDA=39 -D PIN_BOARD_SCL=40 + -D BOARD_HAS_PSRAM -D DISPLAY_CLASS=SCIndicatorDisplay -D DISPLAY_LINES=21 -D LINE_LENGTH=53 -D DISABLE_WIFI_OTA=1 -D IO_EXPANDER=0x40 -D IO_EXPANDER_IRQ=42 - -D UI_ZOOM=3.5 + -D DISPLAY_ROTATION=1 + ; Render at the panel's native 480x480 resolution while preserving the + ; established 160x160 UI coordinate system. A 4-bit internal canvas avoids + ; competing with the RGB scanout framebuffer in PSRAM. + -D UI_ZOOM=1 + -D UI_COORD_SCALE=3 + -D UI_BUFFER_COLOR_DEPTH=4 + -D AUTO_OFF_MILLIS=300000UL + -D USB_MESSAGE_PREVIEW_MILLIS=300000UL + ; Retain every byte of a maximum-length 160-byte chat message. The generic + ; 78-byte preview is intended for small displays and left most of this panel + ; unused while silently truncating longer text. + -D UI_MSG_PREVIEW_SIZE=161 + -D UI_RADIO_REFRESH_MILLIS=1000UL + ; Use the configured five-minute timeout literally on this USB-powered LCD. + -D UI_USB_AUTO_OFF_MULTIPLIER=1UL + ; GPIO 38 cannot wake the ESP32-S3 from the no-wakeup hibernate path. + ; Keep this USB-powered target on and use the display timeout instead. + -D UI_NO_HIBERNATE -D UI_RECENT_LIST_SIZE=9 -D UI_SENSORS_PAGE=1 -D PIN_USER_BTN=38 -D HAS_TOUCH + -D TOUCH_REVERSE_SWIPE + -D TOUCH_SEPARATE_VERTICAL_SWIPES + ; The outer 15% remains available for Previous/Next taps; the middle 70% + ; is Select. This makes the primary tap target much easier on a 480px panel. + -D TOUCH_CENTER_ZONE_PERCENT=70 -I variants/sensecap_indicator-espnow build_src_filter = ${esp32_base.build_src_filter} +<../variants/sensecap_indicator-espnow/*.cpp> @@ -32,6 +56,35 @@ lib_deps=${esp32_base.lib_deps} adafruit/Adafruit BusIO @ ^1.17.2 lovyan03/LovyanGFX @ ^1.2.7 +; D1L and D1Pro route the onboard SX1262's control pins through the TCA9535 +; expander. Keep the original ESP-NOW target above for Indicators without the +; LoRa hardware, and use this target for the LoRa-capable models. +[SenseCapIndicator-LoRa] +extends = SenseCapIndicator-ESPNow +build_flags = + ${SenseCapIndicator-ESPNow.build_flags} + -D SENSECAP_INDICATOR_LORA + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=IndicatorSX1262Wrapper + -D P_LORA_SCLK=41 + -D P_LORA_MISO=47 + -D P_LORA_MOSI=48 + -D P_LORA_NSS=64 + -D P_LORA_RESET=65 + -D P_LORA_BUSY=66 + -D P_LORA_DIO_1=67 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=2.4 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D LORA_TX_POWER=22 + -D MAX_LORA_TX_POWER=22 + -D RECOVERABLE_EXTERNAL_RADIO +build_src_filter = ${SenseCapIndicator-ESPNow.build_src_filter} + - +lib_deps = ${SenseCapIndicator-ESPNow.lib_deps} + [env:SenseCapIndicator-ESPNow_comp_radio_usb] extends = SenseCapIndicator-ESPNow build_flags = @@ -39,6 +92,7 @@ build_flags = -I examples/companion_radio/ui-new -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 ; NOTE: DO NOT ENABLE --> -D ESPNOW_DEBUG_LOGGING=1 @@ -48,3 +102,48 @@ build_src_filter = ${SenseCapIndicator-ESPNow.build_src_filter} lib_deps = ${SenseCapIndicator-ESPNow.lib_deps} densaugeo/base64 @ ~1.4.0 + +[env:SenseCapIndicator-LoRa_comp_radio_usb] +extends = SenseCapIndicator-LoRa +build_flags = + ${SenseCapIndicator-LoRa.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D ENABLE_USB_INTERFACE +; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 +; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 +build_src_filter = ${SenseCapIndicator-LoRa.build_src_filter} + +<../examples/companion_radio/ui-new/*.cpp> + +<../examples/companion_radio/*.cpp> +lib_deps = + ${SenseCapIndicator-LoRa.lib_deps} + densaugeo/base64 @ ~1.4.0 + +; Keep the recoverable USB Companion link while also exposing the same framed +; protocol over WiFi TCP port 5000. Placeholder credentials deliberately start +; WebConfig in setup-AP mode on a fresh device; saved NVS credentials win on +; later boots. +[env:SenseCapIndicator-LoRa_comp_radio_usb_wifi] +extends = env:SenseCapIndicator-LoRa_comp_radio_usb +; Two 2.5 MiB OTA slots fit the combined display/WiFi image. The custom map +; deliberately leaves the existing NVS and SPIFFS offsets unchanged so moving +; an already-configured USB Indicator to this build preserves its identity, +; channels, radio settings, and WiFi credentials. +board_build.partitions = variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv +build_flags = + ${env:SenseCapIndicator-LoRa_comp_radio_usb.build_flags} + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D DEFAULT_WIFI_POWER_SAVE_MODE=0 + ; If neither the USB nor WiFi Companion link supplies authoritative time, + ; begin evaluating independently sourced LoRa timestamps after two minutes. + -D COMPANION_MESH_CLOCK_SYNC=1 + -D MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS=120000ULL + -D MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT=3 +build_src_filter = + ${env:SenseCapIndicator-LoRa_comp_radio_usb.build_src_filter} + + +lib_deps = + ${env:SenseCapIndicator-LoRa_comp_radio_usb.lib_deps} diff --git a/variants/sensecap_indicator-espnow/sd/ui-font.vlw b/variants/sensecap_indicator-espnow/sd/ui-font.vlw new file mode 100644 index 00000000..45dfe8ac Binary files /dev/null and b/variants/sensecap_indicator-espnow/sd/ui-font.vlw differ diff --git a/variants/sensecap_indicator-espnow/sd/ui-font.vlw.json b/variants/sensecap_indicator-espnow/sd/ui-font.vlw.json new file mode 100644 index 00000000..77a358f1 --- /dev/null +++ b/variants/sensecap_indicator-espnow/sd/ui-font.vlw.json @@ -0,0 +1,37 @@ +{ + "format": "MeshCore SD UI font 2", + "text_cell": [ + 18, + 24 + ], + "emoji_cell": [ + 12, + 12 + ], + "pixel_font": true, + "antialiasing": false, + "text_pixel_values": [ + 0, + 255 + ], + "emoji_encoding": { + "color": "RGB332", + "transparent_key": 227, + "alpha_threshold": 48 + }, + "text_source": "Noto Sans Mono Bold, binary-thresholded", + "text_native_scale": 3, + "unicode_emoji_version": "17.0", + "text_glyphs": 1357, + "emoji_glyphs": 3953, + "emoji_aliases": 5225, + "trie_bytes": 109080, + "color_atlas_bytes": 569244, + "font_bytes": 1302608, + "sha256": "61bce9662db314054e7bcfaa26147a28ad7b500b51baac4cae1caacce90b7421", + "sources": { + "NotoColorEmoji.ttf": "72a635cb3d2f3524c51620cdde406b217204e8a6a06c6a096ff8ed4b5fd6e27b", + "NotoSansMono-wdth-wght.ttf": "2cb2adb378a8f574213e23df697050b83c54c27df465a2015552740b2769a081", + "emoji-test-17.0.txt": "1d8a944f88d7952f7ef7c5167fef3c67995bcae24543949710231b03a201acda" + } +} diff --git a/variants/sensecap_indicator-espnow/target.cpp b/variants/sensecap_indicator-espnow/target.cpp index 1271b354..5da359d4 100644 --- a/variants/sensecap_indicator-espnow/target.cpp +++ b/variants/sensecap_indicator-espnow/target.cpp @@ -5,7 +5,16 @@ ESP32Board board; +#ifdef SENSECAP_INDICATOR_LORA +static SPIClass radio_spi(FSPI); +static IndicatorRadioHal radio_hal(radio_spi); +RADIO_CLASS radio = new Module(&radio_hal, P_LORA_NSS, P_LORA_DIO_1, + P_LORA_RESET, P_LORA_BUSY); +WRAPPER_CLASS radio_driver(radio, board, radio_hal); +static bool target_radio_available = false; +#else ESPNOWRadio radio_driver; +#endif ESP32RTCClock rtc_clock; #if defined(ENV_INCLUDE_GPS) @@ -18,16 +27,28 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); #ifdef DISPLAY_CLASS DISPLAY_CLASS display; #ifdef PIN_USER_BTN - MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); + // Touch supplies the other navigation actions, so dispatch the physical + // button's single click immediately instead of waiting for a multi-click. + MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true, false); #endif #endif bool radio_init() { rtc_clock.begin(); +#ifdef SENSECAP_INDICATOR_LORA + if (!radio_hal.beginExpander()) { + mesh::usbLoggingPort().println("ERROR: radio I/O expander unavailable"); + target_radio_available = false; + return false; + } + target_radio_available = radio.std_init(&radio_spi); + return target_radio_available; +#else radio_driver.init(); return true; // success +#endif } // Combine the normal software source with true entropy captured before RF/ADC @@ -42,7 +63,20 @@ public: } }; +#ifdef RECOVERABLE_EXTERNAL_RADIO +uint32_t radio_fallback_rng_seed() { return esp_random(); } +#endif + mesh::LocalIdentity radio_new_identity() { +#ifdef SENSECAP_INDICATOR_LORA + if (target_radio_available) { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); + } + ESP_RNG rng; + return mesh::LocalIdentity(&rng); +#else ESP_RNG rng; return mesh::LocalIdentity(&rng); // create new random identity +#endif } diff --git a/variants/sensecap_indicator-espnow/target.h b/variants/sensecap_indicator-espnow/target.h index dab4575e..d2d6a7b1 100644 --- a/variants/sensecap_indicator-espnow/target.h +++ b/variants/sensecap_indicator-espnow/target.h @@ -1,9 +1,17 @@ #pragma once #include -#include #include #include +#ifdef SENSECAP_INDICATOR_LORA + #define RADIOLIB_STATIC_ONLY 1 + #include + #include + #include "IndicatorRadioHal.h" + #include "IndicatorSX1262Wrapper.h" +#else + #include +#endif #ifdef ENV_INCLUDE_GPS #include #endif @@ -13,7 +21,11 @@ #endif extern ESP32Board board; -extern ESPNOWRadio radio_driver; +#ifdef SENSECAP_INDICATOR_LORA + extern WRAPPER_CLASS radio_driver; +#else + extern ESPNOWRadio radio_driver; +#endif extern ESP32RTCClock rtc_clock; extern EnvironmentSensorManager sensors; @@ -23,4 +35,7 @@ extern EnvironmentSensorManager sensors; #endif bool radio_init(); +#ifdef RECOVERABLE_EXTERNAL_RADIO +uint32_t radio_fallback_rng_seed(); +#endif mesh::LocalIdentity radio_new_identity();