Skip to content

Conversation

@Shadowtrance
Copy link
Contributor

@Shadowtrance Shadowtrance commented Jan 25, 2026

Summary

This PR adds the new improved ESP-NOW chat application, introduces ESP-NOW v2.0 support with larger payloads and backwards compatibility, and improves the GPS info panel with additional data and UI refinements.


Chat App

Updates to the chat app with channel support and persistent configuration, allowing nearby devices to communicate without Wi-Fi infrastructure.

Key features:

  • Channel-based messaging (e.g. #general, #team1)
  • Broadcast messages visible across all channels
  • Configurable nickname and optional shared encryption key
  • Persistent settings stored across reboots
  • Channel selector and settings panel
  • Direct, channel, and broadcast message targeting
  • Compatible with ESP-NOW v1.0 and v2.0 devices (with size limits)

Messages are filtered client-side by channel, and short messages remain compatible with older ESP-NOW v1.0 devices.


ESP-NOW v2.0 Support

Introduces ESP-NOW v2.0 awareness to the core service layer and updates the Chat app to take advantage of larger payload sizes where supported.

Highlights:

  • Runtime detection of ESP-NOW protocol version
  • New APIs to query protocol version and maximum payload size
  • Support for variable-length packets (up to 1470 bytes on v2.0)
  • Chat message length increased up to 1416 characters on v2.0
  • Full backwards compatibility with ESP-NOW v1.0 devices for short messages
  • Clear logging of detected ESP-NOW version on startup

Requires ESP-IDF v5.4+ for ESP-NOW v2.0 support.


GPS Info Panel Improvements

Enhances the GPS information panel by exposing more useful data and improving layout and visual clarity.

Changes include:

  • GGA sentence parsing added alongside existing RMC data
  • Latitude
  • Longitude
  • Altitude (meters)
  • Speed
  • Heading
  • Satellite count
  • Improved layout using flex column alignment
  • Clearer “Acquiring lock…” state with distinct color
  • Spacing and padding tweaks for better readability

Summary by CodeRabbit

  • New Features

    • ESP‑NOW chat app: channel-based messaging, nicknames, encryption keys, send/receive UI, channel selector, first‑launch flow, and persistent settings.
  • Improvements

    • Chat: message serialization/deserialization, channel filtering, lifecycle controls, and reliable ESP‑NOW integration.
    • GPS: expanded status view with per-field readouts (latitude, longitude, altitude, speed, heading, satellites).
  • Documentation

    • Added comprehensive chat docs covering features, protocol, UI flow, and architecture.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 25, 2026

📝 Walkthrough

Walkthrough

Adds an ESP‑NOW chat application and documentation. Introduces ChatApp with lifecycle and messaging APIs, ChatView (LVGL UI), ChatState (thread‑safe message storage), ChatProtocol (wire serialization/deserialization), and ChatSettings (persistence and optional encryption). Exposes ESP‑NOW payload limits and helper APIs (getVersion, getMaxDataLength) and adds EspNowService version tracking/getter. Implements ESP‑NOW enable/disable, send/receive flows, settings-driven restart, and channel/nickname handling. Adds GGA storage/getter in GpsService and expands GPS settings UI. All new code is conditionally compiled for ESP platforms with Wi‑Fi support.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the three main components of the PR: chat app update, ESP-NOW v2 support, and GPS information enhancements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Tactility/Source/service/gps/GpsService.cpp (1)

163-178: Potential race: GGA data could be lost immediately after receiving.

The devices are started and callbacks subscribed at line 166. If a GGA sentence arrives before lines 169-170 execute, the valid ggaTime would be reset to 0, discarding the data.

Consider resetting rmcTime and ggaTime before starting the devices:

Suggested reordering
+    rmcTime = 0;
+    ggaTime = 0;
+
     for (auto& record: deviceRecords) {
         started_one_or_more |= startGpsDevice(record);
     }

-    rmcTime = 0;
-    ggaTime = 0;
-
     if (started_one_or_more) {
🧹 Nitpick comments (12)
Tactility/Source/app/gpssettings/GpsSettings.cpp (1)

240-249: Consider clamping heading to valid range for defensive coding.

While GPS course should always be 0-360°, if heading were negative, the expression (int)((heading + 22.5f) / 45.0f) % 8 can yield a negative index in C++, causing out-of-bounds access on dirs[].

♻️ Suggested defensive fix
                 float heading = minmea_tofloat(&rmc.course);
                 if (!isnan(heading)) {
+                    // Normalize heading to [0, 360) range
+                    heading = fmodf(heading, 360.0f);
+                    if (heading < 0) heading += 360.0f;
                     const char* dirs[] = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
                     // Calculate cardinal direction index (0-7)
                     int idx = (int)((heading + 22.5f) / 45.0f) % 8;
Documentation/chat.md (2)

20-31: Consider adding a language specifier to the fenced code block.

The ASCII diagram code block could benefit from a language specifier like text or plaintext to satisfy linting rules and improve rendering consistency.

-```
+```text
 +------------------------------------------+
 | [Back] Chat: `#general`    [List] [Gear]   |

52-76: Consider adding a language specifier to the wire protocol table.

Similar to the UI layout diagram, this code block could use text or plaintext as the language specifier.

Tactility/Source/service/espnow/EspNow.cpp (1)

82-88: Consider logging when service is unavailable for consistency.

Other functions in this file (e.g., enable, disable, addPeer) log an error when the service is not found. For consistency, you might consider adding a similar log here, though returning 0 as a fallback is a reasonable design choice.

♻️ Optional: Add error logging for consistency
 uint32_t getVersion() {
     auto service = findService();
     if (service != nullptr) {
         return service->getVersion();
     }
+    LOGGER.error("Service not found");
     return 0;
 }
Tactility/Source/service/espnow/EspNowService.cpp (1)

112-129: Consider resetting espnowVersion when disabling.

The espnowVersion member is not reset to 0 in disableFromDispatcher(). While the version likely doesn't change between enable/disable cycles, resetting it would ensure getVersion() returns 0 when the service is disabled, which might be more accurate.

♻️ Optional: Reset version on disable
 void EspNowService::disableFromDispatcher() {
     auto lock = mutex.asScopedLock();
     lock.lock();

     if (!enabled) {
         return;
     }

     if (esp_now_deinit() != ESP_OK) {
         LOGGER.error("esp_now_deinit() failed");
     }

     if (!deinitWifi()) {
         LOGGER.error("deinitWifi() failed");
     }

+    espnowVersion = 0;
     enabled = false;
 }
Tactility/Source/app/chat/ChatSettings.cpp (2)

64-76: Consider using sizeof(IV_SEED) - 1 instead of strlen(IV_SEED).

Since IV_SEED is a compile-time constant string literal, you could use sizeof(IV_SEED) - 1 to avoid the runtime strlen call. This is a minor optimization.

♻️ Suggested change
 static bool encryptKey(const uint8_t key[ESP_NOW_KEY_LEN], std::string& hexOutput) {
     uint8_t iv[16];
-    crypt::getIv(IV_SEED, strlen(IV_SEED), iv);
+    crypt::getIv(IV_SEED, sizeof(IV_SEED) - 1, iv);

88-89: Same optimization opportunity for consistency.

Apply the same sizeof(IV_SEED) - 1 pattern here for consistency with the suggested change above.

♻️ Suggested change
     uint8_t iv[16];
-    crypt::getIv(IV_SEED, strlen(IV_SEED), iv);
+    crypt::getIv(IV_SEED, sizeof(IV_SEED) - 1, iv);
Documentation/espnow-v2.md (2)

28-31: Add language specifier to fenced code block.

Per static analysis (markdownlint MD040), fenced code blocks should have a language specified. Use text or log for log output.

♻️ Suggested fix
-```
+```text
 I (15620) ESPNOW: espnow [version: 2.0] init
 I [EspNowService] ESP-NOW version: 2.0
 ```

52-60: Add language specifier to fenced code block.

Per static analysis (markdownlint MD040), fenced code blocks should have a language specified. Use text for the wire protocol format table.

♻️ Suggested fix
-```
+```text
 Offset  Size   Field
 ------  ----   -----
 0       4      header (magic: 0x31544354)
 4       1      protocol_version (0x01)
 5       24     sender_name
 29      24     target
 53      1-1417 message (variable length)
 ```
Tactility/Source/app/chat/ChatApp.cpp (1)

121-166: Consider simplifying hex validation with std::isxdigit.

The manual hex character validation works correctly, but could be simplified:

♻️ Optional simplification using std::isxdigit
     // Parse hex key
     if (keyHex.size() == ESP_NOW_KEY_LEN * 2) {
-        bool validHex = true;
-        for (char c : keyHex) {
-            if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) {
-                validHex = false;
-                break;
-            }
-        }
+        bool validHex = std::all_of(keyHex.begin(), keyHex.end(),
+            [](unsigned char c) { return std::isxdigit(c); });
         if (validHex) {

This requires #include <cctype> and #include <algorithm> (algorithm is already included).

Tactility/Source/app/chat/ChatView.cpp (2)

254-267: Consider adding visual feedback for invalid/empty nickname.

The save handler silently ignores empty nicknames. Users might be confused if clicking Save has no effect. Consider showing a brief message or preventing the save button from being enabled when the nickname is empty.


279-286: Empty channel input is silently ignored.

Similar to settings save, if the user clears the channel field and clicks OK, nothing happens. Consider either:

  1. Defaulting to a fallback channel (e.g., #general)
  2. Providing visual feedback that a channel name is required

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (5)
Tactility/Source/service/espnow/EspNowService.cpp (1)

87-91: Consider explicitly initializing espnowVersion before the API call.

If esp_now_get_version() fails, the espnowVersion member may retain an indeterminate value depending on how the ESP-IDF function handles the output parameter on failure. Setting it to 0 before the call ensures a known fallback.

♻️ Suggested fix
+    espnowVersion = 0;
     if (esp_now_get_version(&espnowVersion) == ESP_OK) {
         LOGGER.info("ESP-NOW version: {}.0", espnowVersion);
     } else {
         LOGGER.warn("Failed to get ESP-NOW version");
     }
Tactility/Source/app/gpssettings/GpsSettings.cpp (1)

364-369: Consider adding a null check for the GPS service.

If findGpsService() returns nullptr, subsequent calls like service->getCoordinates() and service->getGga() will cause a crash. While this may be an established pattern where the app assumes the service exists, a defensive check would improve robustness.

♻️ Suggested defensive check
 GpsSettingsApp() {
     timer = std::make_unique<Timer>(Timer::Type::Periodic, kernel::secondsToTicks(1), [this] {
         updateViews();
     });
     service = service::gps::findGpsService();
+    tt_assert(service != nullptr, "GPS service not found");
 }
Documentation/chat.md (2)

3-3: Optional: Consider hyphenation for compound adjective.

When "ESP-NOW based" modifies "chat application", it should be hyphenated as "ESP-NOW-based chat application" per standard English grammar rules for compound adjectives.

Based on static analysis hints.

📝 Suggested fix
-ESP-NOW based chat application with channel-based messaging. Devices with the same encryption key can communicate in real-time without requiring a WiFi access point or internet connection.
+ESP-NOW-based chat application with channel-based messaging. Devices with the same encryption key can communicate in real-time without requiring a WiFi access point or internet connection.

129-130: Optional: Rephrase to improve readability.

The last limitation item uses "only" twice in a way that makes the sentence feel cramped. Consider splitting into two separate bullet points for clarity.

Based on static analysis hints.

📝 Suggested improvement
-- All communication is broadcast; channel filtering is client-side only
-- Messages > 250 bytes only received by devices running ESP-NOW v2.0
+- All communication is broadcast; channel filtering is client-side only
+- Messages > 250 bytes are only received by devices running ESP-NOW v2.0
Tactility/Source/app/chat/ChatApp.cpp (1)

163-173: Clamp channel length before persisting.

An oversized channel will later trip serializeMessage. Trim to TARGET_SIZE - 1 before setting state/persisting.

♻️ Proposed tweak
-    state.setCurrentChannel(chatChannel);
-    settings.chatChannel = chatChannel;
+    const auto trimmedChannel = chatChannel.substr(0, TARGET_SIZE - 1);
+    state.setCurrentChannel(trimmedChannel);
+    settings.chatChannel = trimmedChannel;

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Copy link
Contributor

@KenVanHoeylandt KenVanHoeylandt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great stuff! 👍

char message[MESSAGE_SIZE];
};

static_assert(sizeof(Message) == 1470, "Message struct must be exactly ESP-NOW v2.0 max payload");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would avoid making all messages this large. If you receive 10 messages, it's almost 15kB of memory.
It would also mean a lot of radio activity if you just want to send "Hi!".

I looked at this for inspiration:
https://www.localmesh.nl/en/meshcore-message-formats-payloads/

Total package size is maximum 256 bytes, but the package can be smaller!
Pernhaps we should also break it up into a header + a dynamically sized message part.

For example:

struct __attribute__((packed)) MessageHeader {
  uint16_t protocol_version; // default to 1, which is current protocol version
  uint32_t from; // sender id (randomly generated and stored in NVS or settings?)
  uint32_t to; // receiver id, 0 for broadcast (e.g. when sending to all or sending to channel)
  uint8_t payload_type; 
  bytes payload_size;
}

In case payload_type is 1, we assume it's a text message:
The remaining bytes would contain the sender's nickname and the message they sent:

  • read bytes until you read nullptr. That's the nickname.
  • read the remaining bytes as the text message.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is a bit too much then feel free to just reduce the message size for now to 256 bytes or so, and I can look at it myself later.

Copy link
Contributor

@KenVanHoeylandt KenVanHoeylandt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great! 👍

4. Extract sender name, target, and message as strings
5. Store in message deque
2. Validate packet:
- Minimum size: 18 bytes (16 header + 2 null terminators)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd clarify that a bit more and do a minor protocol update for the following:

The protocol allows for 16 byte messages, but for the TextMessage specifically, the minimum size should be 16 + minimum_nickname_size + null + minimum_target_size + minimum_message_size:

  • minimum_nickname_size: 2 bytes, as single letter names are not ok, but there are valid 2 letter names
  • minimum_target_size: Not sure. Could be 0 for broadcasting and non-zero for channel? It can also be a minimum of 1 where it's set to B (or b) for broadcasting and starts with # for channel.
  • minimum_message: 1 byte, e.g. a response like "?" is valid.

If you don't feel like changing this, we can leave it as-is and I can pick it up later myself.
In that case, just clarify that the minimum message size for the protocol is 16 bytes and the effective minmimum message size (for TextMessage) is 18 bytes.

// Validate input lengths
if (senderName.size() > MAX_NICKNAME_LEN || target.size() > MAX_TARGET_LEN) {
return false;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you feel like updating the protocol: I would add a minimum length check for both parameters.

out.resize(HEADER_SIZE + payloadSize);

// Build header
MessageHeader header;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather see this as

MessageHeader header = {
    ...
};

That way, my editor warns me when a field is forgotten.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants