Week 15 โ€“ Interface and Application Programming

This week is about learning Application programming and Interfacing that with the input/output device that I build for my final project. .

The objective is to build an application that interacts with my projects and supports the features that I intend to integrate with my final project. I intend to have BLE based control of my outfits Neopixels and Meshtastic integrated in the mobile Application.

AI prompt ChatGPT: " This is Interface and Application Programming week, I am developing the mobile application that will connect to my final project (Smart Navratri Outfits) with BLE, using Application I can also control the embroidery Neopixel color, brightness and fade in and out. Second, feature is set the smart Navratri outfit on the mode that it blinks with sound/Audio Reactivity. Can you please give me a animated image of me making mobile application? "


Assignment Overview

Individual Task:
  • Write an application that interfaces a user with an input &/or output device that you made.
Group Task:
  • Compare as many tool options as possible. Document your work on the group work page and reflect on your individual page what you learned.

Weekly Activity Log
Day Activity Status
Thursday Class - Interface and Application Programming Completed
Friday Public Holiday , Worked on previous pending Documentation ๐Ÿ“ Completed
Saturday Final Project - Components list, Possible production process Completed
3D design ๐Ÿ“ - Mold for Wayfinding - A 21-LED circular "Badge" Completed
Sunday Final Project Requirement Document ๐Ÿ“ Completed
Firmware and Mobile Application Development with BLE Completed
Monday Integrating Meshtashtic in Mobile Application Completed
Tuesday Regional Review ๐Ÿ‘จโ€๐Ÿซ๐Ÿ’ฌ Completed
Milling the Mold Final Project ๐Ÿ›  Completed
Wednesday Casting ๐Ÿ›  Completed
Documentation ๐Ÿ“ Completed

Group Assignment

This week, as part of our group assignment, we explored different ways of creating interfaces and communicating with hardware devices. Many of us developed interface applications connected to our final projects, allowing users to interact with embedded systems more effectively. During the process, we learned about both web-based and mobile-based applications, as well as various communication methods and protocols used to exchange information between software interfaces and hardware devices.

The link to our group assignment is below : Group Assignment


Application Development Workflow

I plan to use below steps for application development workflow : Make a System requirnment document --> Execution Plan --> Research --> Generate Example UIs --> Test Step-by-Step all functions --> Keep Updating Change log file.

Functional Requirements Document

First, prepared a function requirement document by prompting the Gemini with all necessary minimum features that I want ot include in my final project.

Project Overview

Project Name: Smart Navratri Outfits

Goal: To create a pair of interactive traditional costumes that synchronize LED patterns to folk music and use off-grid LoRa communication to help users locate each other in high-density crowds where mobile networks fail.

Core Functional Requirements

Focusing on what the system must do:

Functional Requirements
Feature ID Function Description Priority
FR-01 Audio Reactivity The system must use a digital microphone to animate WS2812B LEDs (blink, fade, color shift) in rhythm with live music. High
FR-02 BLE Control A mobile app must allow users to manually adjust color, brightness, and patterns via Bluetooth Low Energy. High
FR-03 Sync Mode One mobile app must be able to "bond" two costumes to synchronize their LED behavior in real-time. Medium
FR-04 Off-Grid Comms Use Wio-SX1262 (LoRa) to send location data between costumes over several kilometers. High
FR-05 Wayfinding A 21-LED circular "Badge" must act as a visual compass, lighting up the specific clock-position (1โ€“12) of the other user. High
FR-06 Proximity Alert The blinking frequency of the direction indicator must increase as the distance between users decreases. Medium

System Architecture

Following the Input โ†’ Process โ†’ Output workflow:

  • Inputs:
    • Digital Microphone (Sound waves/Rhythm sensing).
    • Wio-SX1262 LoRa Module (Location data from the peer).
    • GPS Module (To determine current coordinates for the compass).
  • Processing:
    • Custom PCB (likely ESP32 for its built-in BLE and processing power).
  • Outputs:
    • WS2812B Neopixel strips (Integrated into machined embroidery).
    • 21-LED Compass Badge (Circular arrangement: 1 center, 8 mid-ring, 12 outer-ring).

User Interaction (UI/UX)

  • The App: Used for selecting color palettes and pairing with a friend's costume.
  • The Badge: Provides haptic or visual feedback. When a friend is at "3 o'clock" relative to you, the LED at that position blinks.
  • Form Factor: Wearable electronics integrated into traditional fabric; the badge is a detachable or pinned-on unit.

Non-Functional Requirements

  • Fabrication: The badge housing must be 3D printed or CNC milled, and the embroidery must be machine-produced in the lab.
  • Durability: The electronics must be protected from sweat and the vigorous movement of Garba folk dance.
  • Connectivity: Must operate reliably in an environment with 40k+ people (off-grid LoRa is essential here).

The "Spiral" Development Plan

To ensure you graduate on time, we break the complexity into "spirals":

  • Spiral 1 (The Must-Haves): Create one PCB that controls Neopixels based on microphone input (Sound-reactive mode).
  • Spiral 2: Develop the BLE Mobile App to change colors and brightness for a single suit.
  • Spiral 3: Integrate the Wio-SX1262 modules to send a "ping" between two boards and light up the direction on the 21-LED badge.
  • Spiral 4 (The Reach Goal): Full Meshtastic integration and synchronized "Master/Slave" control of two suits from one app.

Since, I am in the interfacing and application development week I an starting with Spiral 2 Development of a android based Mobile App - first, I listed down what I want as user work-flow and then given it to AI for refinement and guidance with the MIT APP Inventor so that I can build the App.

AI prompt Gemini: " This is Interface and Application Programming week, I am developing the mobile application that will connect to my final project (Smart Navratri Outfits) with BLE, using application. I want three screens, first screen - welcome screen with connect option abd connection status, second screen - brightness control, color picker, audio-reaction and third for integrating Meshtectic."

Application User Workflow

Screen 1: Welcome to Garba-Sync

  • Action: Big button labeled "Scan for Bheru".
  • Discovery: A list showing available costumes (e.g., "Cloth1" or "Cloth2").
  • Feedback: A status label that switches between "Searching...", "Connected to Bheru", and "Lost Connection".

Screen 2: Aura Settings (The Fun Word!)

  • Audio-Responsive: A toggle switch to enable/disable the "Dance Mode" (Audio-Reactive blinking).
  • Brightness: A slider (0โ€“255) to adjust the intensity so you don't blind people on the dance floor.
  • The Color Wheel: 12 specific buttons arranged in a circle (or a list) matching your requested palette:
    • Warm: Red, Red-Orange, Orange, Yellow-Orange, Yellow.
    • Nature: Yellow-Green, Green, Blue-Green.
    • Cool: Blue, Blue-Purple, Purple, Red-Purple.
    Sync Mode: A special button that sends a LoRa signal to all nearby clothes.
  • Friend Finder Toggle: The master switch to start/stop the LoRa location broadcast.

Screen 3: Radar (The "Magic")

  • Avatar Selection: Two icons at the top (Boy/Girl). You tap one to represent the friend you are looking for.
  • The Compass: A central rotating needle or arrow.
  • Animation: The Boy/Girl icon sits at the tip of the needle, pulsing as the LoRa signal strength updates.

I decided to use MIT App Inventor to make the application.

AI prompt Gemini: "I am going to use MIT App Inverter for first time, can you guide be step by step lest start with screen 1, I just opened the MIT App Inventor website, I have not yet started - and not even imported BLE Extension."

MIT App Invertor : Designer Screen

Step 1: Start a New Project

  • Click the "Create Apps!" button on the MIT App Inventor website and log in with your Google account.
  • Click Projects > Start new project.
  • Name it: Garba_Sync_Bheru (Note: App Inventor doesn't allow spaces in project names).

Step 2: Import the BLE Extension

Since you've already downloaded the .aix file:

  • Look at the Palette (left column). Scroll to the very bottom to find Extension.
  • Click Import extension > Choose File > select your .aix file.
  • Once it appears in the Palette, drag and drop the "BluetoothLE" component onto the white phone screen.
  • It won't show up on the screen; it will appear at the bottom as a "Non-visible component."

Step 3: Setup the "Vertical Arrangements" (The Screen Strategy)

Instead of 3 screens, we will make 3 "folders" on one screen.

  • Go to the Palette > Layout.
  • Drag 3 VerticalArrangement components onto the screen.
  • Rename them in the Components (right column) to:
  • Welcome_Layout
  • Aura_Layout
  • Radar_Layout

For all three, go to the Properties (far right) and set:

  • Width: Fill Parent
  • Height: Fill Parent

Crucial: Uncheck the Visible box for Aura_Layout and Radar_Layout. Only Welcome_Layout should be checked.

Step 4: Design Screen 1 (Welcome_Layout)

Designing: Welcome_layout

Now, letโ€™s add the buttons and labels for your "Bheru" connection:

  • Drag a Label into Welcome_Layout. Rename it lbl_Header.
  • Set Text to: "Welcome to Garba-Sync"
  • Set Font Size to: 24 and Bold.
  • Drag a Button into Welcome_Layout. Rename it btn_Scan.
  • Set Text to: "Scan for Bheru"
  • Set Background Color to something vibrant!
  • Drag a ListView into Welcome_Layout. Rename it list_Devices.
  • This is where "Chaniya Choli" or "Kediyu" names will appear.
  • Drag another Label at the bottom. Rename it lbl_Status.
  • Set Text to: "Status: Not Connected"

Step 5: The "Scan" Logic (The Blocks)

MIT App Inventor : The Blocks
The Blocks for Welcome_Layout

Now, click the Blocks button in the top right corner to start the "coding."

  • Find the btn_Scan blocks: Drag out when btn_Scan.Click.
  • Find the BluetoothLE blocks: Inside the button click, snap in call BluetoothLE1.StartScanning.
  • Update Status: Drag out set lbl_Status.Text to and attach a text block that says "Searching for Bheru...".

Step 6: Design Screen 2 (Aura_Layout)

Designing : Aura_layout

Now, let's build the Aura Settings page. Follow these steps in the Designer tab:

Switch Visibility:

  • In the Components list, uncheck Visible for Welcome_Layout.
  • Check Visible for Aura_Layout. (It will look like a blank canvas now).

Add Audio Toggle:

  • Drag a HorizontalArrangement into Aura_Layout.
  • Inside it, drag a Label ("Audio-Responsive") and a Switch (Rename to sw_Audio).

Add Brightness Slider:

  • Drag a Label ("Brightness") into Aura_Layout.
  • Drag a Slider (Rename to sld_Brightness) below it.
  • Properties: MaxValue = 255, MinValue = 0, ThumbPosition = 50.

Add the Color Palette (The 12 Colors):

  • Drag a TableArrangement into Aura_Layout.
  • Set Columns to 4 and Rows to 3 (this gives you your 12 slots).
  • Drag a Button into each cell. Rename them btn_Red, btn_Orange, etc.
  • Set the Background Color of each button to match your wheel.

Add the Sync Button:

  • Drag a Button (Rename btn_Sync) at the bottom.
  • Text: "โœจ Sync with Nearby Bherus โœจ".
  • Make it Bold and a distinct color like Gold.

Add Radar Navigation:

  • Drag a Button (Rename btn_GoToRadar).
  • Text: "Open Friend Finder Radar".

Step 7.1: The List Update (Clock)

  • Gold Block: when Clock1.Timer
  • Inside: set list_Devices.Elements to โ†’ BluetoothLE1.AdvertiserNames

Step 7 : Setting up the Code Block for Bluetooth Connection

The Blocks for Bluetooth Connection

Step 7.2: The Handshake (AfterPicking)

  • Gold Block: when list_Devices.AfterPicking
  • Inside (Top): call BluetoothLE1.StopScanning
  • Inside (Bottom): call BluetoothLE1.ConnectWithName (or ConnectWithAddress)
  • Socket: list_Devices.Selection

Step 7.3: The Success Switch (Connected)

  • Gold Block: when BluetoothLE1.Connected
  • Inside (1st): set Welcome_Layout.Visible to โ†’ false
  • Inside (2nd): set Aura_Layout.Visible to โ†’ true
  • Inside (3rd): set lbl_Status.Text to โ†’ "โœจ Connected to Bheru! โœจ"

Application Testing and Validation

MIT AI2 Companion

The MIT AI2 Companion is a free app that acts as a "live bridge" between my computer and my phone.

Instead of having to download and install an app file (.apk) every time I make a change, the Companion app shows my project live. When I move a button on my computer screen, it moves on my phone screen instantly. It is essential for testing Bluetooth because computers usually can't "talk" to BLE devices directly through the browserโ€”they need my phone's hardware to do it.

Download the MIT AI2 Companion --> Connect computer and phone to the same WiFi network --> In my computer browser click "connect" --> AI Companion --> Scan QR code using my mobile --> Wait for Mobile Screen to get updated.

What Went Wrong - The screen resolution and icon size mismatch, Keep Searching... for device

The Screen design and pixel size on MIT App Inventor was not completely on my phone's screen when I download .apk

What I Learned - Test Step by Step

DO not weight for entire design to be done and then testing all features together, it is better to test as we design and build.

Step 1: So here, I should have first make welcome screen block, check the actual size and tested the BLE connect function first.

Step 2: Then test the Aura Screen block, check the actual size and Neopixel Brightness control function.

Step 3: Then I should have test color selector function change the Neopixel color change.

At this point the week was over and it wsa time fo rme to move to another week , so I decided to come back late and continue the mobile application development.

I came back to this task during my project development weeks and continued from below :

I tried MIT App Inventor and developed a primary version of my BLE Application. I decided to use that as my draft UI design and make a new prompt to design final version of my BLE based application using Claude Design.

Below, is my Application development Week - Android Application for reference.

MIT App Inventor

Below, is a new prompt and detailed instruction that I defined make new version on reference of earlier version using Claude Design.

AI prompt Claude:

Project name is โ€œRangbheruโ€ Mobile App screens_needed are as below:

  • Top Bar: BLE scan button - Connect / pair XIAO ESP32S3 Sence, show connected device name โ€œBheru A1โ€, I have two devices โ€œBheru A1โ€ and Bheru A2โ€, and sliding button on the top right to toggle between bright (white/day) and dark (black/night) modes. Top Bar shall be common in all the screens.
  • Screen for Color, Aura & brightness control and mobile apps shall communicate the values of Color; Aura, and Brightness level to device over BLE.
    • Color picker style: HSV square + sliders, and the 12-step colors:
      • Red #FF0000 Primary
      • Red-Orange #FF4500 Tertiary
      • Orange #FFA500 Secondary
      • Yellow-Orange #FF8C00 Tertiary
      • Yellow #FFFF00 Primary
      • Yellow-Green #9ACD32 Tertiary
      • Green #008000 Secondary
      • Blue-Green #008B8B Tertiary
      • Blue #0000FF Primary
      • Blue-Purple #8A2BE2 Tertiary
      • Purple #800080 Secondary
      • Red-Purple #C71585 Tertiary
    • Aura control shall have three Aure buttons in a single row
      • First is slow blinking frequency with random colors, smooth color change of 36 neopixels
      • Second is medium random blinking frequency with random colors and random turn on/off on 36 neopixels
      • Third is fast random blinking frequency with random colors and random turn on/off on 36 neopixels
    • Brightness: Slider control 0% - 100%
  • Screen for Audio_viz : Audio reactive mode (visualizer + settings), Live waveform.
    • Audio reactive mode as "Beat Sync" toggle button.
    • Audio Sensitivity : Slider control 0% - 100%
  • Screen for Mesh map and Friend finder - It shall have โ€œFrind Finderโ€ on/off slider, nearby nodes with bearing/distance, compass to nearest node, the app should use mobiles GPS data and send to device the device has Magnetometer module on it, so it can calculate direction and show on direction arrow OLED screen. Once two friends are in BLE range of each other -> Friend Sync: Two Friends can sync their color and blinking pattens of neopixel

What Went Wrong - Icons and Animations didn't appear as rendered App

Below listed icons needed fix. where I gave below images back to Claude AI in a prompt and asked to fix.

Hardware Firmware Development

First, I listed down all the features that I need to develop the Firmware. Secondly, the Hardware firmware has to work with mobile app thus I gave both requirement and feature list to Claude to have smooth interface programming between mobile App and hardware firmware.

AI prompt to Claude Code :

Feature 1: Bluetooth Connection and Device Pairing

Hardware Firmware:

  • On power-up, the device shall enable Bluetooth (BLE) and display the message "Enable Bluetooth & Connect Bheru" on the OLED screen.
  • The device name shall be "Bheru A1". I have two devices, "Bheru A1" and "Bheru A2". The device names will be defined in the firmware of each device.
  • Once connected, the OLED shall display "Connected" for 5 seconds.

Mobile App - Screen 1:

  • A Bluetooth icon shall be displayed in the top bar.
  • When the user taps the Bluetooth icon, a translucent overlay shall appear showing a Bluetooth scanning animation and a list of available Bluetooth devices.
  • Next to the Bluetooth icon, the currently connected device name shall be displayed (for example, "Bheru A1").
  • I have two devices, "Bheru A1" and "Bheru A2". The device names will be defined in the firmware of each device.
  • The connected device name shall be displayed in the top-right corner.
  • This top bar shall be common across all screens in the application.

Feature 2: Color, Aura, and Brightness Control

Mobile App Screen:

  • Color Picker Style: HSV square with sliders.
  • Brightness control slider.
  • 12-step color palette:
    • Red (#FF0000) - Primary
    • Red-Orange (#FF4500) - Tertiary
    • Orange (#FFA500) - Secondary
    • Yellow-Orange (#FF8C00) - Tertiary
    • Yellow (#FFFF00) - Primary
    • Yellow-Green (#9ACD32) - Tertiary
    • Green (#008000) - Secondary
    • Blue-Green (#008B8B) - Tertiary
    • Blue (#0000FF) - Primary
    • Blue-Purple (#8A2BE2) - Tertiary
    • Purple (#800080) - Secondary
    • Red-Purple (#C71585) - Tertiary
  • Three Aura buttons in a single row:
    • Aura 1: Slow blinking frequency with random colors and smooth color transitions across 36 NeoPixels.
    • Aura 2: Medium blinking frequency with random colors and random on/off effects across 36 NeoPixels.
    • Aura 3: Fast blinking frequency with random colors and random on/off effects across 36 NeoPixels.
  • The mobile application shall transmit the selected color, Aura mode, and brightness level to the device over BLE.

Hardware Firmware:

  • The device shall receive the color, Aura mode, and brightness level from the mobile application over BLE.
  • The firmware shall control the 36 NeoPixels accordingly, including color, brightness, and blinking effects.
  • The Aura patterns shall be implemented in the device firmware:
    • Aura 1: Slow blinking frequency with random colors and smooth color transitions.
    • Aura 2: Medium blinking frequency with random colors and random on/off effects.
    • Aura 3: Fast blinking frequency with random colors and random on/off effects.

Feature 3: Audio Visualizer

Mobile App - Audio Visualizer Screen:

  • Audio Reactive Mode (Visualizer + Settings).
  • Live waveform display.
  • Audio-reactive sensitivity slider.
  • The mobile application shall send the Audio Reactive Mode status (ON/OFF) to the hardware over BLE whenever the status changes.
  • The mobile application shall also send the sensitivity slider value to the hardware over BLE.

Hardware Firmware:

  • The device shall receive the Audio Reactive Mode status and sensitivity value from the mobile application over BLE.
  • An INMP441 digital microphone is connected to the XIAO ESP32S3 Sense. Please refer to the connection table above.
  • The firmware shall process the microphone input and animate the 36 NeoPixels according to the detected audio waveform.

Feature 4: Mesh Map and Friend Finder

Mobile App Screen:

  • Mesh map displaying nearby nodes with bearing and distance information.
  • A Friend Finder ON/OFF slider.
  • The mobile application shall send the Friend Finder status to the hardware over BLE.

Hardware Firmware:

  • The firmware shall integrate Meshtastic, an open-source, off-grid, decentralized mesh network.
  • The device shall receive the Friend Finder status from the mobile application over BLE.
  • When enabled, the device shall discover nearby Meshtastic nodes and display their names.
  • The device shall broadcast its own node name and GPS location data.
  • When a user selects a nearby node, the OLED shall display the direction of that node.
  • The user will hold the device like a compass.
  • The device shall use magnetometer data to determine North/South orientation and calculate the direction to the selected node.
  • When two friends are within BLE range of each other, a Friend Sync feature shall allow synchronization of NeoPixel colors and blinking patterns.

Feature 5: Mesh Chat

Mobile App - Chat Screen:

  • Users shall be able to send and receive messages between mesh nodes.
  • Two Meshtastic nodes shall be able to communicate directly using text messages over the mesh network.
Arduino Code

/*
 * RangbheruFirmware.ino
 * XIAO ESP32S3 Sense + Wio-SX1262
 *
 * Libraries required (install via Arduino Library Manager):
 *   NimBLE-Arduino          by h2zero
 *   FastLED                 by Daniel Garcia   (>= 3.6.0, replaces Adafruit NeoPixel)
 *   Adafruit SSD1306        by Adafruit
 *   Adafruit GFX Library    by Adafruit
 *   arduinoFFT              by Enrique Condes
 *   RadioLib                by Jan Gromeลก
 *
 * Board: XIAO_ESP32S3 (Seeed Studio XIAO ESP32S3)
 */

#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <NimBLEDevice.h>
#include <FastLED.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_GFX.h>
#include <math.h>

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// FEATURE FLAGS  โ€” set to true one-by-one as you test each module
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#define ENABLE_LORA    false   // set true later for A2 mesh testing
#define ENABLE_AUDIO   true    // Beat Sync audio reactive (INMP441 I2S mic)
#define ENABLE_MAG     false   // set true for compass / mesh test

#if ENABLE_AUDIO
  #include    // new ESP-IDF 5.x I2S API (avoids conflict with FastLED)
  #include 
#endif

#if ENABLE_LORA
  #include 
#endif

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// PIN DEFINES
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#define I2C_SDA          5     // D4
#define I2C_SCL          6     // D5
#define NEOPIXEL_PIN     2     // D1  (via 499ฮฉ)
#define NEOPIXEL_COUNT   34    // 17 front + 17 back (waist)
#define LED_TYPE         WS2812B
#define COLOR_ORDER      GRB
#define MAX_MA           1500  // FastLED current cap (mA) โ€” lower for thin conductive thread

// โ”€โ”€ Ring layout (0-based FastLED indices) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Front: outer circle first in chain โ†’ inner โ†’ center
#define FRONT_OUTER_START  0
#define FRONT_OUTER_END    11  // 12 pixels
#define FRONT_INNER_START  12
#define FRONT_INNER_END    15  // 4 pixels
#define FRONT_CENTER       16  // 1 pixel
// Back: outer circle continues chain โ†’ inner โ†’ center
#define BACK_OUTER_START   17
#define BACK_OUTER_END     28  // 12 pixels
#define BACK_INNER_START   29
#define BACK_INNER_END     32  // 4 pixels
#define BACK_CENTER        33  // 1 pixel
#define BUZZER_PIN       3     // D2

#define I2S_SCK_PIN      1     // D0
#define I2S_WS_PIN       43    // D6
#define I2S_SD_PIN       44    // D7

#define LORA_SCK         8
#define LORA_MISO        9
#define LORA_MOSI        10
#define LORA_CS          41
#define LORA_RST         42
#define LORA_DIO1        45
#define LORA_BUSY        46

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// DEVICE IDENTITY
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#define DEVICE_NAME      "Bheru A1"   // change to "Bheru A2" for second device

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// BLE UUIDs
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHAR_COLOR_UUID     "beb5483e-36e1-4688-b7f5-ea07361b26a8"
#define CHAR_BRI_UUID       "beb5483e-36e1-4688-b7f5-ea07361b26a9"
#define CHAR_AURA_UUID      "beb5483e-36e1-4688-b7f5-ea07361b26aa"
#define CHAR_AUDIO_UUID     "beb5483e-36e1-4688-b7f5-ea07361b26ab"
#define CHAR_FINDER_UUID    "beb5483e-36e1-4688-b7f5-ea07361b26ac"
#define CHAR_GPS_UUID       "beb5483e-36e1-4688-b7f5-ea07361b26ad"
#define CHAR_SYNC_UUID      "beb5483e-36e1-4688-b7f5-ea07361b26ae"
#define CHAR_NOTIFY_UUID    "beb5483e-36e1-4688-b7f5-ea07361b26af"

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// OLED
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#define OLED_ADDR        0x3C
#define SCREEN_WIDTH     128
#define SCREEN_HEIGHT    64

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// HMC5883L
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#define HMC5883L_ADDR    0x1E
#define HMC5883L_REG_A   0x00
#define HMC5883L_REG_MODE 0x02
#define HMC5883L_REG_DATA 0x03

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// AURA MODES
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
enum AuraMode { AURA_OFF = 0, AURA_CALM = 1, AURA_RHYTHM = 2, AURA_ENERGY = 3 };

// Audio visualizer modes / palettes / band focus (must match app indices)
enum VizMode  { VIZ_BARS = 0, VIZ_PULSE = 1, VIZ_SPARKLE = 2, VIZ_SPECTRUM = 3 };
enum BandFocus{ BAND_BASS = 0, BAND_MIDS = 1, BAND_HIGHS = 2 };

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// GLOBAL STATE
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
struct AppState {
  uint8_t  r = 255, g = 0, b = 128;
  uint8_t  brightness = 191;
  AuraMode aura = AURA_OFF;
  // audio
  bool     audioOn = false;
  uint8_t  audioSensitivity = 165;
  uint8_t  vizMode = VIZ_BARS;
  uint8_t  palette = 0;            // 0=holi 1=hotpink 2=fire 3=ice
  uint8_t  smoothing = 128;        // 0-255  (higher = slower/smoother response)
  uint8_t  bandFocus = BAND_BASS;
  // mesh
  bool     finderOn = false;
  float    gpsLat = 0.0f, gpsLon = 0.0f;
  bool     syncActive = false;
  uint8_t  syncR = 0, syncG = 0, syncB = 0;
  AuraMode syncAura = AURA_OFF;
  bool     connected = false;
};

AppState state;
bool bleConnected = false;

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// HARDWARE OBJECTS
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
CRGB              leds[NEOPIXEL_COUNT];
Adafruit_SSD1306  display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

#if ENABLE_LORA
  SX1262 lora(new Module(LORA_CS, LORA_DIO1, LORA_RST, LORA_BUSY));
#endif

#if ENABLE_AUDIO
  #define FFT_SAMPLES  256
  #define SAMPLE_RATE  16000
  static i2s_chan_handle_t i2s_rx_handle = NULL;
  double fftReal[FFT_SAMPLES];
  double fftImag[FFT_SAMPLES];
  ArduinoFFT FFT = ArduinoFFT(fftReal, fftImag, FFT_SAMPLES, SAMPLE_RATE);
#endif

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// VISUALIZER PALETTES  (match app's VIZ_PALETTES stops)
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
struct PaletteDef { const CRGB *colors; uint8_t len; };

const CRGB PAL_HOLI[]    = { CRGB(0xFF4500), CRGB(0xFFD23F), CRGB(0x8A2BE2), CRGB(0x22D3EE), CRGB(0xA3FF3D) };
const CRGB PAL_HOTPINK[] = { CRGB(0xFF3DA5), CRGB(0xFFFAF1) };
const CRGB PAL_FIRE[]    = { CRGB(0xFFFF00), CRGB(0xFFA500), CRGB(0xFF4500), CRGB(0xC71585) };
const CRGB PAL_ICE[]     = { CRGB(0x22D3EE), CRGB(0x0000FF), CRGB(0x9B5DE5) };

const PaletteDef PALETTES[4] = {
  { PAL_HOLI,    5 },
  { PAL_HOTPINK, 2 },
  { PAL_FIRE,    4 },
  { PAL_ICE,     3 },
};

// pos 0.0โ€“1.0 across the palette gradient
CRGB paletteColor(uint8_t idx, float pos) {
  if (idx > 3) idx = 0;
  const PaletteDef &p = PALETTES[idx];
  if (p.len == 1) return p.colors[0];
  if (pos < 0) pos = 0; if (pos > 1) pos = 1;
  float fp = pos * (p.len - 1);
  int   i  = (int)fp;
  if (i >= p.len - 1) return p.colors[p.len - 1];
  uint8_t frac = (uint8_t)((fp - i) * 255.0f);
  return blend(p.colors[i], p.colors[i + 1], frac);
}

// NimBLE notify char
NimBLECharacteristic *pNotifyChar = nullptr;

// Timing
unsigned long lastNeoUpdate  = 0;
unsigned long lastOledUpdate = 0;
unsigned long lastMagUpdate  = 0;
unsigned long lastLoraUpdate = 0;
unsigned long connectedAt    = 0;

float compassHeading = 0.0f;

// LCG for pseudo-random aura patterns
uint32_t rngState = 12345;
uint32_t lcgNext() {
  rngState = rngState * 1664525UL + 1013904223UL;
  return rngState;
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// FORWARD DECLARATIONS  (needed because callbacks reference these functions)
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
void oledShowWaiting();
void oledShowConnected();
void oledShowAudioMode();
void oledShowCompass(float heading, float targetBearing, const char *targetName, float distM);

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// BLE CALLBACKS
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class ServerCallbacks : public NimBLEServerCallbacks {
  void onConnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo) override {
    bleConnected    = true;
    connectedAt     = millis();
    state.connected = true;
    tone(BUZZER_PIN, 880,  80); delay(100);
    tone(BUZZER_PIN, 1320, 80);
    oledShowConnected();
  }
  void onDisconnect(NimBLEServer *pServer, NimBLEConnInfo &connInfo, int reason) override {
    bleConnected    = false;
    state.connected = false;
    NimBLEDevice::getAdvertising()->start();  // NimBLE 2.x
    oledShowWaiting();
  }
};

class ColorCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
    auto val = pChar->getValue();
    if (val.size() >= 3) {
      state.r = (uint8_t)val[0];
      state.g = (uint8_t)val[1];
      state.b = (uint8_t)val[2];
      state.audioOn = false;
    }
  }
};

class BrightnessCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
    auto val = pChar->getValue();
    if (val.size() >= 1) state.brightness = (uint8_t)val[0];
  }
};

class AuraCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
    auto val = pChar->getValue();
    if (val.size() >= 1) {
      uint8_t v     = (uint8_t)val[0];
      state.aura    = (v <= 3) ? (AuraMode)v : AURA_OFF;
      state.audioOn = false;
    }
  }
};

// Audio config โ€” 6-byte payload: [on, sensitivity, vizMode, palette, smoothing, bandFocus]
// Parsed defensively so older 2-byte writes still work.
class AudioCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
    auto val = pChar->getValue();
    if (val.size() >= 1) state.audioOn         = (val[0] != 0);
    if (val.size() >= 2) state.audioSensitivity = (uint8_t)val[1];
    if (val.size() >= 3) state.vizMode          = (val[2] <= 3) ? (uint8_t)val[2] : 0;
    if (val.size() >= 4) state.palette          = (val[3] <= 3) ? (uint8_t)val[3] : 0;
    if (val.size() >= 5) state.smoothing        = (uint8_t)val[4];
    if (val.size() >= 6) state.bandFocus        = (val[5] <= 2) ? (uint8_t)val[5] : 0;
    if (state.audioOn) state.aura = AURA_OFF;
  }
};

class FinderCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
    auto val = pChar->getValue();
    if (val.size() >= 1) state.finderOn = (val[0] != 0);
  }
};

class GpsCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
    auto val = pChar->getValue();
    if (val.size() < 3) return;
    char buf[32] = {0};
    size_t len = val.size() < 31 ? val.size() : 31;
    for (size_t i = 0; i < len; i++) buf[i] = (char)val[i];
    char *comma = strchr(buf, ',');
    if (comma) {
      *comma = '\0';
      state.gpsLat = atof(buf);
      state.gpsLon = atof(comma + 1);
    }
  }
};

class SyncCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic *pChar, NimBLEConnInfo &connInfo) override {
    auto val = pChar->getValue();
    if (val.size() >= 4) {
      state.syncR    = (uint8_t)val[0];
      state.syncG    = (uint8_t)val[1];
      state.syncB    = (uint8_t)val[2];
      state.syncAura = (AuraMode)((uint8_t)val[3] <= 3 ? (uint8_t)val[3] : 0);
      state.syncActive = true;
      state.r = state.syncR; state.g = state.syncG; state.b = state.syncB;
      state.aura = state.syncAura;
      tone(BUZZER_PIN, 1760, 150);
    }
  }
};

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// OLED HELPERS
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
void oledShowWaiting() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(4, 2);  display.println("Enable Bluetooth");
  display.setCursor(4, 14); display.println("& Connect");
  display.setTextSize(2);
  display.setCursor(4, 30); display.println(DEVICE_NAME);
  display.display();
}

void oledShowConnected() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(2);
  display.setCursor(8, 18);  display.println("Connected");
  display.setTextSize(1);
  display.setCursor(24, 46); display.println(DEVICE_NAME);
  display.display();
}

void oledShowAudioMode() {
  const char *vizNames[]  = { "Bars", "Pulse", "Sparkle", "Spectrum" };
  const char *palNames[]  = { "Holi", "HotPink", "Fire", "Ice" };
  const char *bandNames[] = { "Bass", "Mids", "Highs" };
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(4, 2);  display.print("BEAT SYNC  ");
  display.println(vizNames[state.vizMode <= 3 ? state.vizMode : 0]);
  display.setCursor(4, 14);
  display.print(palNames[state.palette <= 3 ? state.palette : 0]);
  display.print(" / ");
  display.println(bandNames[state.bandFocus <= 2 ? state.bandFocus : 0]);
  int barW = map(state.audioSensitivity, 0, 255, 0, 120);
  display.drawRect(4, 30, 120, 10, SSD1306_WHITE);
  display.fillRect(4, 30, barW, 10, SSD1306_WHITE);
  display.setCursor(4, 46);
  display.print("Sens: ");
  display.print(map(state.audioSensitivity, 0, 255, 0, 100));
  display.print("%");
  display.display();
}

void oledShowCompass(float heading, float targetBearing, const char *targetName, float distM) {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  const int cx = 96, cy = 32, cr = 28;
  display.drawCircle(cx, cy, cr, SSD1306_WHITE);
  display.setCursor(cx - 2, cy - cr - 8); display.print("N");
  display.setCursor(cx - 2, cy + cr + 1); display.print("S");
  display.setCursor(cx + cr + 1, cy - 3); display.print("E");
  display.setCursor(cx - cr - 7, cy - 3); display.print("W");
  float relRad = ((targetBearing - heading) + 360.0f) * DEG_TO_RAD;
  int ax = cx + (int)((cr - 5) * sinf(relRad));
  int ay = cy - (int)((cr - 5) * cosf(relRad));
  display.drawLine(cx, cy, ax, ay, SSD1306_WHITE);
  display.fillCircle(ax, ay, 3, SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 0);  display.println("POINTING TO:");
  display.println(targetName);
  char buf[16];
  snprintf(buf, sizeof(buf), "%.1f m", distM);
  display.println(buf);
  display.display();
}

void oledShowStatus() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(4, 2);  display.println(DEVICE_NAME);
  const char *auraNames[] = { "Steady", "Calm", "Rhythm", "Energy" };
  display.setCursor(4, 14); display.print("Aura: "); display.println(auraNames[state.aura]);
  char buf[20];
  snprintf(buf, sizeof(buf), "RGB #%02X%02X%02X", state.r, state.g, state.b);
  display.setCursor(4, 26); display.println(buf);
  display.setCursor(4, 38); display.print("Bri: ");
  display.print((int)(state.brightness * 100 / 255));
  display.println("%");
  display.display();
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// MAGNETOMETER (disabled until ENABLE_MAG = true)
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#if ENABLE_MAG
bool magInit() {
  Wire.beginTransmission(HMC5883L_ADDR);
  Wire.write(HMC5883L_REG_A);
  Wire.write(0x70);  // 8-sample avg, 15Hz
  Wire.write(0xA0);  // gain
  Wire.write(0x00);  // continuous
  return Wire.endTransmission() == 0;
}

float magReadHeading() {
  Wire.beginTransmission(HMC5883L_ADDR);
  Wire.write(HMC5883L_REG_DATA);
  Wire.endTransmission();
  Wire.requestFrom((uint8_t)HMC5883L_ADDR, (uint8_t)6);
  if (Wire.available() < 6) return compassHeading;
  int16_t x = (Wire.read() << 8) | Wire.read();
  int16_t z = (Wire.read() << 8) | Wire.read();
  int16_t y = (Wire.read() << 8) | Wire.read();
  float h = atan2f((float)y, (float)x) * RAD_TO_DEG;
  if (h < 0) h += 360.0f;
  return h;
}
#endif

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// AUDIO REACTIVE (disabled until ENABLE_AUDIO = true)
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#if ENABLE_AUDIO
void i2sInit() {
  // New ESP-IDF 5.x I2S driver โ€” compatible with FastLED on ESP32-S3
  i2s_chan_config_t chan_cfg  = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
  chan_cfg.dma_desc_num  = 4;
  chan_cfg.dma_frame_num = FFT_SAMPLES;  // 256 frames per DMA buffer
  ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, NULL, &i2s_rx_handle));

  i2s_std_config_t std_cfg = {
    .clk_cfg  = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE),
    // STEREO mode: generates proper alternating WS that INMP441 requires.
    // L/R=GND โ†’ mic outputs on left channel (WS low). We extract both channels
    // and use whichever has signal (debug shows peakL vs peakR).
    .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO),
    .gpio_cfg = {
      .mclk = I2S_GPIO_UNUSED,
      .bclk = (gpio_num_t)I2S_SCK_PIN,
      .ws   = (gpio_num_t)I2S_WS_PIN,
      .dout = I2S_GPIO_UNUSED,
      .din  = (gpio_num_t)I2S_SD_PIN,
      .invert_flags = { .mclk_inv = false, .bclk_inv = false, .ws_inv = false },
    },
  };
  ESP_ERROR_CHECK(i2s_channel_init_std_mode(i2s_rx_handle, &std_cfg));
  ESP_ERROR_CHECK(i2s_channel_enable(i2s_rx_handle));
}

// Map band focus -> FFT bin range. Bin width = SAMPLE_RATE/FFT_SAMPLES = 62.5 Hz.
void bandRange(int &loBin, int &hiBin) {
  switch (state.bandFocus) {
    case BAND_BASS:  loBin = 1;  hiBin = 6;   break;  // ~60โ€“375 Hz
    case BAND_MIDS:  loBin = 6;  hiBin = 40;  break;  // ~375โ€“2500 Hz
    default:         loBin = 40; hiBin = 120; break;  // ~2.5โ€“7.5 kHz
  }
}

void audioReactiveUpdate() {
  static unsigned long lastDbg  = 0;
  static int32_t       lastPeak = 0;

  // Stereo buffer: FFT_SAMPLES L/R pairs interleaved = 2ร— samples
  int32_t raw[FFT_SAMPLES * 2];
  size_t  bytesRead = 0;
  // 40 ms timeout โ€” 256 stereo frames @ 16 kHz = 32 ms
  i2s_channel_read(i2s_rx_handle, raw, sizeof(raw), &bytesRead, pdMS_TO_TICKS(40));

  int nPairs = (int)(bytesRead / sizeof(int32_t)) / 2;

  // Measure both channel peaks โ€” tells us which channel the INMP441 is using
  int32_t peakL = 0, peakR = 0;
  for (int i = 0; i < nPairs; i++) {
    int32_t avL = raw[i*2]   < 0 ? -raw[i*2]   : raw[i*2];
    int32_t avR = raw[i*2+1] < 0 ? -raw[i*2+1] : raw[i*2+1];
    if (avL > peakL) peakL = avL;
    if (avR > peakR) peakR = avR;
  }
  lastPeak = peakL > peakR ? peakL : peakR;

  // Extract left channel (even indices, L/R=GND โ†’ left = WS low)
  // If peakR > peakL in debug, swap to raw[i*2+1] for right channel
  for (int i = 0; i < FFT_SAMPLES; i++) {
    fftReal[i] = (i < nPairs) ? (double)(raw[i*2] >> 8) : 0.0;
    fftImag[i] = 0.0;
  }
  FFT.windowing(FFTWindow::Hamming, FFTDirection::Forward);
  FFT.compute(FFTDirection::Forward);
  FFT.complexToMagnitude();

  float gain = 0.3f + (state.audioSensitivity / 255.0f) * 3.0f;
  float sm   = (state.smoothing / 255.0f) * 0.9f;

  int loBin, hiBin; bandRange(loBin, hiBin);

  double sum = 0;
  for (int b = loBin; b < hiBin && b < FFT_SAMPLES / 2; b++) sum += fftReal[b];
  float rawLevel = (float)(sum / (hiBin - loBin) * gain / 500000.0f);
  float level    = constrain(rawLevel, 0.0f, 1.0f);

  static float sLevel = 0.0f;
  sLevel = sLevel * sm + level * (1.0f - sm);

  // Debug every 2 s โ€” clap near mic and watch rawPeak jump
  if (millis() - lastDbg > 2000) {
    lastDbg = millis();
    Serial.printf("[Audio] bytes=%u  peakL=%d  peakR=%d  rawLevel=%.5f  sLevel=%.4f  viz=%d\n",
                  (unsigned)bytesRead, peakL, peakR, rawLevel, sLevel, state.vizMode);
  }

  // Idle animation โ€” full brightness palette drift when no signal.
  // Viz/palette switches are always visible here even without a mic.
  if (sLevel < 0.04f) {
    static float idlePhase = 0.0f;
    idlePhase = fmodf(idlePhase + 0.003f, 1.0f);
    for (int i = 0; i < NEOPIXEL_COUNT; i++) {
      float t = fmodf(idlePhase + (float)i / NEOPIXEL_COUNT, 1.0f);
      leds[i] = paletteColor(state.palette, t);   // full brightness โ€” clearly visible
    }
    FastLED.setBrightness(state.brightness);
    FastLED.show();
    return;
  }

  switch (state.vizMode) {

    case VIZ_BARS: {                       // VU meter โ€” fill from one end
      int lit = (int)(sLevel * NEOPIXEL_COUNT + 0.5f);
      for (int i = 0; i < NEOPIXEL_COUNT; i++)
        leds[i] = (i < lit) ? paletteColor(state.palette, (float)i / NEOPIXEL_COUNT)
                            : CRGB::Black;
      break;
    }

    case VIZ_PULSE: {
      // Concentric ring expansion โ€” all pixels in each ring turn on together.
      // Low sound  โ†’ center only (1 px front + 1 px back)
      // Medium     โ†’ center + inner ring (4 px each side)
      // Loud       โ†’ all 3 rings (17 px each side)
      //
      // Tune these thresholds if rings trigger too early or too late:
      const float T_CENTER = 0.06f;
      const float T_INNER  = 0.30f;
      const float T_OUTER  = 0.65f;

      // Smooth ramp: 0โ†’full brightness over 0.15 window above each threshold.
      // This gives a crisp but not jarring snap-on.
      auto ringBri = [](float lvl, float thr) -> uint8_t {
        if (lvl <= thr) return 0;
        return (uint8_t)(constrain((lvl - thr) / 0.15f, 0.f, 1.f) * 255.f);
      };
      uint8_t bC = ringBri(sLevel, T_CENTER);
      uint8_t bI = ringBri(sLevel, T_INNER);
      uint8_t bO = ringBri(sLevel, T_OUTER);

      // Ring colours from active palette
      CRGB cCenter = paletteColor(state.palette, 0.5f);
      CRGB cInner  = paletteColor(state.palette, 0.3f);
      CRGB cOuter  = paletteColor(state.palette, 0.8f);

      // โ”€โ”€ Front โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
      leds[FRONT_CENTER] = cCenter; leds[FRONT_CENTER].nscale8(bC);
      for (int i = FRONT_INNER_START; i <= FRONT_INNER_END; i++)
        { leds[i] = cInner; leds[i].nscale8(bI); }
      for (int i = FRONT_OUTER_START; i <= FRONT_OUTER_END; i++)
        { leds[i] = cOuter; leds[i].nscale8(bO); }

      // โ”€โ”€ Back โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
      leds[BACK_CENTER] = cCenter; leds[BACK_CENTER].nscale8(bC);
      for (int i = BACK_INNER_START; i <= BACK_INNER_END; i++)
        { leds[i] = cInner; leds[i].nscale8(bI); }
      for (int i = BACK_OUTER_START; i <= BACK_OUTER_END; i++)
        { leds[i] = cOuter; leds[i].nscale8(bO); }

      break;
    }

    case VIZ_SPARKLE: {                    // random flashes scaled by loudness
      fadeToBlackBy(leds, NEOPIXEL_COUNT, 48);
      int sparks = (int)(sLevel * NEOPIXEL_COUNT);
      for (int s = 0; s < sparks; s++) {
        int idx = lcgNext() % NEOPIXEL_COUNT;
        leds[idx] = paletteColor(state.palette, (lcgNext() % 256) / 255.0f);
      }
      break;
    }

    default: {
      // VIZ_SPECTRUM โ€” each LED maps to a distinct frequency bin inside the band.
      // Uses ratio mapping so it works correctly even when band has fewer bins than LEDs (e.g. Bass).
      static float sBin[NEOPIXEL_COUNT] = {};
      static uint8_t lastBand = 255;
      if (state.bandFocus != lastBand) {   // reset EMA when band changes
        memset(sBin, 0, sizeof(sBin));
        lastBand = state.bandFocus;
      }
      for (int led = 0; led < NEOPIXEL_COUNT; led++) {
        float frac = (NEOPIXEL_COUNT > 1) ? (float)led / (NEOPIXEL_COUNT - 1) : 0.5f;
        int   bin  = loBin + (int)(frac * (hiBin - loBin));
        bin = constrain(bin, 0, FFT_SAMPLES / 2 - 1);
        float norm = constrain((float)(fftReal[bin] * gain / 2000.0f), 0.0f, 1.0f);
        sBin[led]  = sBin[led] * sm + norm * (1.0f - sm);
        CRGB c = paletteColor(state.palette, frac);
        c.nscale8((uint8_t)(sBin[led] * 255));
        leds[led] = c;
      }
      break;
    }
  }

  FastLED.setBrightness(state.brightness);
  FastLED.show();
}
#endif

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// NEOPIXEL PATTERNS  (color / aura โ€” FastLED, master brightness applied by caller)
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
void neoSteady() {
  fill_solid(leds, NEOPIXEL_COUNT, CRGB(state.r, state.g, state.b));
  FastLED.show();
}

void neoCalmUpdate(unsigned long /*now*/) {
  static float phase = 0.0f;
  phase += 0.005f;
  for (int i = 0; i < NEOPIXEL_COUNT; i++) {
    float hue = fmodf(phase + (float)i / NEOPIXEL_COUNT, 1.0f);
    float amp = 0.5f + 0.5f * sinf((phase * TWO_PI * 0.6f) + (float)i / NEOPIXEL_COUNT * TWO_PI);
    leds[i] = CHSV((uint8_t)(hue * 255), 255, (uint8_t)(amp * 255));
  }
  FastLED.show();
}

void neoRhythmUpdate(unsigned long now) {
  static unsigned long lastTick = 0;
  static CRGB cols[NEOPIXEL_COUNT];
  static bool ledOn[NEOPIXEL_COUNT];
  if (now - lastTick > 450) {
    lastTick = now;
    for (int i = 0; i < NEOPIXEL_COUNT; i++) {
      ledOn[i] = (lcgNext() % 2 == 0);
      cols[i]  = CHSV((uint8_t)(lcgNext() % 256), 255, 255);
    }
  }
  for (int i = 0; i < NEOPIXEL_COUNT; i++)
    leds[i] = ledOn[i] ? cols[i] : CRGB::Black;
  FastLED.show();
}

void neoEnergyUpdate(unsigned long now) {
  static unsigned long lastTick = 0;
  if (now - lastTick < 80) return;
  lastTick = now;
  for (int i = 0; i < NEOPIXEL_COUNT; i++) {
    if ((lcgNext() % 10) < 4)
      leds[i] = CHSV((uint8_t)(lcgNext() % 256), 255, 255);
    else
      leds[i] = CRGB::Black;
  }
  FastLED.show();
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// LORA MESH (disabled until ENABLE_LORA = true)
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
#if ENABLE_LORA
struct MeshNode { char name[16]; float lat, lon; unsigned long lastSeen; };
#define MAX_NODES 8
MeshNode meshNodes[MAX_NODES];
int meshNodeCount = 0;

void loraTxPosition() {
  if (!state.finderOn) return;
  char pkt[64];
  snprintf(pkt, sizeof(pkt), "RB:%s:%.6f:%.6f", DEVICE_NAME, state.gpsLat, state.gpsLon);
  lora.transmit((uint8_t *)pkt, strlen(pkt));
}

void loraRxCheck() {
  if (!state.finderOn) return;
  uint8_t buf[64]; int len = sizeof(buf);
  int err = lora.receive(buf, len);
  if (err == RADIOLIB_ERR_NONE) {
    buf[len] = 0;
    char *s = (char*)buf;
    if (strncmp(s, "RB:", 3) == 0) {
      char *c1 = strchr(s + 3, ':');
      if (!c1) return;
      char *c2 = strchr(c1 + 1, ':');
      if (!c2) return;
      *c1 = '\0'; *c2 = '\0';
      const char *name = s + 3;
      float lat = atof(c1 + 1), lon = atof(c2 + 1);
      for (int i = 0; i < meshNodeCount; i++) {
        if (strcmp(meshNodes[i].name, name) == 0) {
          meshNodes[i].lat = lat; meshNodes[i].lon = lon;
          meshNodes[i].lastSeen = millis(); return;
        }
      }
      if (meshNodeCount < MAX_NODES) {
        strncpy(meshNodes[meshNodeCount].name, name, 15);
        meshNodes[meshNodeCount].lat = lat; meshNodes[meshNodeCount].lon = lon;
        meshNodes[meshNodeCount].lastSeen = millis();
        meshNodeCount++;
      }
    }
  }
}

float bearingTo(float la1, float lo1, float la2, float lo2) {
  float dLon = (lo2 - lo1) * DEG_TO_RAD;
  float y = sinf(dLon) * cosf(la2 * DEG_TO_RAD);
  float x = cosf(la1 * DEG_TO_RAD) * sinf(la2 * DEG_TO_RAD)
           - sinf(la1 * DEG_TO_RAD) * cosf(la2 * DEG_TO_RAD) * cosf(dLon);
  return fmodf(atan2f(y, x) * RAD_TO_DEG + 360.0f, 360.0f);
}
float distanceTo(float la1, float lo1, float la2, float lo2) {
  const float R = 6371000.0f;
  float dLat = (la2 - la1) * DEG_TO_RAD, dLon = (lo2 - lo1) * DEG_TO_RAD;
  float a = sinf(dLat/2)*sinf(dLat/2) + cosf(la1*DEG_TO_RAD)*cosf(la2*DEG_TO_RAD)*sinf(dLon/2)*sinf(dLon/2);
  return R * 2.0f * atan2f(sqrtf(a), sqrtf(1.0f - a));
}
#endif

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// BLE NOTIFY โ€” compass heading โ†’ app
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
void notifyHeading() {
  if (!bleConnected || !pNotifyChar) return;
  uint16_t h = (uint16_t)compassHeading;
  pNotifyChar->setValue((uint8_t*)&h, 2);
  pNotifyChar->notify();
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// SETUP
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println("\n=== Rangbheru " DEVICE_NAME " ===");

  // Buzzer boot beep
  pinMode(BUZZER_PIN, OUTPUT);
  tone(BUZZER_PIN, 440, 100); delay(150);
  tone(BUZZER_PIN, 660, 100); delay(150);

  // I2C
  Wire.begin(I2C_SDA, I2C_SCL);

  // OLED
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    Serial.println("OLED not found โ€” check wiring");
  } else {
    display.clearDisplay(); display.display();
    Serial.println("OLED OK");
  }
  oledShowWaiting();

  // Magnetometer
#if ENABLE_MAG
  if (!magInit()) Serial.println("HMC5883L not found");
  else            Serial.println("Magnetometer OK");
#endif

  // NeoPixels (FastLED with current cap)
  FastLED.addLeds(leds, NEOPIXEL_COUNT);
  FastLED.setMaxPowerInVoltsAndMilliamps(5, MAX_MA);
  FastLED.setBrightness(180);
  FastLED.clear(); FastLED.show();
  // Boot rainbow sweep
  for (int i = 0; i < NEOPIXEL_COUNT; i++) {
    leds[i] = CHSV((uint8_t)(i * 255 / NEOPIXEL_COUNT), 255, 255);
    FastLED.show(); delay(30);
  }
  delay(300);
  FastLED.clear(); FastLED.show();
  Serial.println("NeoPixels OK (FastLED)");

  // Audio
#if ENABLE_AUDIO
  i2sInit();
  Serial.println("I2S mic OK");
#endif

  // LoRa
#if ENABLE_LORA
  SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS);
  int loraErr = lora.begin(868.0);
  if (loraErr != RADIOLIB_ERR_NONE) {
    Serial.printf("LoRa failed: %d\n", loraErr);
  } else {
    lora.setSpreadingFactor(7);
    lora.setBandwidth(125.0);
    lora.setCodingRate(5);
    lora.setOutputPower(14);
    Serial.println("LoRa OK");
  }
#endif

  // BLE
  NimBLEDevice::init(DEVICE_NAME);
  NimBLEDevice::setPower(ESP_PWR_LVL_P9);

  NimBLEServer  *pServer  = NimBLEDevice::createServer();
  pServer->setCallbacks(new ServerCallbacks());

  NimBLEService *pService = pServer->createService(SERVICE_UUID);

  const uint32_t W = NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR;
  const uint32_t N = NIMBLE_PROPERTY::NOTIFY;

  pService->createCharacteristic(CHAR_COLOR_UUID,   W)->setCallbacks(new ColorCallback());
  pService->createCharacteristic(CHAR_BRI_UUID,     W)->setCallbacks(new BrightnessCallback());
  pService->createCharacteristic(CHAR_AURA_UUID,    W)->setCallbacks(new AuraCallback());
  pService->createCharacteristic(CHAR_AUDIO_UUID,   W)->setCallbacks(new AudioCallback());
  pService->createCharacteristic(CHAR_FINDER_UUID,  W)->setCallbacks(new FinderCallback());
  pService->createCharacteristic(CHAR_GPS_UUID,     W)->setCallbacks(new GpsCallback());
  pService->createCharacteristic(CHAR_SYNC_UUID,    W)->setCallbacks(new SyncCallback());
  pNotifyChar = pService->createCharacteristic(CHAR_NOTIFY_UUID, N);

  pService->start();

  // NimBLE 2.x advertising setup
  NimBLEAdvertising *pAdv = NimBLEDevice::getAdvertising();
  pAdv->setName(DEVICE_NAME);           // broadcast name so Android can find it
  pAdv->addServiceUUID(SERVICE_UUID);   // include service UUID in advertisement
  pAdv->setMinInterval(32);             // 20ms โ€” fast advertising, easier to discover
  pAdv->setMaxInterval(64);             // 40ms
  pAdv->start();                        // NimBLE 2.x: use pAdv->start(), not NimBLEDevice::startAdvertising()

  Serial.println("BLE advertising โ€” ready to connect");
  Serial.print("Device name: "); Serial.println(DEVICE_NAME);
}

// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// LOOP
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
void loop() {
  unsigned long now = millis();

  // Magnetometer
#if ENABLE_MAG
  if (now - lastMagUpdate >= 50) {
    lastMagUpdate = now;
    compassHeading = magReadHeading();
    if (bleConnected) notifyHeading();
  }
#endif

  // Audio reactive mode
#if ENABLE_AUDIO
  if (state.audioOn) {
    audioReactiveUpdate();
    if (now - lastOledUpdate >= 1000) { lastOledUpdate = now; oledShowAudioMode(); }
    return;
  }
#endif

  // NeoPixel patterns (color / aura)
  if (now - lastNeoUpdate >= 16) {
    lastNeoUpdate = now;
    FastLED.setBrightness(state.brightness);   // master brightness = app slider
    switch (state.aura) {
      case AURA_CALM:   neoCalmUpdate(now);   break;
      case AURA_RHYTHM: neoRhythmUpdate(now); break;
      case AURA_ENERGY: neoEnergyUpdate(now); break;
      default:          neoSteady();          break;
    }
  }

  // OLED refresh
  if (now - lastOledUpdate >= 500) {
    lastOledUpdate = now;
    if (!bleConnected) {
      oledShowWaiting();
    } else if (now - connectedAt < 5000) {
      oledShowConnected();
#if ENABLE_LORA
    } else if (state.finderOn && meshNodeCount > 0) {
      MeshNode &n = meshNodes[0];
      oledShowCompass(compassHeading,
                      bearingTo(state.gpsLat, state.gpsLon, n.lat, n.lon),
                      n.name,
                      distanceTo(state.gpsLat, state.gpsLon, n.lat, n.lon));
    } else if (state.finderOn) {
      display.clearDisplay();
      display.setTextColor(SSD1306_WHITE); display.setTextSize(1);
      display.setCursor(4, 2);  display.println("Friend Finder ON");
      display.setCursor(4, 14); display.println("Searching mesh...");
      display.display();
#endif
    } else {
      oledShowStatus();
    }
  }

  // LoRa mesh
#if ENABLE_LORA
  if (now - lastLoraUpdate >= 5000) { lastLoraUpdate = now; loraTxPosition(); }
  loraRxCheck();
#endif
}

            

BLE Connect and Neopixel control Testing :

Sound reactive-mode Testing

Sound Reactive mode Testing : Unmute the video for the sound / function verification.

Sound reactive-mode Testing

Below work is for my future reference and for my final project progress


AI prompt Gemini: "Can you please help to start development with spiral 2 : Develop the BLE Mobile App to control color and brightness for a single costume. we can come back to Spiral 1 of hardware development later. Right Now, I have XIAO ESP32S3 and Neopixel Strip of with 25 Neopixels. can you help me develop the BLE Mobile App to control color and brightness for these 25 Neopixels."

1. Hardware Setup

I connected XIAO ESP32S3 to Neopixel ring as follows VCC --> 3V3, GND --> GND, and DATA PIN --> D9.

2. ESP32S3 Firmware (The "Server")

I will need to use the ArduinoBLE library and a Neopixel library (like Adafruit_NeoPixel). The ESP32S3 will act as a BLE Peripheral, exposing "Characteristics" for color and brightness that the mobile app can write to.

Required Logic for the Firmware: I will need native ESP32 BLE library, which is much more robust for the S3 architecture, do not need to download it because it is built into the ESP32 board manager you already installed. The ESP32S3 will act as a BLE Peripheral, exposing "Characteristics" for color and brightness that the mobile app can write to.

  • Service: Create a custom BLE Service for "Garba-Sync".

Characteristics:

  • Color Characteristic: A 3-byte array (Red, Green, Blue) that the app updates.
  • Brightness Characteristic: A single byte (0-255) to control intensity.
  • Callback: When the app writes a new value, the ESP32 updates the Neopixel strip immediately.

3. Mobile App Development (The "Client")

For a custom mobile application, I can use MIT App Inventor (Android) with the BLE Extension.

UI Elements to Create in the App:

  • Scan/Connect Button: To find and pair with your "Garba-Sync" costume.
  • Color Picker or RGB Sliders: Three sliders (0-255) to define the color.
  • Brightness Slider: One slider to send a value to the brightness characteristic.

4. Integration Steps

  • Flash the ESP32S3: Upload the code that initializes the BLE stack and sets the Neopixel strip to 25 LEDs.
  • Test Connection: Use a generic BLE scanner app (like nRF Connect) to ensure I can see the device and manually write hex values to change the LED colors.
  • Build the App: Once manual control works, build the interface in MIT App Inventor to automate the data sending.

AI prompt Gemini: "Lets first work on Arduino code for XIAO ESP32S3 to handle the BLE, I will do the hardware setup VCC, GND and DIN to D9 pin of XIAO ESP32S3 and also XIAO ESP32S3 will be my main controller of the project. Lets use the generic BLE scanner app like nRF connect to ensure I can see the device and manually write hex values to change the LED colors."

Arduino Code - Testing BLE with nRF Connect and Color Service
  
  #include <BLEDevice.h>
  #include <BLEUtils.h>
  #include <BLEServer.h>
  #include <Adafruit_NeoPixel.h>

  // Use the GPIO number directly for the XIAO S3
  #define LED_PIN    D9      
  #define LED_COUNT  25

  Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);

  // Unique IDs for your Garba Costume
  #define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
  #define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"

  class MyCallbacks: public BLECharacteristicCallbacks {
      void onWrite(BLECharacteristic *pCharacteristic) {
        // FIX: Use Arduino 'String' instead of 'std::string'
        String value = pCharacteristic->getValue(); 
        
        if (value.length() == 3) {
          // Accessing indices works the same way
          uint8_t r = (uint8_t)value[0];
          uint8_t g = (uint8_t)value[1];
          uint8_t b = (uint8_t)value[2];
          
          for(int i=0; icreateService(SERVICE_UUID);
    
    BLECharacteristic *pCharacteristic = pService->createCharacteristic(
                                          CHARACTERISTIC_UUID,
                                          BLECharacteristic::PROPERTY_READ |
                                          BLECharacteristic::PROPERTY_WRITE
                                        );

    pCharacteristic->setCallbacks(new MyCallbacks());
    pService->start();
    
    BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
    pAdvertising->addServiceUUID(SERVICE_UUID);
    pAdvertising->setScanResponse(true);
    pAdvertising->setMinPreferred(0x06);  
    pAdvertising->setMinPreferred(0x12);
    BLEDevice::startAdvertising();
    
    Serial.println("Characteristic defined! Now gradient-scanning on your phone...");
  }

  void loop() {
    // Nothing needed here for now, callbacks handle the work!
    delay(2000);
  }
        

What Went Wrong

The XIAO ESP32 was not flashing and giving the upload error - PermissionError(13), I tried several times.

What I Learned

While flashing, please ensure below steps.

  • The "Manual Bootloader" Trick (Most Likely Fix), Press and hold the BOOT button and While holding BOOT, press and release the RESET button once and then Release the BOOT button. Now, select the COM Port and Upload the code.
  • Close the Serial Monitor
  • Check for "Ghost" Connections - If you have other software open that talks to serial ports
  • Swap the Cable
  • Check "USB CDC On Boot" (Again) - Make sure Tools --> USB CDC On Boot is still set to Enabled.

Testing with nRF Connect

Now, I Installed the nRF Connect mobile Application to check if the XIAO is able to connect via BLE.

I followed these steps to test:

  • Open nRF Connect: Enable Bluetooth on my phone and open the app.
  • Scan: I looked for a device named "Garba-Sync-01" and tap Connect.
  • Find Characteristics: Under that service, we can see a characteristic with UUID: beb5483e-36e1-4688-b7f5-ea07361b26a8.

Change Color:

  • Tap the Write icon (up arrow).
  • Select BYTE ARRAY.
  • Enter 3 hex values (e.g., FF 00 00 for Red, 00 FF 00 for Green).
Test With nRF Connect App

Change Brightness:

  • Tap the Write icon.
  • Enter 1 hex value between 00 and FF (e.g., 32 for roughly 20% brightness).
Arduino Code - Both Color and Brightness Services


    #include <BLEDevice.h>
    #include <BLEUtils.h>
    #include <BLEServer.h>
    #include <Adafruit_NeoPixel.h>

    #define LED_PIN    D9      
    #define LED_COUNT  25
    Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);

    // Unique IDs
    #define SERVICE_UUID           "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
    #define COLOR_CHAR_UUID        "beb5483e-36e1-4688-b7f5-ea07361b26a8"
    #define BRIGHTNESS_CHAR_UUID   "d211246c-7e61-456a-93a0-f80076a598c1"

    class ColorCallbacks: public BLECharacteristicCallbacks {
        void onWrite(BLECharacteristic *pCharacteristic) {
          String value = pCharacteristic->getValue();
          if (value.length() == 3) {
            strip.setPixelColor(0, strip.Color((uint8_t)value[0], (uint8_t)value[1], (uint8_t)value[2]));
            for(int i=1; igetValue();
          if (value.length() > 0) {
            uint8_t b = (uint8_t)value[0];
            strip.setBrightness(b);
            strip.show();
            Serial.printf("Brightness set to: %d\n", b);
          }
        }
    };

    void setup() {
      Serial.begin(115200);
      strip.begin();
      strip.setBrightness(50); // Start dim for safety
      strip.show();

      BLEDevice::init("Garba-Sync-01");
      BLEServer *pServer = BLEDevice::createServer();
      BLEService *pService = pServer->createService(SERVICE_UUID);

      // Color Characteristic
      BLECharacteristic *pColorChar = pService->createCharacteristic(COLOR_CHAR_UUID, BLECharacteristic::PROPERTY_WRITE);
      pColorChar->setCallbacks(new ColorCallbacks());

      // Brightness Characteristic
      BLECharacteristic *pBrightChar = pService->createCharacteristic(BRIGHTNESS_CHAR_UUID, BLECharacteristic::PROPERTY_WRITE);
      pBrightChar->setCallbacks(new BrightnessCallbacks());

      pService->start();
      BLEDevice::startAdvertising();
      Serial.println("Ready! Connect via nRF Connect.");
    }

    void loop() { delay(10); }

    

What Went Wrong - not able to see the second service of Brightness control on nRF Connect App

I was not able to see the second service/characteristic (d211...) of brightness control. It is very likely that nRF Connect is showing you a "cached" version of your device from the previous code upload. Since the phone remembers the first time it connected to "Garba-Sync-01" (when it only had one characteristic), it doesn't always look for new ones automatically.

What I Learned

If we face this then :

  • Refresh Services in nRF Connect --> Connect to XIAO --> click top right corner three dots (โ‹ฎ) --> click "Refresh Services".
  • Toggle Bluetooth of my phone

Color and Brightness Control Services

Spiral 3 - LoRa/GPS - starting with the "Ping" test code

XIAO ESP32S3 Sense with the B2B (Board-to-Board) connector is perfect for rapid prototyping my Spiral 3, and my plan to integrate the Wio-SX1262 directly onto a custom PCB for the final version.

Wio-SX1262 module is strictly a LoRaยฎ transceiver module. Its job is to send and receive long-range radio packets for "find friends" feature, I need two distinct pieces of data.

  • Location Provided by a GPS Module, it uses UART (Serial) - TX and RX pins. I installed arduino library TinyGPSplus (by Mikal Hart) to parse the GPS data.
  • Communication: Provided by the LoRa Module (Wio-SX1262) to send that location to others. The Wio-SX1262 uses SPI communication - MOSI, MISO, SCK, and a CS (Chip Select) pin. I installed arduino library RadioLib (by Jan Gromes) - for the SX1262 LoRa chip.

Test Code for GPS Module - this code will read the GPS coordinates and print them to the Serial Monitor. How this works "Under the Hood" - The GPS module (NEO-6M) is constantly screaming text called NMEA sentences (they look like $GPRMC,123519,A,4807.038,N...).

  • The SerialGPS.read() grabs those characters.
  • The gps.encode() function acts like a translator, turning that text into usable variables like gps.location.lat().
  • Because the ESP32S3 is powerful, it can handle this translation while simultaneously running the BLE and Neopixels without any lag.
Arduino Code - GPS Module

// Test Code for GPS Module - this code will read the GPS coordinates and print them to the Serial Monitor.
// --- LIBRARIES ---
#include          // The library that turns messy GPS "sentences" into numbers
#include     // Allows us to create a second serial port for the GPS
#include  // Controls your 25 LEDs

// --- PIN DEFINITIONS ---
#define LED_PIN    D3    // Data pin for Neopixels (moved to D3 to avoid LoRa conflict)
#define LED_COUNT  25   // Total number of LEDs in your strip
#define GPS_RX     D7    // XIAO Pin D7 (Connect to GPS TX)
#define GPS_TX     D6    // XIAO Pin D6 (Connect to GPS RX)

// --- OBJECT INITIALIZATION ---
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800); // Create the LED strip object
TinyGPSPlus gps;                // Create the GPS "parser" object to handle data
HardwareSerial SerialGPS(1);    // Create a new Serial port (Serial1) on the ESP32 hardware

void setup() {
  // Start the standard Serial for the Computer (Serial Monitor) at 115200 baud
  Serial.begin(115200);
  
  // Initialize Neopixels
  strip.begin();
  strip.setBrightness(50);
  strip.fill(strip.Color(0, 0, 255)); // Set color to BLUE (indicates "System OK, searching for GPS")
  strip.show();

  // Initialize GPS communication. 9600 is the factory standard for NEO-6M modules.
  // We tell the ESP32 to use Pins 7 (RX) and 6 (TX) for this specific serial link.
  SerialGPS.begin(9600, SERIAL_8N1, GPS_RX, GPS_TX);
  
  Serial.println("Spiral 3: GPS Test Starting...");
}

void loop() {
  // 1. DATA FEEDING: This loop checks if the GPS module is sending any bytes.
  while (SerialGPS.available() > 0) {
    // gps.encode() takes the raw characters one-by-one and looks for a valid GPS "sentence."
    // It returns 'true' only when it has a complete, valid packet of information.
    if (gps.encode(SerialGPS.read())) {
      displayGPSInfo(); // If a valid packet is ready, go to the display function
    }
  }

  // 2. ERROR CHECK: If 5 seconds pass and no data characters have been processed,
  // it usually means the TX/RX wires are swapped or loose.
  if (millis() > 5000 && gps.charsProcessed() < 10) {
    Serial.println("No GPS detected: check wiring (TX to D7, RX to D6)");
    delay(2000);
  }
}

// --- DISPLAY FUNCTION ---
void displayGPSInfo() {
  // Check if the library successfully calculated a location from the satellites
  if (gps.location.isValid()) {
    // Print Latitude and Longitude with 6 decimal places of precision
    Serial.print("Location: ");
    Serial.print(gps.location.lat(), 6);
    Serial.print(",");
    Serial.println(gps.location.lng(), 6);
    
    // VISUAL FEEDBACK: Change LEDs to GREEN to show the user the "Friend-Finder" is ready
    strip.fill(strip.Color(0, 255, 0));
    strip.show();
  } else {
    // If we have a connection to the module but not enough satellites yet
    Serial.println("Waiting for Satellites... (Go near a window)");
  }
}
  
GPS Module Test

LoRa Integration code - Sending your location through the air! This code will take my GPS coordinates and broadcast them as a LoRa packet. With second device, it would be able to "hear" my location from up to several kilometers away. LoRa Settings - LoRa uses different "Open" radio bands depending on your local laws, the code line radio.begin(868.0) shall be 868.0 for Europe/India, and 915.0 for Americas.

  • The Serial Monitor will say "[SX1262] Initializing ... success!"
  • Once my GPS gets a fix, the Serial Monitor will say "Transmitting packet ... success!"
  • Now I am officially "Broadcasting" its location into the air.

What Went Wrong - X1262 initialization... failed, code -2

The error code -2 in RadioLib means RADIOLIB_ERR_CHIP_NOT_FOUND. Essentially, the XIAO is looking for the LoRa chip over the SPI bus and Wio-SX1262 is answering.

What I Learned "We need to direct AI"

The Ai did not know that there is B2B connection and it is enough for the connection it was directing me to connect 3V3, GND, DIO1, BUSY, NRST to (D0, D1, etc..)pins using jumper wires, which was not required and I send a lot of time debugging it.

Then I gave AI, the links of Seeed WIKI page and after reviewing the after reviewing the official Seeed Studio documentation for the XIAO ESP32S3 + Wio-SX1262 kit, it found the "missing link."

The documentation reveals that when using the B2B connector, the LoRa module uses non-standard pins for its control signals (CS, Busy, Reset, and DIO1) compared to the standard stacking headers. This is why our previous attempts with D0, D1, etc., were failing with Code -2."

The Correct B2B Pin Mapping : CS: 41, DIO1: 39, Busy: 40, Reset: 42

LoRa Initialization

Integration : GPS + LoRa + LEDs

Arduino Code - Integration : GPS + LoRa + LEDs

        
        #include <RadioLib.h>
        #include <TinyGPS++.h>
        #include <HardwareSerial.h>
        #include <Adafruit_NeoPixel.h>

        // --- 1. PIN DEFINITIONS ---
        // LoRa Pins (Official Seeed B2B Mapping for XIAO ESP32S3)
        #define LORA_CS    41
        #define LORA_DIO1  39
        #define LORA_BUSY  40
        #define LORA_NRST  42

        // GPS Pins (XIAO Side Headers)
        #define GPS_RX     D7    // Connect to GPS TX
        #define GPS_TX     D6    // Connect to GPS RX

        // Neopixel Pin (Move to D3 to stay clear of I2C/SPI)
        #define LED_PIN    D3    
        #define LED_COUNT  25

        // --- 2. OBJECT INITIALIZATION ---
        SX1262 radio = new Module(LORA_CS, LORA_DIO1, LORA_NRST, LORA_BUSY);
        TinyGPSPlus gps;
        HardwareSerial SerialGPS(1); // Use UART1
        Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);

        // Timing variables
        unsigned long lastSendTime = 0;
        const int sendInterval = 10000; // Broadcast every 10 seconds

        void setup() {
          Serial.begin(115200);
          while(!Serial); // Wait for monitor
          delay(1000);

          // Initialize LEDs
          strip.begin();
          strip.setBrightness(50);
          strip.fill(strip.Color(0, 0, 255)); // Start Blue (Searching)
          strip.show();

          // Initialize GPS Serial
          SerialGPS.begin(9600, SERIAL_8N1, GPS_RX, GPS_TX);

          // Initialize LoRa Radio
          Serial.println(F("\n--- Garba-Sync Spiral 3 Boot ---"));
          Serial.print(F("[LoRa] Initializing... "));
          
          // 868.0 for India/Europe, 915.0 for Americas
          int state = radio.begin(868.0); 

          if (state == RADIOLIB_ERR_NONE) {
            Serial.println(F("Success!"));
          } else {
            Serial.print(F("Failed, code ")); Serial.println(state);
            strip.fill(strip.Color(255, 0, 0)); // Red for Radio Failure
            strip.show();
            while (true);
          }
        }

        void loop() {
          // 3. AGGRESSIVE GPS FEED
          // We read every single character from GPS to prevent buffer overflow
          while (SerialGPS.available() > 0) {
            gps.encode(SerialGPS.read());
          }

          // 4. PERIODIC DATA UPLOAD (Every 10 Seconds)
          if (millis() - lastSendTime > sendInterval) {
            
            // Print Debug Info to Serial Monitor
            Serial.print(F("Satellites: ")); Serial.print(gps.satellites.value());
            Serial.print(F(" | Lat: ")); Serial.print(gps.location.lat(), 6);
            Serial.print(F(" | Lng: ")); Serial.println(gps.location.lng(), 6);

            if (gps.location.isValid() && gps.location.age() < 2000) {
              // Create the packet string
              String msg = "ID:Sync-01,LAT:" + String(gps.location.lat(), 6) + ",LNG:" + String(gps.location.lng(), 6);
              
              Serial.print(F("[LoRa] Broadcasting Location... "));
              int state = radio.transmit(msg);

              if (state == RADIOLIB_ERR_NONE) {
                Serial.println(F("Done!"));
                flashLED(strip.Color(0, 255, 0)); // Flash Green on success
              } else {
                Serial.print(F("Error: ")); Serial.println(state);
              }
            } 
            else {
              // Visual feedback while waiting for lock
              if(gps.satellites.value() > 0) {
                strip.fill(strip.Color(255, 255, 0)); // Yellow = Seeing sats, but no lock yet
              } else {
                strip.fill(strip.Color(0, 0, 255));   // Blue = No satellites seen
              }
              strip.show();
              Serial.println(F("[GPS] Waiting for valid 3D Lock..."));
            }
            
            lastSendTime = millis();
          }
        }

        // Function to flash LEDs briefly
        void flashLED(uint32_t color) {
          strip.fill(color);
          strip.show();
          delay(300);
          strip.fill(0);
          strip.show();
        }
        
LoRa Broadcasting My Location ! - "Please come and save me from FAB Academy Final Project Pressure!!
LoRa Broadcasting

Master Firmware (BLE, NEO, GPS and LoRa) Key Takeaways :

  • Concurrency: Explain how the code handles three protocols at once (BLE, LoRa, GPS) using the "non-blocking" millis() timer instead of delay().
  • BLE GATT: Mention that you used a Peripheral role with a GATT Service to allow your mobile app to interact with the hardware.
  • Pin Mapping: Highlight that you used the B2B pins (39-42) for LoRa to save the external headers for your GPS and LEDs.
Arduino Code - BLE NEO GPS LoRA (Master Firmware)

  /* * GARBA-SYNC MASTER FIRMWARE (Spiral 3.5)
  * Integrated GPS, LoRa (868MHz), and BLE (Bluetooth Low Energy)
  */
 
  #include <BLEDevice.h>         // Enables Bluetooth Low Energy functionality
  #include <BLEUtils.h>          // BLE helper functions
  #include <BLEServer.h>         // Enables the XIAO to act as a BLE Server (Peripheral)
  #include <RadioLib.h>          // Controls the SX1262 LoRa Module
  #include <TinyGPS++.h>         // Parses raw NMEA data from GPS into readable Lat/Lng
  #include <HardwareSerial.h>    // Manages UART communication for the GPS module
  #include <Adafruit_NeoPixel.h> // Controls the RGB LED ring

  // BLE UUIDs: These are unique "addresses" for your app to find specific features.
  // Service UUID: The main "folder" for your device's features.
  #define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
  // Brightness Characteristic UUID: The specific "file" the app writes to for LED control.
  #define BRIGHT_CHAR_UUID    "beb5483e-36e1-4688-b7f5-ea07361b26a8"

  // LoRa Pin definitions for the XIAO ESP32S3 B2B (Board-to-Board) connector
  #define LORA_CS    41   // Chip Select
  #define LORA_DIO1  39   // Data Input/Output 1 (Interrupt)
  #define LORA_BUSY  40   // Busy status pin
  #define LORA_NRST  42   // Hardware Reset

  // GPS and LED Pins connected to the XIAO headers
  #define GPS_RX     D7   // Connect to GPS Module TX
  #define GPS_TX     D6   // Connect to GPS Module RX
  #define LED_PIN    D3   // Data pin for the Neopixels
  #define LED_COUNT  25   // Number of LEDs in the ring

  // --- OBJECT INITIALIZATION ---
  // Create LoRa instance using the SX1262 module and specific pins
  SX1262 radio = new Module(LORA_CS, LORA_DIO1, LORA_NRST, LORA_BUSY);

  // Create GPS parser instance
  TinyGPSPlus gps;

  // Define a Hardware Serial port (Serial1) for the GPS module
  HardwareSerial SerialGPS(1);

  // Initialize Neopixel strip
  Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);

  // Global variables for system state
  int currentBrightness = 50;     // Initial LED brightness (0-255)
  unsigned long lastSendTime = 0; // Stores the timestamp of the last LoRa transmission

  // --- BLE CALLBACK CLASS ---
  // This class "listens" for data coming from your Mobile App
  class MyCallbacks: public BLECharacteristicCallbacks {
      // This function runs automatically whenever you send data from the App
      void onWrite(BLECharacteristic *pCharacteristic) {
        // Retrieve the data sent from the phone app
        String value = pCharacteristic->getValue(); 

        if (value.length() > 0) {
          // We take the first byte of data and use it to set LED brightness
          currentBrightness = (int)value[0];
          strip.setBrightness(currentBrightness);
          strip.show(); // Update the physical LEDs immediately
          
          Serial.print("App Update -> New Brightness: ");
          Serial.println(currentBrightness);
        }
      }
  };

  void setup() {
    // Start the standard Serial Monitor for debugging
    Serial.begin(115200);
    
    // 1. INITIALIZE LEDs
    strip.begin();
    strip.setBrightness(currentBrightness);
    strip.fill(strip.Color(255, 0, 255)); // Set Purple color (Indicates "Booting/BLE Active")
    strip.show();

    // 2. INITIALIZE BLE (Bluetooth)
    BLEDevice::init("Garba-Sync-Badge");             // Set the name seen by your phone
    BLEServer *pServer = BLEDevice::createServer();    // Create the BLE server instance
    BLEService *pService = pServer->createService(SERVICE_UUID); // Start the main service
    
    // Create the "Brightness" characteristic so the app can talk to the badge
    BLECharacteristic *pChar = pService->createCharacteristic(
                                  BRIGHT_CHAR_UUID, 
                                  BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE
                              );
                              
    pChar->setCallbacks(new MyCallbacks());            // Link the app logic to the code above
    pService->start();                                 // Start the service
    BLEDevice::getAdvertising()->start();              // Start "broadcasting" so phone can find it

    // 3. INITIALIZE GPS & LoRa
    SerialGPS.begin(9600, SERIAL_8N1, GPS_RX, GPS_TX); // Start GPS serial communication
    int state = radio.begin(868.0);                    // Initialize LoRa radio at 868MHz
    
    if (state == RADIOLIB_ERR_NONE) {
      Serial.println("System Online: BLE, GPS, and LoRa Active.");
    } else {
      // If radio fails, print error code (check B2B connection if this happens)
      Serial.print("LoRa Failed, code: "); Serial.println(state);
    }
  }

  void loop() {
    // --- GPS LOGIC ---
    // Constantly "feed" every character coming from the GPS module into the TinyGPS parser
    while (SerialGPS.available() > 0) {
      gps.encode(SerialGPS.read());
    }

    // --- LoRa BROADCAST LOGIC ---
    // Use a non-blocking timer to send data every 10 seconds (10000ms)
    if (millis() - lastSendTime > 10000) {
      
      // Check if the GPS has successfully found its location (Lock)
      if (gps.location.isValid()) {
        // Create a data packet: ID of badge, Latitude, and Longitude
        String msg = "ID:01,LAT:" + String(gps.location.lat(), 6) + ",LNG:" + String(gps.location.lng(), 6);
        
        // Send the location packet into the air via LoRa
        radio.transmit(msg);
        
        Serial.println("LoRa Broadcast Sent: " + msg);
      } else {
        // If GPS isn't ready yet, we wait (usually requires being near a window or outside)
        Serial.println("Waiting for GPS fix to broadcast...");
      }
      
      lastSendTime = millis(); // Reset the 10-second timer
    }
  }
  

Below Steps are for my refercne application develipment in future:

Application Development Worlflow using AI

  • Make a System requirnment document
  • Ask AI's Execution Plan - Gemini and Codex
  • Ask AI's to research - Gemini and Codex
  • Ask AI to share example UI - Gemini and Codex
  • Validate "My wanting" - and iterate if required
  • Ask AI for Final Prompt Document - Gemini and Codex
  • Maintain Change log

Meshtastic Web Flasher

So I also tried the Meshtastic Web Flasher and the kit was successfully flashed.




Download and Useful Links

Mobile App .apl fileDownload here

Mobile App Source CodeDownload here

Firmware for HardwareDownload here

Download BLE Extension for MIT App Inventor

Meshtastic Android Link

Meshtastic Firmware Link