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
- Write an application that interfaces a user with an input &/or output device that you made.
- Compare as many tool options as possible. Document your work on the group work page and reflect on your individual page what you learned.
| 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:
| 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."
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)
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)
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)
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
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.
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.
/*
* 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 : Unmute the video for the sound / function verification.
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."
#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).
Change Brightness:
- Tap the Write icon.
- Enter 1 hex value between 00 and FF (e.g., 32 for roughly 20% brightness).
#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
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 likegps.location.lat(). - Because the ESP32S3 is powerful, it can handle this translation while simultaneously running the BLE and Neopixels without any lag.
// 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)");
}
}
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
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();
}
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.
/* * 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 Firmware Link