diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..d9480e1 Binary files /dev/null and b/.DS_Store differ diff --git a/firmware-cpp/.gitignore b/firmware-cpp/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/firmware-cpp/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/firmware-cpp/.vscode/extensions.json b/firmware-cpp/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/firmware-cpp/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/firmware-cpp/README.md b/firmware-cpp/README.md new file mode 100644 index 0000000..c91cef1 --- /dev/null +++ b/firmware-cpp/README.md @@ -0,0 +1,194 @@ +# ESP32 WebSocket Audio Client + +This firmware turns your Seed Studio XIAO ESP32-S3 (or general ESP32 WROOM Dev module) device into a WebSocket audio client, enabling real-time full-duplex audio communication with the server hosted at `../backend`. It's designed to be used in interactive toys or devices to converse with your personal AI characters. + +## Pin Configuration + + + +| **Component** | **Seeed Studio XIAO ESP32S3** | **General ESP32 Dev Board** | +| -------------------------- | ----------------------------- | --------------------------- | +| **I2S Input (Microphone)** | | | +| SD | D9 | GPIO 13 | +| WS | D7 | GPIO 5 | +| SCK | GD8 | GPIO 18 | +| **I2S Output (Speaker)** | | | +| WS | D0 | GPIO 32 | +| BCK | D1 | GPIO 33 | +| DATA | D2 | GPIO 25 | +| SD (shutdown) | D3 | N/A | +| **Others** | | | +| LED | D4 | GPIO 2 | +| Button | D5 | GPIO 26 | + + + +## Firmware burning with PlatformIO + +1. Install PlatformIO IDE (Visual Studio Code extension) if you haven't already. + +2. Create a new PlatformIO project: + + - Open PlatformIO Home + - Click "New Project" + - Name your project (e.g., "FullDuplexWebSocketAudio") + - Select "Espressif ESP32 Dev Module" as the board + - Choose "Arduino" as the framework + - Select a location for your project + +3. Replace the contents of `src/main.cpp` with the provided ESP32 WebSocket Audio Client code. + +4. Add the required libraries to your `platformio.ini` file: + + - For Seeed Studio XIAO ESP32S3 + + ```ini + [env:seeed_xiao_esp32s3] + platform = espressif32 + board = seeed_xiao_esp32s3 + framework = arduino + monitor_speed = 115200 + lib_deps = + https://github.com/tzapu/WiFiManager.git + gilmaimon/ArduinoWebsockets@^0.5.4 + bblanchon/ArduinoJson@^7.1.0 + ``` + + - For a general ESP32 Dev board + ```ini + [env:esp32dev] + platform = espressif32 + board = esp32dev + framework = arduino + monitor_speed = 115200 + lib_deps = + https://github.com/tzapu/WiFiManager.git + gilmaimon/ArduinoWebsockets @ ^0.5.3 + bblanchon/ArduinoJson @ ^7.1.0 + ``` + +5. Update the WebSocket server details in the code: + + - Find the following lines in the code and update them with your information: + ```cpp + const char *websocket_server_host = ""; // this is your WiFi I.P. Address + const uint16_t websocket_server_port = 8000; + const char *websocket_server_path = "/Elato"; + const char *auth_token = ""; // generate auth-token in your Elato web-app in Settings + ``` + +6. Build the project: + + - Click the "PlatformIO: Build" button in the PlatformIO toolbar or run the build task. + +7. Upload the firmware: + + - Connect your ESP32 to your computer. + - Click the "PlatformIO: Upload" button or run the upload task. + +8. Monitor the device: + + - Open the Serial Monitor to view debug output and device status. + - You can do this by clicking the "PlatformIO: Serial Monitor" button or running the monitor task. + +9. Connect to WiFi using the WiFi Captive portal + - It is straightforward to connect to your local Wifi network with an SSID (WiFi name) and Password. + - Once the device is on, it acts as an Access Point to connect to a known WiFi network. + - Find the device name "Elato device" in your list of local wifi networks. + - Press "Configure Wifi" and type in your SSID and PW for your Wifi and connect. + - The Seeed Stuido XIAO ESP32S3 should then automatically connect to your Wifi and save your Wifi details. + +## Usage + +1. Power on the ESP32 device. +2. The device will automatically connect to the WiFi network as set on the Captive portal. +3. Press the button to initiate a full-duplex WebSocket connection to the server. +4. The LED indicates the current status: + + - Off: Not connected + - Solid On: Connected and listening on microphone + - Pulsing: Streaming audio output (receiving from server) + +5. Speak into the microphone to send audio to the server. +6. The device will play audio received from the server through the speaker. + + + + + + + + + +## Functions + +- `micTask`: Handles audio input from the microphone +- `buttonTask`: Manages button presses for connection control +- `ledControlTask`: Controls the LED status indicator +- `handleTextMessage`: Processes text messages from the server +- `handleBinaryAudio`: Processes binary audio data from the server + +## Customization + +You can modify the following parameters in the code: + + +- Buffer sizes (`bufferCnt`, `bufferLen`) + +- Debounce time for the button (`DEBOUNCE_TIME`) + +## Troubleshooting + +- If you experience connection issues, check your WiFi credentials and server details. +- Ensure all required libraries are installed and up to date. +- Verify that the pin configuration matches your hardware setup. + +## Contributing + +Feel free to submit issues or pull requests to improve this firmware. diff --git a/firmware-cpp/data/startup.mp3 b/firmware-cpp/data/startup.mp3 new file mode 100644 index 0000000..64ed688 Binary files /dev/null and b/firmware-cpp/data/startup.mp3 differ diff --git a/firmware-cpp/include/README b/firmware-cpp/include/README new file mode 100644 index 0000000..194dcd4 --- /dev/null +++ b/firmware-cpp/include/README @@ -0,0 +1,39 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the usual convention is to give header files names that end with `.h'. +It is most portable to use only letters, digits, dashes, and underscores in +header file names, and at most one dot. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/firmware-cpp/lib/README b/firmware-cpp/lib/README new file mode 100644 index 0000000..2593a33 --- /dev/null +++ b/firmware-cpp/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into executable file. + +The source code of each library should be placed in an own separate directory +("lib/your_library_name/[here are source files]"). + +For example, see a structure of the following two libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +and a contents of `src/main.c`: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +PlatformIO Library Dependency Finder will find automatically dependent +libraries scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/firmware-cpp/partition.csv b/firmware-cpp/partition.csv new file mode 100644 index 0000000..e995fd7 --- /dev/null +++ b/firmware-cpp/partition.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 36K, 20K, +otadata, data, ota, 56K, 8K, +app0, app, ota_0, 64K, 2M, +app1, app, ota_1, , 2M, +spiffs, data, spiffs, , 3M, \ No newline at end of file diff --git a/firmware-cpp/platformio.ini b/firmware-cpp/platformio.ini new file mode 100644 index 0000000..9f7ff1a --- /dev/null +++ b/firmware-cpp/platformio.ini @@ -0,0 +1,43 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:esp32-s3-devkitc-1] +platform = espressif32 @ 6.10.0 +board = esp32-s3-devkitc-1 +framework = arduino +monitor_speed = 115200 + +lib_deps = + bblanchon/ArduinoJson@^7.1.0 + links2004/WebSockets@^2.4.1 + https://github.com/esp-arduino-libs/ESP32_Button.git + https://github.com/pschatzmann/arduino-audio-tools.git#v1.0.1 + https://github.com/pschatzmann/arduino-libopus.git + ESP32Async/AsyncTCP + ESP32Async/ESPAsyncWebServer + +; board_build.arduino.memory_type = qio_opi +; board_build.flash_mode = qio +; board_build.prsam_type = opi +board_upload.flash_size = 16MB +board_upload.maximum_size = 16777216 +board_build.filesystem = spiffs +board_build.partitions = partition.csv +upload_protocol = esptool +monitor_filters = + esp32_exception_decoder + time +build_unflags = -std=gnu++11 + +build_flags = + -std=gnu++17 + -D CORE_DEBUG_LEVEL=5 + -D DEBUG_ESP_PORT=Serial + -D TOUCH_SENSOR_ENABLE=1 \ No newline at end of file diff --git a/firmware-cpp/src/Audio.cpp b/firmware-cpp/src/Audio.cpp new file mode 100644 index 0000000..e325c42 --- /dev/null +++ b/firmware-cpp/src/Audio.cpp @@ -0,0 +1,370 @@ +#include "OTA.h" +#include "Print.h" +#include "Config.h" +#include "AudioTools.h" +// #include "AudioTools/Concurrency/RTOS.h" +#include "AudioTools/AudioCodecs/CodecOpus.h" +#include +#include "Audio.h" + +// WEBSOCKET +SemaphoreHandle_t wsMutex; +WebSocketsClient webSocket; + +// TASK HANDLES +TaskHandle_t speakerTaskHandle = NULL; +TaskHandle_t micTaskHandle = NULL; +TaskHandle_t networkTaskHandle = NULL; + +// TIMING REGISTERS +bool scheduleListeningRestart = false; +unsigned long scheduledTime = 0; +unsigned long speakingStartTime = 0; + +// AUDIO SETTINGS +int currentVolume = 70; +const int CHANNELS = 1; // Mono +const int BITS_PER_SAMPLE = 16; // 16-bit audio + +// AUDIO OUTPUT +class BufferPrint : public Print { +public: + BufferPrint(BufferRTOS& buf) : _buffer(buf) {} + + virtual size_t write(uint8_t data) override { + if (webSocket.isConnected() && deviceState == SPEAKING) { + return _buffer.writeArray(&data, 1); + } + return 0; + } + + virtual size_t write(const uint8_t *buffer, size_t size) override { + if (webSocket.isConnected() && deviceState == SPEAKING) { + return _buffer.writeArray(buffer, size); + } + return 0; + } + +private: + BufferRTOS& _buffer; +}; + +BufferPrint bufferPrint(audioBuffer); +OpusAudioDecoder opusDecoder; +BufferRTOS audioBuffer(AUDIO_BUFFER_SIZE, AUDIO_CHUNK_SIZE); +I2SStream i2s; +VolumeStream volume(i2s); +QueueStream queue(audioBuffer); +StreamCopy copier(volume, queue); +AudioInfo info(SAMPLE_RATE, CHANNELS, BITS_PER_SAMPLE); + +unsigned long getSpeakingDuration() { + if (deviceState == SPEAKING && speakingStartTime > 0) { + return millis() - speakingStartTime; + } + return 0; +} + +void transitionToSpeaking() { + vTaskDelay(50); + + i2sInput.flush(); + + if (xSemaphoreTake(wsMutex, pdMS_TO_TICKS(100)) == pdTRUE) { + deviceState = SPEAKING; + digitalWrite(10, HIGH); + speakingStartTime = millis(); + + webSocket.enableHeartbeat(30000, 15000, 3); + xSemaphoreGive(wsMutex); + } + + Serial.println("Transitioned to speaking mode"); +} + +void transitionToListening() { + deviceState = PROCESSING; + scheduleListeningRestart = false; + Serial.println("Transitioning to listening mode"); + + // These stream operations don't directly interact with the WebSocket + i2s.flush(); + volume.flush(); + queue.flush(); + i2sInput.flush(); + audioBuffer.reset(); + + Serial.println("Transitioned to listening mode"); + + if (xSemaphoreTake(wsMutex, pdMS_TO_TICKS(100)) == pdTRUE) { + deviceState = LISTENING; + digitalWrite(10, LOW); + webSocket.disableHeartbeat(); + xSemaphoreGive(wsMutex); + } +} + +void audioStreamTask(void *parameter) { + Serial.println("Starting I2S stream pipeline..."); + + pinMode(10, OUTPUT); + + OpusSettings cfg; + cfg.sample_rate = SAMPLE_RATE; + cfg.channels = CHANNELS; + cfg.bits_per_sample = BITS_PER_SAMPLE; + cfg.max_buffer_size = 6144; + opusDecoder.setOutput(bufferPrint); + opusDecoder.begin(cfg); + + queue.begin(); + + auto config = i2s.defaultConfig(TX_MODE); + config.bits_per_sample = BITS_PER_SAMPLE; + config.sample_rate = SAMPLE_RATE; + config.channels = CHANNELS; + config.pin_bck = I2S_BCK_OUT; + config.pin_ws = I2S_WS_OUT; + config.pin_data = I2S_DATA_OUT; + config.port_no = I2S_PORT_OUT; + + config.copyFrom(info); + i2s.begin(config); + + auto vcfg = volume.defaultConfig(); + vcfg.copyFrom(config); + vcfg.allow_boost = true; + volume.begin(vcfg); + + while (1) { + if (webSocket.isConnected() && deviceState == SPEAKING) { + copier.copy(); + } + vTaskDelay(1); + } +} + + +// AUDIO INPUT SETTINGS +class WebsocketStream : public Print { +public: + virtual size_t write(uint8_t b) override { + if (!webSocket.isConnected() || deviceState != LISTENING) { + return 1; + } + + if (xSemaphoreTake(wsMutex, pdMS_TO_TICKS(100)) == pdTRUE) { + webSocket.sendBIN(&b, 1); + xSemaphoreGive(wsMutex); + return 1; + } + + return 1; + } + + virtual size_t write(const uint8_t *buffer, size_t size) override { + if (size == 0 || !webSocket.isConnected() || deviceState != LISTENING) { + return size; + } + + + if (xSemaphoreTake(wsMutex, pdMS_TO_TICKS(100)) == pdTRUE) { + webSocket.sendBIN(buffer, size); + xSemaphoreGive(wsMutex); + return size; + } + + return size; + } +}; + +WebsocketStream wsStream; +I2SStream i2sInput; +StreamCopy micToWsCopier(wsStream, i2sInput); +const int MIC_COPY_SIZE = 64; + +void micTask1(void *parameter) { + auto i2sConfig = i2sInput.defaultConfig(RX_MODE); + i2sConfig.bits_per_sample = BITS_PER_SAMPLE; + i2sConfig.sample_rate = SAMPLE_RATE; + i2sConfig.channels = CHANNELS; + i2sConfig.i2s_format = I2S_LEFT_JUSTIFIED_FORMAT; + i2sConfig.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT; + + i2sConfig.pin_bck = I2S_SCK; + i2sConfig.pin_ws = I2S_WS; + i2sConfig.pin_data = I2S_SD; + i2sConfig.port_no = I2S_PORT_IN; + i2sInput.begin(i2sConfig); + + while (1) { + if (scheduleListeningRestart && millis() >= scheduledTime) { + transitionToListening(); + } + + if (deviceState == LISTENING && webSocket.isConnected()) { + micToWsCopier.copyBytes(MIC_COPY_SIZE); + } + + vTaskDelay(1); + } +} + +void micTask(void *parameter) { + // start I2S input stream. + auto i2sConfig = i2sInput.defaultConfig(RX_MODE); + i2sConfig.bits_per_sample = BITS_PER_SAMPLE; + i2sConfig.sample_rate = SAMPLE_RATE; + i2sConfig.channels = CHANNELS; + i2sConfig.i2s_format = I2S_LEFT_JUSTIFIED_FORMAT; + i2sConfig.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT; + + // I2S input pins a + i2sConfig.pin_bck = I2S_SCK; + i2sConfig.pin_ws = I2S_WS; + i2sConfig.pin_data = I2S_SD; + i2sConfig.port_no = I2S_PORT_IN; + i2sInput.begin(i2sConfig); + + while (1) { + // Checking to see if a transition to listening mode is scheduled. + if (scheduleListeningRestart && millis() >= scheduledTime) { + transitionToListening(); + } + + if (deviceState == LISTENING && webSocket.isConnected()) { + // smaller chunk size to avoid blocking too long + micToWsCopier.copyBytes(MIC_COPY_SIZE); + + // yielding frequently + vTaskDelay(1); + } else { + vTaskDelay(10); + } + } +} + +// WEBSOCKET EVENTS +void webSocketEvent(WStype_t type, uint8_t *payload, size_t length) +{ + switch (type) + { + case WStype_DISCONNECTED: + Serial.printf("[WSc] Disconnected!\n"); + deviceState = IDLE; + break; + case WStype_CONNECTED: + Serial.printf("[WSc] Connected to url: %s\n", payload); + deviceState = PROCESSING; + break; + case WStype_TEXT: + { + Serial.printf("[WSc] get text: %s\n", payload); + + JsonDocument doc; + DeserializationError error = deserializeJson(doc, (char *)payload); + + if (error) + { + Serial.println("Error deserializing JSON"); + deviceState = IDLE; + return; + } + + String type = doc["type"]; + + // auth messages + if (strcmp((char*)type.c_str(), "auth") == 0) { + currentVolume = doc["volume_control"].as(); + bool is_ota = doc["is_ota"].as(); + bool is_reset = doc["is_reset"].as(); + volume.setVolume(currentVolume / 100.0f); // Setting initial volume (e.g., 70/100 = 0.7) + + if (is_ota) { + Serial.println("OTA update received"); + setOTAStatusInNVS(OTA_IN_PROGRESS); + ESP.restart(); + } + + if (is_reset) { + Serial.println("Factory reset received"); + // setFactoryResetStatusInNVS(true); + ESP.restart(); + } + } + + // oai messages + if (strcmp((char*)type.c_str(), "server") == 0) { + String msg = doc["msg"]; + Serial.println(msg); + + if (strcmp((char*)msg.c_str(), "RESPONSE.COMPLETE") == 0 || strcmp((char*)msg.c_str(), "RESPONSE.ERROR") == 0) { + Serial.println("Received RESPONSE.COMPLETE or RESPONSE.ERROR, starting listening again"); + + // Check if volume_control is included in the message + if (doc.containsKey("volume_control")) { + int newVolume = doc["volume_control"].as(); + volume.setVolume(newVolume / 100.0f); + } + + scheduleListeningRestart = true; + scheduledTime = millis() + 1000; // 1 second delay + } else if (strcmp((char*)msg.c_str(), "AUDIO.COMMITTED") == 0) { + deviceState = PROCESSING; + } else if (strcmp((char*)msg.c_str(), "RESPONSE.CREATED") == 0) { + Serial.println("Received RESPONSE.CREATED, transitioning to speaking"); + transitionToSpeaking(); + } + } + } + break; + case WStype_BIN: + { + if (scheduleListeningRestart || deviceState != SPEAKING) { + Serial.println("Skipping audio data due to touch interrupt."); + break; + } + + // Otherwise process the audio data normally + size_t processed = opusDecoder.write(payload, length); + if (processed != length) { + Serial.printf("Warning: Only processed %d/%d bytes\n", processed, length); + } + break; + } + case WStype_ERROR: + Serial.printf("[WSc] Error: %s\n", payload); + break; + case WStype_FRAGMENT_TEXT_START: + case WStype_FRAGMENT_BIN_START: + case WStype_FRAGMENT: + case WStype_PONG: + case WStype_PING: + case WStype_FRAGMENT_FIN: + break; + } +} + +void websocketSetup(String server_domain, int port, String path) +{ + String headers = "Authorization: Bearer " + String(authTokenGlobal); + webSocket.setExtraHeaders(headers.c_str()); + webSocket.onEvent(webSocketEvent); + webSocket.setReconnectInterval(1000); + + webSocket.enableHeartbeat(30000, 15000, 3); // 30s ping interval, 15s timeout, 3 retries + + #ifdef DEV_MODE + webSocket.begin(server_domain.c_str(), port, path.c_str()); + #else + webSocket.beginSslWithCA(server_domain.c_str(), port, path.c_str(), CA_cert); + #endif +} + +void networkTask(void *parameter) { + while (1) { + webSocket.loop(); + vTaskDelay(1); + } +} + diff --git a/firmware-cpp/src/Audio.h b/firmware-cpp/src/Audio.h new file mode 100644 index 0000000..ea7f8f8 --- /dev/null +++ b/firmware-cpp/src/Audio.h @@ -0,0 +1,49 @@ + +#include "Print.h" +#include "Config.h" +#include "AudioTools.h" +// #include "AudioTools/Concurrency/RTOS.h" +#include "AudioTools/AudioCodecs/CodecOpus.h" +#include + +extern SemaphoreHandle_t wsMutex; +extern WebSocketsClient webSocket; + +extern TaskHandle_t speakerTaskHandle; +extern TaskHandle_t micTaskHandle; +extern TaskHandle_t networkTaskHandle; + +extern bool scheduleListeningRestart; +extern unsigned long scheduledTime; +extern unsigned long speakingStartTime; + +extern int currentVolume; +extern const int CHANNELS; // Mono +extern const int BITS_PER_SAMPLE; // 16-bit audio + +// AUDIO OUTPUT +constexpr size_t AUDIO_BUFFER_SIZE = 1024 * 10; // total bytes in the buffer +constexpr size_t AUDIO_CHUNK_SIZE = 1024; // ideal read/write chunk size +extern OpusAudioDecoder opusDecoder; +extern BufferRTOS audioBuffer; +extern I2SStream i2s; +extern VolumeStream volume; +extern QueueStream queue; +extern StreamCopy copier; +extern AudioInfo info; + +// AUDIO INPUT +extern I2SStream i2sInput; +extern StreamCopy micToWsCopier; + +// WEBSOCKET +void webSocketEvent(WStype_t type, uint8_t *payload, size_t length); +void websocketSetup(String server_domain, int port, String path); +void networkTask(void *parameter); + +// AUDIO OUTPUT +unsigned long getSpeakingDuration(); +void audioStreamTask(void *parameter); + +// AUDIO INPUT +void micTask(void *parameter); diff --git a/firmware-cpp/src/Config.cpp b/firmware-cpp/src/Config.cpp new file mode 100644 index 0000000..838f8b7 --- /dev/null +++ b/firmware-cpp/src/Config.cpp @@ -0,0 +1,82 @@ +#include "Config.h" +#include + +// ! define preferences +Preferences preferences; +OtaStatus otaState = OTA_IDLE; +bool factory_reset_status = false; + +// websocket_setup("192.168.1.166", 8000, "/"); +// websocket_setup("talkedge.deno.dev",443, "/"); +// websocket_setup("xygbupeczfhwamhqnucy.supabase.co", 443, "/functions/v1/relay"); +// websocket_setup("https://emkmtesvjrqhvx2mo2mxslvmmy0zsuhq.lambda-url.us-east-1.on.aws/", 8000, "/"); +// Runtime WebSocket server details + +#ifdef DEV_MODE +const char *ws_server = "10.2.1.25"; +const uint16_t ws_port = 8000; +const char *ws_path = "/"; +// Backend server details +const char *backend_server = "10.2.1.25"; +const uint16_t backend_port = 3000; + +#else +// PROD +const char *ws_server = "talkedge.deno.dev"; +const uint16_t ws_port = 443; +const char *ws_path = "/"; +// Backend server details +const char *backend_server = "www.elatoai.com"; +const uint16_t backend_port = 3000; + +#endif + +String authTokenGlobal; +DeviceState deviceState = IDLE; + +// I2S and Audio parameters +const uint32_t SAMPLE_RATE = 24000; + +// ----------------- Pin Definitions ----------------- +const i2s_port_t I2S_PORT_IN = I2S_NUM_1; +const i2s_port_t I2S_PORT_OUT = I2S_NUM_0; + +#ifdef USE_NORMAL_ESP32 + +const int BLUE_LED_PIN = 13; +const int RED_LED_PIN = 9; +const int GREEN_LED_PIN = 8; + +const int I2S_SD = 14; +const int I2S_WS = 4; +const int I2S_SCK = 1; + +const int I2S_WS_OUT = 5; +const int I2S_BCK_OUT = 6; +const int I2S_DATA_OUT = 7; +const int I2S_SD_OUT = 10; + +const gpio_num_t BUTTON_PIN = GPIO_NUM_2; // Only RTC IO are allowed - ESP32 Pin example + +#endif + +// supabase CA cert +// const char *CA_cert = R"EOF( +// -----BEGIN CERTIFICATE----- +// +// -----END CERTIFICATE----- +// )EOF"; + +const char *Vercel_CA_cert = R"EOF( +-----BEGIN CERTIFICATE----- + +-----END CERTIFICATE----- +)EOF"; + + +// talkedge.deno.dev CA cert +const char *CA_cert = R"EOF( +-----BEGIN CERTIFICATE----- + +-----END CERTIFICATE----- +)EOF"; diff --git a/firmware-cpp/src/Config.h b/firmware-cpp/src/Config.h new file mode 100644 index 0000000..52384eb --- /dev/null +++ b/firmware-cpp/src/Config.h @@ -0,0 +1,91 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#include +#include +#include +#include +#include +#include +#include + +extern Preferences preferences; +extern bool factory_reset_status; + +enum OtaStatus { + OTA_IDLE, + OTA_IN_PROGRESS, + OTA_COMPLETE +}; + +extern OtaStatus otaState; + +enum DeviceState +{ + SETUP, + IDLE, + LISTENING, + SPEAKING, + PROCESSING, + WAITING, + OTA, + FACTORY_RESET +}; + +extern DeviceState deviceState; + +// WiFi credentials +extern const char *EAP_IDENTITY; +extern const char *EAP_USERNAME; +extern const char *EAP_PASSWORD; +extern const char *ssid; + +extern const char *ssid_peronal; +extern const char *password_personal; + +extern String authTokenGlobal; + +// WebSocket server details +extern const char *ws_server; +extern const uint16_t ws_port; +extern const char *ws_path; + +// Backend server details +extern const char *backend_server; +extern const uint16_t backend_port; + +// I2S and Audio parameters +extern const uint32_t SAMPLE_RATE; + +// ---------- Development ------------ +// #define DEV_MODE +#define TOUCH_MODE + +// ----------------- Pin Definitions ----------------- +#define USE_NORMAL_ESP32 + +extern const int BLUE_LED_PIN; +extern const int RED_LED_PIN; +extern const int GREEN_LED_PIN; + +extern const gpio_num_t BUTTON_PIN; + +// I2S Microphone pins +extern const int I2S_SD; +extern const int I2S_WS; +extern const int I2S_SCK; +extern const i2s_port_t I2S_PORT_IN; + +// I2S Speaker pins +extern const int I2S_WS_OUT; +extern const int I2S_BCK_OUT; +extern const int I2S_DATA_OUT; +extern const i2s_port_t I2S_PORT_OUT; +extern const int I2S_SD_OUT; + +// SSL certificate +extern const char *CA_cert; +extern const char *Vercel_CA_cert; +void factoryResetDevice(); + +#endif \ No newline at end of file diff --git a/firmware-cpp/src/FactoryReset.h b/firmware-cpp/src/FactoryReset.h new file mode 100644 index 0000000..7f4ce3d --- /dev/null +++ b/firmware-cpp/src/FactoryReset.h @@ -0,0 +1,80 @@ +#include +#include +#include //https://github.com/me-no-dev/ESPAsyncWebServer using the latest dev version from @me-no-dev + +void setResetComplete() { + HTTPClient http; + WiFiClientSecure client; + client.setCACert(Vercel_CA_cert); // Using the existing server certificate + + // Construct JSON payload + JsonDocument doc; + doc["authToken"] = authTokenGlobal; + + String jsonString; + serializeJson(doc, jsonString); + + // Initialize HTTPS connection with client + #ifdef DEV_MODE + http.begin("http://" + String(backend_server) + ":" + String(backend_port) + "/api/factory_reset_handler"); + #else + http.begin(client, "https://" + String(backend_server) + "/api/factory_reset_handler"); + #endif + + http.addHeader("Content-Type", "application/json"); + http.setTimeout(10000); // Add timeout for reliability + + // Make the POST request + int httpCode = http.POST(jsonString); + + // ... existing code ... + if (httpCode > 0) { + if (httpCode == HTTP_CODE_OK) { + Serial.println("Factory reset status updated successfully"); + } else { + Serial.printf("Factory reset status update failed with code: %d\n", httpCode); + } + } else { + Serial.printf("HTTP request failed: %s\n", http.errorToString(httpCode).c_str()); + } + + http.end(); + + // Clear NVS + factoryResetDevice(); + +} + +// TODO(@akdeb): Update this to use `false` as default +void getFactoryResetStatusFromNVS() +{ + preferences.begin("is_reset", false); + factory_reset_status = preferences.getBool("is_reset", false); + preferences.end(); +} + +void setFactoryResetStatusInNVS(bool status) +{ + preferences.begin("is_reset", false); + preferences.putBool("is_reset", status); + preferences.end(); + factory_reset_status = status; +} + +void factoryResetDevice() { + Serial.println("Factory reset device"); + + // Erase the NVS partition + esp_err_t err = nvs_flash_erase(); + if (err != ESP_OK) { + Serial.printf("Error erasing NVS: %d\n", err); + return; + } + + // Reinitialize NVS + err = nvs_flash_init(); + if (err != ESP_OK) { + Serial.printf("Error initializing NVS: %d\n", err); + return; + } + } \ No newline at end of file diff --git a/firmware-cpp/src/LEDHandler.cpp b/firmware-cpp/src/LEDHandler.cpp new file mode 100644 index 0000000..9a0faf3 --- /dev/null +++ b/firmware-cpp/src/LEDHandler.cpp @@ -0,0 +1,332 @@ +#include "LEDHandler.h" + +int brightness = 0; +int fadeAmount = 5; +static unsigned long lastToggle = 0; +static bool ledState = false; + +void setLEDColor(uint8_t r, uint8_t g, uint8_t b) +{ + analogWrite(RED_LED_PIN, r); + analogWrite(GREEN_LED_PIN, g); + analogWrite(BLUE_LED_PIN, b); +} + +enum class StaticColor +{ + RED, + GREEN, + BLUE, + YELLOW, + MAGENTA, + CYAN, +}; + +void setStaticColor(StaticColor color) +{ + switch (color) + { + case StaticColor::RED: + digitalWrite(RED_LED_PIN, LOW); + digitalWrite(GREEN_LED_PIN, HIGH); + digitalWrite(BLUE_LED_PIN, HIGH); + break; + case StaticColor::GREEN: + digitalWrite(RED_LED_PIN, HIGH); + digitalWrite(GREEN_LED_PIN, LOW); + digitalWrite(BLUE_LED_PIN, HIGH); + break; + case StaticColor::BLUE: + digitalWrite(RED_LED_PIN, HIGH); + digitalWrite(GREEN_LED_PIN, HIGH); + digitalWrite(BLUE_LED_PIN, LOW); + break; + case StaticColor::YELLOW: + digitalWrite(RED_LED_PIN, LOW); + digitalWrite(GREEN_LED_PIN, LOW); + digitalWrite(BLUE_LED_PIN, HIGH); + break; + case StaticColor::MAGENTA: + digitalWrite(RED_LED_PIN, LOW); + digitalWrite(GREEN_LED_PIN, HIGH); + digitalWrite(BLUE_LED_PIN, LOW); + break; + case StaticColor::CYAN: + digitalWrite(RED_LED_PIN, HIGH); + digitalWrite(GREEN_LED_PIN, LOW); + digitalWrite(BLUE_LED_PIN, LOW); + break; + } +} + +void loopCyanPinkYellow() +{ + // Cyan (Green + Blue) + digitalWrite(RED_LED_PIN, LOW); + digitalWrite(GREEN_LED_PIN, HIGH); + digitalWrite(BLUE_LED_PIN, HIGH); + delay(500); + + // Pink (Red + Blue) + digitalWrite(RED_LED_PIN, HIGH); + digitalWrite(GREEN_LED_PIN, LOW); + digitalWrite(BLUE_LED_PIN, HIGH); + delay(500); + + // Yellow (Red + Green) + digitalWrite(RED_LED_PIN, HIGH); + digitalWrite(GREEN_LED_PIN, HIGH); + digitalWrite(BLUE_LED_PIN, LOW); + delay(500); +} + +void pulseWhite() +{ + setLEDColor(brightness, brightness, brightness); + brightness += fadeAmount; + if (brightness <= 0 || brightness >= 255) // Changed from 255 to 128 + { + fadeAmount = -fadeAmount; + } +} + +void pulseMagenta() +{ + setLEDColor(brightness, 0, brightness); + brightness += fadeAmount; + if (brightness <= 0 || brightness >= 255) // Changed from 255 to 128 + { + fadeAmount = -fadeAmount; + } +} + +void pulseYellow() +{ + setLEDColor(brightness, brightness, 0); + brightness += fadeAmount; + if (brightness <= 0 || brightness >= 255) // Changed from 255 to 128 + { + fadeAmount = -fadeAmount; + } +} + +void pulseBlue() +{ + setLEDColor(0, 0, brightness); + brightness += fadeAmount; + if (brightness <= 0 || brightness >= 255) // Changed from 255 to 128 + { + fadeAmount = -fadeAmount; + } +} + +void blinkWhite() +{ + if (ledState) + { + digitalWrite(RED_LED_PIN, HIGH); + digitalWrite(GREEN_LED_PIN, HIGH); + digitalWrite(BLUE_LED_PIN, HIGH); + } + else + { + digitalWrite(RED_LED_PIN, LOW); + digitalWrite(GREEN_LED_PIN, LOW); + digitalWrite(BLUE_LED_PIN, LOW); + } +} + +void blinkGreen() +{ + digitalWrite(BLUE_LED_PIN, LOW); + digitalWrite(RED_LED_PIN, LOW); + if (ledState) + { + digitalWrite(GREEN_LED_PIN, HIGH); + } + else + { + digitalWrite(GREEN_LED_PIN, LOW); + } +} + +void blinkYellow() +{ + digitalWrite(BLUE_LED_PIN, LOW); + if (ledState) + { + digitalWrite(RED_LED_PIN, HIGH); + digitalWrite(GREEN_LED_PIN, HIGH); + } + else + { + digitalWrite(RED_LED_PIN, LOW); + digitalWrite(GREEN_LED_PIN, LOW); + } +} + +void turnOffLED() +{ + digitalWrite(RED_LED_PIN, LOW); + digitalWrite(GREEN_LED_PIN, LOW); + digitalWrite(BLUE_LED_PIN, LOW); +} + +void turnOnLED() +{ + digitalWrite(RED_LED_PIN, HIGH); + digitalWrite(GREEN_LED_PIN, HIGH); + digitalWrite(BLUE_LED_PIN, HIGH); +} + +void setupRGBLED() +{ + pinMode(RED_LED_PIN, OUTPUT); + pinMode(GREEN_LED_PIN, OUTPUT); + pinMode(BLUE_LED_PIN, OUTPUT); + turnOffLED(); // Turn off the LED initially +} + +void blinkCyanPulse() +{ + analogWrite(GREEN_LED_PIN, brightness); + analogWrite(BLUE_LED_PIN, brightness); + + brightness += fadeAmount; + if (brightness <= 0 || brightness >= 255) + { + fadeAmount = -fadeAmount; + } +} + +const uint8_t colorSequence[][3] = { + {0, 255, 255}, // Cyan (R=0, G=255, B=255) + {255, 0, 255}, // Pink (R=255, G=0, B=255) + {255, 255, 0}, // Yellow (R=255, G=255, B=0) +}; +const int NUM_COLORS = sizeof(colorSequence) / sizeof(colorSequence[0]); + +void loopCyanPinkYellowPulse(unsigned long currentTime) +{ + // Duration of each color fade + const unsigned long transitionDuration = 1000; // 500 ms per fade + + // colorIndex = which color in colorSequence we’re currently *starting* from + static int colorIndex = 0; + + // We'll store the "start color" and "end color" for the current fade + static uint8_t startColor[3]; + static uint8_t endColor[3]; + + // The timestamp at which the current fade *started* + static unsigned long transitionStartTime = 0; + + // A flag so we can initialize the first fade + static bool initialized = false; + + if (!initialized) + { + // On the very first call, set the starting color to colorSequence[0] + // and the endColor to the next color in the array + memcpy(startColor, colorSequence[colorIndex], 3); + int nextIndex = (colorIndex + 1) % NUM_COLORS; + memcpy(endColor, colorSequence[nextIndex], 3); + + transitionStartTime = currentTime; + initialized = true; + } + + // How long has this transition been running? + unsigned long elapsed = currentTime - transitionStartTime; + float t = (float)elapsed / (float)transitionDuration; + if (t > 1.0f) + { + t = 1.0f; // clamp + } + + // Interpolate each channel: R, G, B + uint8_t r = startColor[0] + (endColor[0] - startColor[0]) * t; + uint8_t g = startColor[1] + (endColor[1] - startColor[1]) * t; + uint8_t b = startColor[2] + (endColor[2] - startColor[2]) * t; + + // Write these values to your LED pins + analogWrite(RED_LED_PIN, r); + analogWrite(GREEN_LED_PIN, g); + analogWrite(BLUE_LED_PIN, b); + + // Check if this transition has finished + if (elapsed >= transitionDuration) + { + // Move to next color in the sequence + colorIndex = (colorIndex + 1) % NUM_COLORS; + memcpy(startColor, endColor, 3); // old 'end' becomes new 'start' + + int nextIndex = (colorIndex + 1) % NUM_COLORS; + memcpy(endColor, colorSequence[nextIndex], 3); + + transitionStartTime = currentTime; // reset the clock for the next fade + } +} + +void blinkBlue() +{ + digitalWrite(GREEN_LED_PIN, LOW); + digitalWrite(RED_LED_PIN, LOW); + if (ledState) + { + digitalWrite(BLUE_LED_PIN, HIGH); + } + else + { + digitalWrite(BLUE_LED_PIN, LOW); + } +} + +void staticYellow() +{ + digitalWrite(BLUE_LED_PIN, LOW); + digitalWrite(RED_LED_PIN, HIGH); + digitalWrite(GREEN_LED_PIN, HIGH); +} + +void ledTask(void *parameter) +{ + setupRGBLED(); + unsigned long currentTime = 0; + while (1) + { + currentTime += 20; // Track time based on vTaskDelay + + // Toggle LED state every 200ms for blinking functions + if (currentTime - lastToggle >= 200) + { + ledState = !ledState; + lastToggle = currentTime; + } + + switch (deviceState) + { + case IDLE: + setStaticColor(StaticColor::GREEN); + break; + case PROCESSING: + setStaticColor(StaticColor::RED); + break; + case SPEAKING: + setStaticColor(StaticColor::BLUE); + break; + case LISTENING: + setStaticColor(StaticColor::YELLOW); + break; + case OTA: + setStaticColor(StaticColor::CYAN); + break; + default: + setStaticColor(StaticColor::GREEN); // LED on + break; + } + + // Delay for smoother LED transitions + vTaskDelay(20 / portTICK_PERIOD_MS); // Approximate the delay from the original `pulsateLED()` + } +} \ No newline at end of file diff --git a/firmware-cpp/src/LEDHandler.h b/firmware-cpp/src/LEDHandler.h new file mode 100644 index 0000000..6070151 --- /dev/null +++ b/firmware-cpp/src/LEDHandler.h @@ -0,0 +1,14 @@ +#ifndef LEDHANDLER_H +#define LEDHANDLER_H + +#include "Config.h" + +void setLEDColor(uint8_t r, uint8_t g, uint8_t b); +void turnOffLED(); +void turnOnLED(); +void setupRGBLED(); +void turnOnBlueLED(); +void turnOnRedLEDFlash(); +void ledTask(void *parameter); + +#endif \ No newline at end of file diff --git a/firmware-cpp/src/OTA.cpp b/firmware-cpp/src/OTA.cpp new file mode 100644 index 0000000..e121885 --- /dev/null +++ b/firmware-cpp/src/OTA.cpp @@ -0,0 +1,127 @@ +#include "OTA.h" +#include "HttpsOTAUpdate.h" +#include "esp_ota_ops.h" + +HttpsOTAStatus_t otastatus; + +// OTA firmware url +#ifdef TOUCH_MODE +const char *ota_firmware_url = "https://elato.s3.us-east-1.amazonaws.com/firmware-touch.bin"; +#else +const char *ota_firmware_url = "https://elato.s3.us-east-1.amazonaws.com/firmware-button.bin"; +#endif + +const char *server_certificate = R"EOF( +-----BEGIN CERTIFICATE----- + + + +-----END CERTIFICATE----- +)EOF"; + +void markOTAUpdateComplete() { + HTTPClient http; + WiFiClientSecure client; + client.setCACert(Vercel_CA_cert); // Using the existing server certificate + + // Construct the JSON payload + JsonDocument doc; + doc["authToken"] = authTokenGlobal; + + String jsonString; + serializeJson(doc, jsonString); + + // Initialize HTTPS connection with client + #ifdef DEV_MODE + http.begin("http://" + String(backend_server) + ":" + String(backend_port) + "/api/ota_update_handler"); + #else + http.begin(client, "https://" + String(backend_server) + "/api/ota_update_handler"); + #endif + + http.addHeader("Content-Type", "application/json"); + http.setTimeout(10000); // Add timeout for reliability + + // Make the POST request + int httpCode = http.POST(jsonString); + + // ... existing code ... + if (httpCode > 0) { + if (httpCode == HTTP_CODE_OK) { + Serial.println("OTA status updated successfully"); + setOTAStatusInNVS(OTA_IDLE); + } else { + Serial.printf("OTA status update failed with code: %d\n", httpCode); + } + } else { + Serial.printf("HTTP request failed: %s\n", http.errorToString(httpCode).c_str()); + } + + http.end(); +} + +void getOTAStatusFromNVS() +{ + preferences.begin("ota", false); + otaState = (OtaStatus)preferences.getUInt("status", OTA_IDLE); + preferences.end(); +} + +void setOTAStatusInNVS(OtaStatus status) +{ + preferences.begin("ota", false); + preferences.putUInt("status", status); + preferences.end(); + otaState = status; +} + +void loopOTA() +{ + otastatus = HttpsOTA.status(); + if (otastatus == HTTPS_OTA_SUCCESS) + { + Serial.println("Firmware written successfully. To reboot device, call API ESP.restart() or PUSH restart button on device"); + setOTAStatusInNVS(OTA_COMPLETE); + ESP.restart(); + } + else if (otastatus == HTTPS_OTA_FAIL) + { + Serial.println("Firmware Upgrade Fail"); + setOTAStatusInNVS(OTA_IN_PROGRESS); + ESP.restart(); + } +} + +void HttpEvent(HttpEvent_t *event) +{ + switch (event->event_id) + { + case HTTP_EVENT_ERROR: + // Serial.println("Http Event Error"); + break; + case HTTP_EVENT_ON_CONNECTED: + // Serial.println("Http Event On Connected"); + break; + case HTTP_EVENT_HEADER_SENT: + // Serial.println("Http Event Header Sent"); + break; + case HTTP_EVENT_ON_HEADER: + // Serial.printf("Http Event On Header, key=%s, value=%s\n", event->header_key, event->header_value); + break; + case HTTP_EVENT_ON_DATA: + break; + case HTTP_EVENT_ON_FINISH: + // Serial.println("Http Event On Finish"); + break; + case HTTP_EVENT_DISCONNECTED: + // Serial.println("Http Event Disconnected"); + break; + } +} + +void performOTAUpdate() +{ + Serial.println("Starting OTA Update..."); + HttpsOTA.onHttpEvent(HttpEvent); + HttpsOTA.begin(ota_firmware_url, server_certificate); +} + diff --git a/firmware-cpp/src/OTA.h b/firmware-cpp/src/OTA.h new file mode 100644 index 0000000..1445e79 --- /dev/null +++ b/firmware-cpp/src/OTA.h @@ -0,0 +1,13 @@ +#ifndef OTA_H +#define OTA_H +#include "Config.h" + +extern const char *server_certificate; +extern const char *ota_firmware_url; +void performOTAUpdate(); +void markOTAUpdateComplete(); +void loopOTA(); +void setOTAStatusInNVS(OtaStatus status); +void getOTAStatusFromNVS(); + +#endif \ No newline at end of file diff --git a/firmware-cpp/src/WifiManager.cpp b/firmware-cpp/src/WifiManager.cpp new file mode 100644 index 0000000..fb4bef0 --- /dev/null +++ b/firmware-cpp/src/WifiManager.cpp @@ -0,0 +1,1246 @@ +/** + * Wifi Manager + * (c) 2022-2024 Martin Verges + * + * Licensed under CC BY-NC-SA 4.0 + * (Attribution-NonCommercial-ShareAlike 4.0 International) +**/ +#include "WifiManager.h" +#include "OTA.h" +#include "AsyncJson.h" +#include "ArduinoJson.h" +#include +#include +#include + +bool isDeviceRegistered() { + if (!authTokenGlobal.isEmpty()) { + return true; + } + + HTTPClient http; + WiFiClientSecure client; + client.setCACert(Vercel_CA_cert); + + #ifdef DEV_MODE + http.begin("http://" + String(backend_server) + ":" + String(backend_port) + + "/api/generate_auth_token?macAddress=" + WiFi.macAddress()); + #else + http.begin(client, "https://" + String(backend_server) + + "/api/generate_auth_token?macAddress=" + WiFi.macAddress()); + #endif + + http.setTimeout(10000); + + int httpCode = http.GET(); + + if (httpCode == HTTP_CODE_OK) { + String payload = http.getString(); + JsonDocument doc; + DeserializationError error = deserializeJson(doc, payload); + + if (error) { + Serial.print("JSON parsing failed: "); + Serial.println(error.c_str()); + http.end(); + return false; + } + + String authToken = doc["token"]; + if (!authToken.isEmpty()) { + // Store the auth token in NVS + preferences.begin("auth", false); + preferences.putString("auth_token", authToken); + preferences.end(); + + authTokenGlobal = String(authToken); + http.end(); + return true; + } + } + + // If we get here, either the request failed or no token was found + http.end(); + return false; +} + +void connectCb() { + Serial.println("On connecting to Wifi"); + if (isDeviceRegistered()) { + if (otaState == OTA_IN_PROGRESS) { + performOTAUpdate(); + } else if (otaState == OTA_COMPLETE) { + markOTAUpdateComplete(); + ESP.restart(); + } else { + websocketSetup(ws_server, ws_port, ws_path); + } + } +} + +/** + * @brief Write a message to the Serial interface + * @param msg The message to be written + * + * This function is a simple wrapper around Serial.print() to write a message + * to the serial console. It can be overwritten by a custom implementation for + * enhanced logging. + */ +void WIFIMANAGER::logMessage(String msg) { + Serial.print(msg); +} + +/** + * @brief Background Task running as a loop forever + * @param param needs to be a valid WIFIMANAGER instance + */ +void wifiTask(void* param) { + yield(); + delay(500); // wait a short time until everything is setup before executing the loop forever + yield(); + const TickType_t xDelay = 10000 / portTICK_PERIOD_MS; + WIFIMANAGER * wifimanager = (WIFIMANAGER *) param; + + for(;;) { + yield(); + wifimanager->loop(); + yield(); + vTaskDelay(xDelay); + } +} + +/** + * @brief Start the background task, which will take care of the Wifi connection + * + * This method will load the configuration from NVS, try to connect to the configured WIFI(s) + * and then start a background task, which will keep monitoring and trying to reconnect + * to the configured WIFI(s) in case the connection drops. + */ +void WIFIMANAGER::startBackgroundTask(String softApName, String softApPass) { + if (softApName.length()) this->softApName = softApName; + if (softApPass.length()) this->softApPass = softApPass; + + WiFi.setSleep(false); + loadFromNVS(); + tryConnect(); + + BaseType_t taskCreated = xTaskCreatePinnedToCore( + wifiTask, + "WifiManager", + 4096, // Stack size in words + this, // Task input parameter + 1, // Priority of the task + &WifiCheckTask, // Task handle. + 0 // Core where the task should run + ); + + if (taskCreated != pdPASS) { + logMessage("[ERROR] WifiManager: Error creating background task\n"); + } +} + +/** + * @brief Construct a new WIFIMANAGER::WIFIMANAGER object + * @details Puts the Wifi mode to AP+STA and registers Wifi Events + * @param ns Namespace for the preferences non volatile storage (NVS) + */ +WIFIMANAGER::WIFIMANAGER(const char * ns) { + NVS = (char *)ns; + + // AP on/off + WiFi.onEvent([&](WiFiEvent_t event, WiFiEventInfo_t info) { + logMessage("[WIFI] onEvent() AP mode started!\n"); + softApRunning = true; +#if ESP_ARDUINO_VERSION_MAJOR >= 2 + }, ARDUINO_EVENT_WIFI_AP_START); // arduino-esp32 2.0.0 and later +#else + }, SYSTEM_EVENT_AP_START); // arduino-esp32 1.0.6 +#endif + WiFi.onEvent([&](WiFiEvent_t event, WiFiEventInfo_t info) { + logMessage("[WIFI] onEvent() AP mode stopped!\n"); + softApRunning = false; +#if ESP_ARDUINO_VERSION_MAJOR >= 2 + }, ARDUINO_EVENT_WIFI_AP_STOP); // arduino-esp32 2.0.0 and later +#else + }, SYSTEM_EVENT_AP_STOP); // arduino-esp32 1.0.6 +#endif + // AP client join/leave + WiFi.onEvent([&](WiFiEvent_t event, WiFiEventInfo_t info) { + logMessage("[WIFI] onEvent() new client connected to softAP!\n"); +#if ESP_ARDUINO_VERSION_MAJOR >= 2 + }, ARDUINO_EVENT_WIFI_AP_STACONNECTED); // arduino-esp32 2.0.0 and later +#else + }, SYSTEM_EVENT_AP_STACONNECTED); // arduino-esp32 1.0.6 +#endif + WiFi.onEvent([&](WiFiEvent_t event, WiFiEventInfo_t info) { + logMessage("[WIFI] onEvent() Client disconnected from softAP!\n"); +#if ESP_ARDUINO_VERSION_MAJOR >= 2 + }, ARDUINO_EVENT_WIFI_AP_STADISCONNECTED); // arduino-esp32 2.0.0 and later +#else + }, SYSTEM_EVENT_AP_STADISCONNECTED); // arduino-esp32 1.0.6 +#endif +} + +/** + * @brief Destroy the WIFIMANAGER::WIFIMANAGER object + * @details will stop the background task as well but not cleanup the AsyncWebserver + */ +WIFIMANAGER::~WIFIMANAGER() { + vTaskDelete(WifiCheckTask); + // FIXME: get rid of the registered Webserver AsyncCallbackWebHandlers +} + +/** + * @brief If no WIFI is available, fallback to create an AP on the ESP32 + * @param state boolean true (create AP) or false (don't create an AP) + */ +void WIFIMANAGER::fallbackToSoftAp(const bool state) { + createFallbackAP = state; +} + +/** + * @brief Get the current configured fallback state + * @return true + * @return false + */ +bool WIFIMANAGER::getFallbackState() { + return createFallbackAP; +} + +/** + * @brief Remove all entries from the current known and configured Wifi list + * @details This only affects memory, not the storage! + * @details If you wan't to persist this, you need to call writeToNVS() + */ +void WIFIMANAGER::clearApList() { + for(uint8_t i=0; i 0) { + sprintf(tmpKey, "apPass%d", i); + String apPass = preferences.getString(tmpKey); + logMessage(String("[WIFI] Load SSID '") + apName + "' to " + String(i+1) + ". slot.\n"); + apList[i].apName = apName; + apList[i].apPass = apPass; + configuredSSIDs++; + } + } + } + preferences.end(); + return true; + } + logMessage("[WIFI] Unable to load data from NVS, giving up...\n"); + return false; +} + +/** + * @brief Write the current in memory configuration to the non volatile storage + * @return true on success + * @return false on error with the NVS + */ +bool WIFIMANAGER::writeToNVS() { + if (!preferences.begin(NVS, false)) { + logMessage("[WIFI] Unable to write data to NVS, giving up..."); + return false; + } + + preferences.clear(); + char tmpKey[10]; + for(uint8_t i = 0; i < WIFIMANAGER_MAX_APS; i++) { + if (apList[i].apName.isEmpty()) continue; + + snprintf(tmpKey, sizeof(tmpKey), "apName%d", i); + preferences.putString(tmpKey, apList[i].apName); + + snprintf(tmpKey, sizeof(tmpKey), "apPass%d", i); + preferences.putString(tmpKey, apList[i].apPass); + } + + preferences.end(); + return true; +} + +/** + * @brief Add a new WIFI SSID to the known credentials list + * @param apName Name of the SSID to connect to + * @param apPass Password (or empty) to connect to the SSID + * @param updateNVS Write the new entry directly to NVS + * @return true on success + * @return false on failure + */ +bool WIFIMANAGER::addWifi(String apName, String apPass, bool updateNVS) { + if(apName.length() < 1 || apName.length() > 31) { + logMessage("[WIFI] No SSID given or ssid too long"); + return false; + } + + if(apPass.length() > 63) { + logMessage("[WIFI] Passphrase too long"); + return false; + } + + for(uint8_t i=0; i 0; +} + +/** + * @brief Provides information about the current configuration state + * @details When at least 1 SSID is configured, the return value will be true, otherwise false + * @return true if one or more SSIDs stored + * @return false if no configuration is available + */ +bool WIFIMANAGER::configAvailable() { + return configuredSSIDs != 0; +} + +/** + * @brief Provides the apList element id of the first configured slot + * @details It's used to speed up connection by getting the first available configuration + * @note only call this function when you have configuredSSIDs > 0, otherwise it will return 0 as well and fail! + * @return uint8_t apList element id + */ +uint8_t WIFIMANAGER::getApEntry() { + for(uint8_t i=0; i timeoutApMillis) { + if (WiFi.softAPgetStationNum() > 0) { + logMessage("[WIFI] SoftAP has " + String(WiFi.softAPgetStationNum()) + " clients connected!\n"); + startApTimeMillis = millis(); // reset timeout as someone is connected + return; + } + logMessage("[WIFI] Running in AP mode but timeout reached. Closing AP!\n"); + stopSoftAP(); + delay(100); + } +} + +/** + * @brief Try to connect to one of the configured SSIDs (if available). + * @details If more than 2 SSIDs configured, scan for available WIFIs and connect to the strongest + * @return true on success + * @return false on error or no configuration + */ +bool WIFIMANAGER::tryConnect() { + if (!configAvailable()) { + logMessage("[WIFI] No SSIDs configured in NVS, unable to connect\n"); + if (createFallbackAP) runSoftAP(); + return false; + } + + if (softApRunning) { + logMessage("[WIFI] Not trying to connect. SoftAP has " + String(WiFi.softAPgetStationNum()) + " clients connected!\n"); + return false; + } + + int choosenAp = INT_MIN; + if (configuredSSIDs == 1) { + // only one configured SSID, skip scanning and try to connect to this specific one. + choosenAp = getApEntry(); + } else { + WiFi.mode(WIFI_STA); + int8_t scanResult = WiFi.scanNetworks(false, true); + if(scanResult <= 0) { + logMessage("[WIFI] Unable to find WIFI networks in range to this device!\n"); + return false; + } + logMessage(String("[WIFI] Found ") + String(scanResult) + " networks in range\n"); + int choosenRssi = INT_MIN; // we want to select the strongest signal with the highest priority if we have multiple SSIDs available + for(int8_t x = 0; x < scanResult; ++x) { + String ssid; + uint8_t encryptionType; + int32_t rssi; + uint8_t* bssid; + int32_t channel; + WiFi.getNetworkInfo(x, ssid, encryptionType, rssi, bssid, channel); + for(uint8_t i=0; i choosenRssi) { + if(encryptionType == WIFI_AUTH_OPEN || apList[i].apPass.length() > 0) { // open wifi or we do know a password + choosenAp = i; + choosenRssi = rssi; + } + } // else lower wifi signal + } + } + WiFi.scanDelete(); + } + + if (choosenAp == INT_MIN) { + logMessage("[WIFI] Unable to find an SSID to connect to!\n"); + return false; + } else { + logMessage(String("[WIFI] Trying to connect to SSID ") + apList[choosenAp].apName + + " with password " + (apList[choosenAp].apPass.length() > 0 ? "'***'" : "''") + "\n" + ); + + WiFi.begin(apList[choosenAp].apName.c_str(), apList[choosenAp].apPass.c_str()); + wl_status_t status = (wl_status_t)WiFi.waitForConnectResult(5000UL); + + auto startTime = millis(); + // wait for connection, fail, or timeout + while(status != WL_CONNECTED && status != WL_NO_SSID_AVAIL && status != WL_CONNECT_FAILED && (millis() - startTime) <= 10000) { + delay(10); + status = (wl_status_t)WiFi.waitForConnectResult(5000UL); + } + switch(status) { + case WL_IDLE_STATUS: + logMessage("[WIFI] Connecting failed (0): Idle\n"); + break; + case WL_NO_SSID_AVAIL: + logMessage("[WIFI] Connecting failed (1): The AP can't be found\n"); + break; + case WL_SCAN_COMPLETED: + logMessage("[WIFI] Connecting failed (2): Scan completed\n"); + break; + case WL_CONNECTED: // 3 + logMessage("[WIFI] Connection successful\n"); + logMessage("[WIFI] SSID : " + WiFi.SSID() + "\n"); + logMessage("[WIFI] IP : " + WiFi.localIP().toString() + "\n"); + connectCb(); + stopSoftAP(); + return true; + break; + case WL_CONNECT_FAILED: + logMessage("[WIFI] Connecting failed (4): Unknown reason\n"); + break; + case WL_CONNECTION_LOST: + logMessage("[WIFI] Connecting failed (5): Connection lost\n"); + break; + case WL_DISCONNECTED: + logMessage("[WIFI] Connecting failed (6): Disconnected\n"); + break; + case WL_NO_SHIELD: + logMessage("[WIFI] Connecting failed (7): No Wifi shield found\n"); + break; + default: + logMessage("[WIFI] Connecting failed (" + String(status) + "): Unknown status code\n"); + break; + } + } + return false; +} + +void WIFIMANAGER::configueSoftAp(String apName, String apPass) { + this->softApName = apName; + this->softApPass = apPass; +} + + + +/** + * @brief Start a SoftAP for direct client access + * @param apName name of the AP to create (default is ESP_XXXXXXXX) + * @return true on success + * @return false o error or if a SoftAP already runs + */ +bool WIFIMANAGER::runSoftAP(String apName, String apPass) { + if (apName.length()) this->softApName = apName; + if (apPass.length()) this->softApPass = apPass; + + if (softApRunning) return true; + startApTimeMillis = millis(); + + if (this->softApName == "") this->softApName = "ESP_" + String((uint32_t)ESP.getEfuseMac()); + logMessage("[WIFI] Starting configuration portal on AP SSID " + this->softApName + "\n"); + + WiFi.mode(WIFI_AP); + bool state = WiFi.softAP(this->softApName.c_str(), (this->softApPass.length() ? this->softApPass.c_str() : NULL)); + if (state) { + IPAddress IP = WiFi.softAPIP(); + logMessage("[WIFI] AP created. My IP is: " + String(IP) + "\n"); + return true; + } else { + logMessage("[WIFI] Unable to create SoftAP!\n"); + return false; + } +} + +/** + * @brief Stop/Disconnect a current running SoftAP + */ +void WIFIMANAGER::stopSoftAP() { + WiFi.softAPdisconnect(); + WiFi.mode(WIFI_STA); +} + +/** + * @brief Stop/Disconnect a current wifi connection + */ +void WIFIMANAGER::stopClient() { + WiFi.disconnect(); +} + +/** + * @brief Stop/Disconnect all running wifi activies and optionally kill the background task as well + * @param killTask true to kill the background task to prevent reconnects + */ +void WIFIMANAGER::stopWifi(bool killTask) { + if (killTask) vTaskDelete(WifiCheckTask); + stopSoftAP(); + stopClient(); +} + +/** + * @brief Attach the WebServer to the WifiManager to register the RESTful API + * @param srv WebServer object + */ +#if ASYNC_WEBSERVER == true +void WIFIMANAGER::attachWebServer(AsyncWebServer * srv) { +#else +void WIFIMANAGER::attachWebServer(WebServer * srv) { +#endif + webServer = srv; // store it in the class for later use + +#if ASYNC_WEBSERVER == true + // not required +#else + // just for debugging + webServer->onNotFound([&]() { + String uri = WebServer::urlDecode(webServer->uri()); // required to read paths with blanks + + // Dump debug data + String message; + message.reserve(100); + message = F("Error: File not found\n\nURI: "); + message += uri; + message += F("\nMethod: "); + message += (webServer->method() == HTTP_GET) ? "GET" : "POST"; + message += F("\nArguments: "); + message += webServer->args(); + message += '\n'; + for (uint8_t i = 0; i < webServer->args(); i++) { + message += F(" NAME:"); + message += webServer->argName(i); + message += F("\n VALUE:"); + message += webServer->arg(i); + message += '\n'; + } + message += "path="; + message += webServer->arg("path"); + message += '\n'; + logMessage(message); + }); +#endif + +#if ASYNC_WEBSERVER == true + webServer->on((apiPrefix + "/softap/start").c_str(), HTTP_POST, [&](AsyncWebServerRequest * request) { + request->send(200, "application/json", "{\"message\":\"Soft AP stopped\"}"); +#else + webServer->on((apiPrefix + "/softap/start").c_str(), HTTP_POST, [&]() { + webServer->send(200, "application/json", "{\"message\":\"Soft AP stopped\"}"); +#endif + yield(); + delay(250); + runSoftAP(); + }); + +#if ASYNC_WEBSERVER == true + webServer->on((apiPrefix + "/softap/stop").c_str(), HTTP_POST, [&](AsyncWebServerRequest * request) { + request->send(200, "application/json", "{\"message\":\"Soft AP stopped\"}"); +#else + webServer->on((apiPrefix + "/softap/stop").c_str(), HTTP_POST, [&]() { + webServer->send(200, "application/json", "{\"message\":\"Soft AP stopped\"}"); +#endif + yield(); + delay(250); // It's likely that this message won't go trough, but we give it a short time + stopSoftAP(); + }); + +#if ASYNC_WEBSERVER == true + webServer->on((apiPrefix + "/client/stop").c_str(), HTTP_POST, [&](AsyncWebServerRequest * request) { + request->send(200, "application/json", "{\"message\":\"Terminating current Wifi connection\"}"); +#else + webServer->on((apiPrefix + "/client/stop").c_str(), HTTP_POST, [&]() { + webServer->send(200, "application/json", "{\"message\":\"Terminating current Wifi connection\"}"); +#endif + yield(); + delay(500); // It's likely that this message won't go trough, but we give it a short time + stopClient(); + }); + +#if ASYNC_WEBSERVER == true + webServer->on((apiPrefix + "/add").c_str(), HTTP_POST, [&](AsyncWebServerRequest * request){}, NULL, + [&](AsyncWebServerRequest * request, uint8_t *data, size_t len, size_t index, size_t total) { + JsonDocument jsonBuffer; + deserializeJson(jsonBuffer, (const char*)data); + auto resp = request; +#else + webServer->on((apiPrefix + "/add").c_str(), HTTP_POST, [&]() { + if (webServer->args() != 1) { + webServer->send(400, "application/json", "{\"message\":\"Bad Request. Only accepting one json body in request!\"}"); + } + JsonDocument jsonBuffer; + deserializeJson(jsonBuffer, webServer->arg(0)); + auto resp = webServer; +#endif + if (!jsonBuffer["apName"].is() || !jsonBuffer["apPass"].is()) { + resp->send(422, "application/json", "{\"message\":\"Invalid data\"}"); + return; + } + if (!addWifi(jsonBuffer["apName"].as(), jsonBuffer["apPass"].as())) { + resp->send(500, "application/json", "{\"message\":\"Unable to process data\"}"); + } else {resp->send(200, "application/json", "{\"message\":\"New network added\"");} + }); + +#if ASYNC_WEBSERVER == true + webServer->on((apiPrefix + "/id").c_str(), HTTP_DELETE, [&](AsyncWebServerRequest * request){}, NULL, + [&](AsyncWebServerRequest * request, uint8_t *data, size_t len, size_t index, size_t total) { + JsonDocument jsonBuffer; + deserializeJson(jsonBuffer, (const char*)data); + auto resp = request; +#else + webServer->on((apiPrefix + "/id").c_str(), HTTP_DELETE, [&]() { + if (webServer->args() != 1) { + webServer->send(400, "application/json", "{\"message\":\"Bad Request. Only accepting one json body in request!\"}"); + } + JsonDocument jsonBuffer; + deserializeJson(jsonBuffer, webServer->arg(0)); + auto resp = webServer; +#endif + int idValue = jsonBuffer["id"].as().toInt(); + if (idValue < 0 || idValue >= WIFIMANAGER_MAX_APS) { + resp->send(422, "application/json", "{\"message\":\"Invalid data\"}"); + return; + } + if (!delWifi(jsonBuffer["id"].as())) { + resp->send(500, "application/json", "{\"message\":\"Unable to delete entry\"}"); + } else resp->send(200, "application/json", "{\"message\":\"AP deleted\"}"); + }); + +#if ASYNC_WEBSERVER == true + webServer->on((apiPrefix + "/apName").c_str(), HTTP_DELETE, [&](AsyncWebServerRequest * request){}, NULL, + [&](AsyncWebServerRequest * request, uint8_t *data, size_t len, size_t index, size_t total) { + JsonDocument jsonBuffer; + deserializeJson(jsonBuffer, (const char*)data); + auto resp = request; +#else + webServer->on((apiPrefix + "/apName").c_str(), HTTP_DELETE, [&]() { + if (webServer->args() != 1) { + webServer->send(400, "application/json", "{\"message\":\"Bad Request. Only accepting one json body in request!\"}"); + } + JsonDocument jsonBuffer; + deserializeJson(jsonBuffer, webServer->arg(0)); + auto resp = webServer; +#endif + if (!jsonBuffer["apName"].is()) { + resp->send(422, "application/json", "{\"message\":\"Invalid data\"}"); + return; + } + if (!delWifi(jsonBuffer["apName"].as())) { + resp->send(500, "application/json", "{\"message\":\"Unable to delete entry\"}"); + } else resp->send(200, "application/json", "{\"message\":\"AP deleted\"}"); + }); + +#if ASYNC_WEBSERVER == true + webServer->on((apiPrefix + "/configlist").c_str(), HTTP_GET, [&](AsyncWebServerRequest *request) { + AsyncResponseStream *response = request->beginResponseStream("application/json"); +#else + webServer->on((apiPrefix + "/configlist").c_str(), HTTP_GET, [&]() { + String buffer; +#endif + JsonDocument jsonDoc; + auto jsonArray = jsonDoc.to(); + for(uint8_t i=0; i 0) { + JsonObject wifiNet = jsonArray.createNestedObject(); + wifiNet["id"] = i; + wifiNet["apName"] = apList[i].apName; + wifiNet["apPass"] = apList[i].apPass.length() > 0 ? true : false; + } + } +#if ASYNC_WEBSERVER == true + serializeJson(jsonArray, *response); + response->setCode(200); + response->setContentLength(measureJson(jsonDoc)); + request->send(response); +#else + // Improve me: not that efficient without the stream response + serializeJson(jsonArray, buffer); + webServer->send(200, "application/json", (buffer.equals("null") ? "{}" : buffer)); +#endif + }); + +#if ASYNC_WEBSERVER == true + webServer->on((apiPrefix + "/scan").c_str(), HTTP_GET, [&](AsyncWebServerRequest *request) { + AsyncResponseStream *response = request->beginResponseStream("application/json"); +#else + webServer->on((apiPrefix + "/scan").c_str(), HTTP_GET, [&]() { + String buffer; +#endif + JsonDocument jsonDoc; + + int scanResult; + String ssid; + uint8_t encryptionType; + int32_t rssi; + uint8_t* bssid; + int32_t channel; + + scanResult = WiFi.scanComplete(); + if (scanResult == WIFI_SCAN_FAILED) { + scanResult = WiFi.scanNetworks(true, true); // FIXME: scanNetworks is disconnecting clients! + jsonDoc["status"] = "scanning"; + } else if (scanResult > 0) { + for (int8_t i = 0; i < scanResult; i++) { + WiFi.getNetworkInfo(i, ssid, encryptionType, rssi, bssid, channel); + + JsonObject wifiNet = jsonDoc.createNestedObject(); + wifiNet["ssid"] = ssid; + wifiNet["encryptionType"] = encryptionType; + wifiNet["rssi"] = rssi; + wifiNet["channel"] = channel; + yield(); + } + WiFi.scanDelete(); + } +#if ASYNC_WEBSERVER == true + serializeJson(jsonDoc, *response); + response->setCode(200); + response->setContentLength(measureJson(jsonDoc)); + request->send(response); +#else + // Improve me: not that efficient without the stream response + serializeJson(jsonDoc, buffer); + webServer->send(200, "application/json", buffer); +#endif + }); + +#if ASYNC_WEBSERVER == true + webServer->on((apiPrefix + "/status").c_str(), HTTP_GET, [&](AsyncWebServerRequest *request) { + AsyncResponseStream *response = request->beginResponseStream("application/json"); +#else + webServer->on((apiPrefix + "/status").c_str(), HTTP_GET, [&]() { + String buffer; +#endif + JsonDocument jsonDoc; + + jsonDoc["ssid"] = WiFi.SSID(); + jsonDoc["signalStrengh"] = WiFi.RSSI(); + + jsonDoc["ip"] = WiFi.localIP().toString(); + jsonDoc["gw"] = WiFi.gatewayIP().toString(); + jsonDoc["nm"] = WiFi.subnetMask().toString(); + + jsonDoc["hostname"] = WiFi.getHostname(); + + jsonDoc["chipModel"] = ESP.getChipModel(); + jsonDoc["chipRevision"] = ESP.getChipRevision(); + jsonDoc["chipCores"] = ESP.getChipCores(); + + jsonDoc["getHeapSize"] = ESP.getHeapSize(); + jsonDoc["freeHeap"] = ESP.getFreeHeap(); + +#if ASYNC_WEBSERVER == true + serializeJson(jsonDoc, *response); + response->setCode(200); + response->setContentLength(measureJson(jsonDoc)); + request->send(response); +#else + // Improve me: not that efficient without the stream response + serializeJson(jsonDoc, buffer); + webServer->send(200, "application/json", buffer); +#endif + }); +} + +/** + * @brief Attach the WebServer to the WifiManager to register the RESTful API + * @param srv WebServer object + */ +void WIFIMANAGER::attachUI() { + +#if ASYNC_WEBSERVER == true + webServer->on((uiPrefix).c_str(), HTTP_GET, [](AsyncWebServerRequest* request) { +#else + webServer->on((uiPrefix).c_str(), HTTP_GET, [&]() { +#endif + + String html = R"html( + + + + + + YOUR ELATO 😊 + + + +
+
+

YOUR ELATO DEVICE 😊

+
+ + +
+ +
+

✅ Saved Networks

+
+
+ +
+

🛜 Available Networks

+
+
+
+ + + + + + +)html"; + +#if ASYNC_WEBSERVER == true + request->send(200, "text/html", html); +#else + webServer->send(200, "text/html", html); +#endif + + }); +} \ No newline at end of file diff --git a/firmware-cpp/src/WifiManager.h b/firmware-cpp/src/WifiManager.h new file mode 100644 index 0000000..41102fc --- /dev/null +++ b/firmware-cpp/src/WifiManager.h @@ -0,0 +1,140 @@ +/** + * Wifi Manager + * (c) 2022-2025 Martin Verges + * + * Licensed under CC BY-NC-SA 4.0 + * (Attribution-NonCommercial-ShareAlike 4.0 International) +**/ +#ifndef WIFIMANAGER_h +#define WIFIMANAGER_h + +#ifndef WIFIMANAGER_MAX_APS +#define WIFIMANAGER_MAX_APS 4 // Valid range is uint8_t +#endif + +#ifndef ASYNC_WEBSERVER + #define ASYNC_WEBSERVER true +#endif + +#include +#include +#if ASYNC_WEBSERVER == true + #include +#else + #include +#endif +#include + +void wifiTask(void* param); + +// Callback for when the device connects to Wifi +void connectCb(); + + +class WIFIMANAGER { + protected: +#if ASYNC_WEBSERVER == true + AsyncWebServer * webServer; // The Webserver to register routes on +#else + WebServer * webServer; // The Webserver to register routes on +#endif + String apiPrefix = "/api/wifi"; // Prefix for all IP endpionts + String uiPrefix = "/wifi"; // Prefix for all UI endpionts + + Preferences preferences; // Used to store AP credentials to NVS + char * NVS; // Name used for NVS preferences + + struct apCredentials_t { + String apName; // Name of the AP SSID + String apPass; // Password if required to the AP + }; + apCredentials_t apList[WIFIMANAGER_MAX_APS]; // Stored AP list + + uint8_t configuredSSIDs = 0; // Number of stored SSIDs in the NVS + + bool softApRunning = false; // Due to lack of functions, we have to remember if the AP is already running... + bool createFallbackAP = true; // Create an AP for configuration if no other connection is available + + uint64_t lastWifiCheckMillis = 0; // Time of last Wifi health check + uint32_t intervalWifiCheckMillis = 10000; // Interval of the Wifi health checks + uint64_t startApTimeMillis = 0; // Time when the AP was started + uint32_t timeoutApMillis = 120000; // Timeout of an AP when no client is connected, if timeout reached rescan, tryconnect or createAP + + String softApName; // Name of the soft AP if created, default to ESP_XXXXXXXX if empty + String softApPass; // Password for the soft AP, default to no password (empty) + + // Wipe the apList credentials + void clearApList(); + + // Get id of the first non empty entry + uint8_t getApEntry(); + + // Print a log message to Serial, can be overwritten + virtual void logMessage(String msg); + + public: + // We let the loop run as as Task + TaskHandle_t WifiCheckTask; + + WIFIMANAGER(const char * ns = "wifimanager"); + virtual ~WIFIMANAGER(); + + // If no known Wifi can't be found, create an AP but retry regulary + void fallbackToSoftAp(bool state = true); + + // Get the current fallback state + bool getFallbackState(); + + // Call to run the Task in the background + void startBackgroundTask(String apName = "", String apPass = ""); + + // Attach a webserver and register api routes +#if ASYNC_WEBSERVER == true + void attachWebServer(AsyncWebServer * srv); +#else + void attachWebServer(WebServer * srv); +#endif + // Attach an UI + void attachUI(); + + // Add another AP to the list of known WIFIs + bool addWifi(String apName, String apPass, bool updateNVS = true); + + // Delete Wifi from apList by ID + bool delWifi(uint8_t apId); + + // Delete Wifi from apList by Name + bool delWifi(String apName); + + // Try each known SSID and connect until none is left or one is connected. + bool tryConnect(); + + // Check if a SSID is stored in the config + bool configAvailable(); + + // Preconfigure the SoftAP + void configueSoftAp(String apName = "", String apPass = ""); + + // Start a SoftAP, called if no wifi can be connected + bool runSoftAP(String apName = "", String apPass = ""); + + // Disconnect/Stop SoftAP Mode + void stopSoftAP(); + + // Disconnect/Stop STA Mode + void stopClient(); + + // Disconnect/Stop SoftAP and STA Mode. Optionally end the task loop as well. + void stopWifi(bool killTask = false); + + // Run in the loop to maintain state + void loop(); + + // Write AP Settings into persistent storage. Called on each addAP; + bool writeToNVS(); + + // Load AP Settings from NVS it known apList + bool loadFromNVS(); +}; + +#endif \ No newline at end of file diff --git a/firmware-cpp/src/main.cpp b/firmware-cpp/src/main.cpp new file mode 100644 index 0000000..a5ecb2a --- /dev/null +++ b/firmware-cpp/src/main.cpp @@ -0,0 +1,265 @@ +#include "OTA.h" +#include +#include +#include "LEDHandler.h" +#include "Config.h" +#include "SPIFFS.h" +#include "WifiManager.h" +#include +#include "Button.h" +#include "FactoryReset.h" + +// #define WEBSOCKETS_DEBUG_LEVEL WEBSOCKETS_LEVEL_ALL + +#define TOUCH_THRESHOLD 28000 +#define LONG_PRESS_MS 1000 +#define REQUIRED_RELEASE_CHECKS 100 // how many consecutive times we need "below threshold" to confirm release +#define TOUCH_DEBOUNCE_DELAY 1000 // milliseconds + +AsyncWebServer webServer(80); +WIFIMANAGER WifiManager; +esp_err_t getErr = ESP_OK; + +void enterSleep() +{ + Serial.println("Going to sleep..."); + + // First, change device state to prevent any new data processing + deviceState = IDLE; + + // Stop audio tasks first + i2s_stop(I2S_PORT_IN); + i2s_stop(I2S_PORT_OUT); + + // Clear any remaining audio in buffer + audioBuffer.reset(); + + // Properly disconnect WebSocket and wait for it to complete + if (webSocket.isConnected()) { + webSocket.disconnect(); + // Give some time for the disconnect to process + delay(100); + } + + // Stop all tasks that might be using I2S or other peripherals + i2s_driver_uninstall(I2S_PORT_IN); + i2s_driver_uninstall(I2S_PORT_OUT); + + // Flush any remaining serial output + Serial.flush(); + + #ifdef TOUCH_MODE + touch_pad_intr_disable(TOUCH_PAD_INTR_MASK_ALL); + while (touchRead(TOUCH_PAD_NUM2) > TOUCH_THRESHOLD) { + delay(50); + } + delay(500); + touchSleepWakeUpEnable(TOUCH_PAD_NUM2, TOUCH_THRESHOLD); + #endif + + esp_deep_sleep_start(); +} + +void printOutESP32Error(esp_err_t err) +{ + switch (err) + { + case ESP_OK: + Serial.println("ESP_OK no errors"); + break; + case ESP_ERR_INVALID_ARG: + Serial.println("ESP_ERR_INVALID_ARG if the selected GPIO is not an RTC GPIO, or the mode is invalid"); + break; + case ESP_ERR_INVALID_STATE: + Serial.println("ESP_ERR_INVALID_STATE if wakeup triggers conflict or wireless not stopped"); + break; + default: + Serial.printf("Unknown error code: %d\n", err); + break; + } +} + +static void onButtonLongPressUpEventCb(void *button_handle, void *usr_data) +{ + Serial.println("Button long press end"); + delay(10); + enterSleep(); +} + +static void onButtonDoubleClickCb(void *button_handle, void *usr_data) +{ + Serial.println("Button double click"); + delay(10); + enterSleep(); +} + +void getAuthTokenFromNVS() +{ + preferences.begin("auth", false); + authTokenGlobal = preferences.getString("auth_token", ""); + preferences.end(); +} + +void setupWiFi() +{ + WifiManager.startBackgroundTask("ELATO-DEVICE"); // Run the background task to take care of our Wifi + WifiManager.fallbackToSoftAp(true); // Run a SoftAP if no known AP can be reached + WifiManager.attachWebServer(&webServer); // Attach our API to the Webserver + WifiManager.attachUI(); // Attach the UI to the Webserver + + // Run the Webserver and add your webpages to it + webServer.on("/", HTTP_GET, [](AsyncWebServerRequest *request){ + request->redirect("/wifi"); + }); + webServer.onNotFound([&](AsyncWebServerRequest *request) { + request->send(404, "text/plain", "Not found"); + }); + webServer.begin(); +} + +void touchTask(void* parameter) { + touch_pad_init(); + touch_pad_config(TOUCH_PAD_NUM2); + + bool touched = false; + unsigned long pressStartTime = 0; + unsigned long lastTouchTime = 0; + + while (1) { + // Read the touch sensor + uint32_t touchValue = touchRead(TOUCH_PAD_NUM2); + bool isTouched = (touchValue > TOUCH_THRESHOLD); + unsigned long currentTime = millis(); + + // Debounced touch detection + if (isTouched && !touched && (currentTime - lastTouchTime > TOUCH_DEBOUNCE_DELAY)) { + touched = true; + lastTouchTime = currentTime; + enterSleep(); + + // if (!webSocket.isConnected()) { + // enterSleep(); + // } else if (deviceState == SPEAKING) { + // // First, set the flag to prevent further audio processing + // // deviceState = PROCESSING; + // scheduleListeningRestart = true; + // scheduledTime = millis() + 100; // Shorter delay + + // unsigned long audio_end_ms = getSpeakingDuration(); + + // // Use ArduinoJson to create the message + // JsonDocument doc; + // doc["type"] = "instruction"; + // doc["msg"] = "INTERRUPT"; + // doc["audio_end_ms"] = audio_end_ms; + + // String jsonString; + // serializeJson(doc, jsonString); + + // // Take mutex to ensure clean WebSocket access + // if (xSemaphoreTake(wsMutex, pdMS_TO_TICKS(10)) == pdTRUE) { + // webSocket.sendTXT(jsonString); + // xSemaphoreGive(wsMutex); + // } + // } + } + + // Release detection + if (!isTouched && touched) { + touched = false; + } + + vTaskDelay(20); // Reduced from 50ms to 20ms for better responsiveness + } + // (This point is never reached.) + vTaskDelete(NULL); +} + +void setupDeviceMetadata() { + // factoryResetDevice(); + deviceState = IDLE; + + getAuthTokenFromNVS(); + getOTAStatusFromNVS(); + + if (otaState == OTA_IN_PROGRESS || otaState == OTA_COMPLETE) { + deviceState = OTA; + } + if (factory_reset_status) { + deviceState = FACTORY_RESET; + } +} + +void setup() +{ + Serial.begin(115200); + delay(500); + + // SETUP + setupDeviceMetadata(); + wsMutex = xSemaphoreCreateMutex(); + + // INTERRUPT + #ifdef TOUCH_MODE + xTaskCreate(touchTask, "Touch Task", 4096, NULL, configMAX_PRIORITIES-2, NULL); + #else + getErr = esp_sleep_enable_ext0_wakeup(BUTTON_PIN, LOW); + printOutESP32Error(getErr); + Button *btn = new Button(BUTTON_PIN, false); + btn->attachLongPressUpEventCb(&onButtonLongPressUpEventCb, NULL); + btn->attachDoubleClickEventCb(&onButtonDoubleClickCb, NULL); + btn->detachSingleClickEvent(); + #endif + + // Pin audio tasks to Core 1 (application core) + xTaskCreatePinnedToCore( + ledTask, // Function + "LED Task", // Name + 4096, // Stack size + NULL, // Parameters + 5, // Priority + NULL, // Handle + 1 // Core 1 (application core) + ); + + xTaskCreatePinnedToCore( + audioStreamTask, // Function + "Speaker Task", // Name + 4096, // Stack size + NULL, // Parameters + 3, // Priority + NULL, // Handle + 1 // Core 1 (application core) + ); + + xTaskCreatePinnedToCore( + micTask, // Function + "Microphone Task", // Name + 4096, // Stack size + NULL, // Parameters + 4, // Priority + NULL, // Handle + 1 // Core 1 (application core) + ); + + // Pin network task to Core 0 (protocol core) + xTaskCreatePinnedToCore( + networkTask, // Function + "Websocket Task", // Name + 8192, // Stack size + NULL, // Parameters + configMAX_PRIORITIES-1, // Highest priority + &networkTaskHandle,// Handle + 0 // Core 0 (protocol core) + ); + + // WIFI + setupWiFi(); +} + +void loop(){ + if (otaState == OTA_IN_PROGRESS) + { + loopOTA(); + } +} \ No newline at end of file diff --git a/firmware-cpp/test/README b/firmware-cpp/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/firmware-cpp/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html diff --git a/firmware-cpp/test/WifiSetup.h b/firmware-cpp/test/WifiSetup.h new file mode 100644 index 0000000..e7df84e --- /dev/null +++ b/firmware-cpp/test/WifiSetup.h @@ -0,0 +1,528 @@ +#include //https://github.com/me-no-dev/AsyncTCP using the latest dev version from @me-no-dev +#include +#include //https://github.com/me-no-dev/ESPAsyncWebServer using the latest dev version from @me-no-dev +#include //Used for mpdu_rx_disable android workaround +#include +#include +#include "FactoryReset.h" +#define uS_TO_S_FACTOR 1000000ULL + +#define MAX_CLIENTS 4 // ESP32 supports up to 10 but I have not tested it yet +#define WIFI_CHANNEL 6 // 2.4ghz channel 6 https://en.wikipedia.org/wiki/List_of_WLAN_channels#2.4_GHz_(802.11b/g/n/ax) + +const IPAddress localIP(4, 3, 2, 1); // the IP address the web server, Samsung requires the IP to be in public space +const IPAddress gatewayIP(4, 3, 2, 1); // IP address of the network should be the same as the local IP for captive portals +const IPAddress subnetMask(255, 255, 255, 0); // no need to change: https://avinetworks.com/glossary/subnet-mask/ + +const String localIPURL = "http://4.3.2.1"; // a string version of the local IP with http, used for redirecting clients to your webpage + +DNSServer dnsServer; +AsyncWebServer server(80); + +int AP_status = 0; + +// We'll store our MP3 in SPIFFS at /startup.mp3 +static const char* MP3_FILE = "/startup.mp3"; + +// Create the AudioTools pipeline components +AudioSourceSPIFFS source; // Will read from SPIFFS +MP3DecoderHelix decoder; // MP3 decoder +I2SStream i2s; // Send output to I2S +AudioPlayer player(source, i2s, decoder); + + +bool isDeviceRegistered(AsyncWebServerRequest *request) { + HTTPClient http; + WiFiClientSecure client; + client.setCACert(Vercel_CA_cert); + + #ifdef DEV_MODE + http.begin("http://" + String(backend_server) + ":" + String(backend_port) + + "/api/generate_auth_token?macAddress=" + WiFi.macAddress()); + #else + http.begin(client, "https://" + String(backend_server) + + "/api/generate_auth_token?macAddress=" + WiFi.macAddress()); + #endif + + http.setTimeout(10000); + + int httpCode = http.GET(); + + if (httpCode == HTTP_CODE_OK) { + String payload = http.getString(); + JsonDocument doc; + DeserializationError error = deserializeJson(doc, payload); + + if (error) { + Serial.print("JSON parsing failed: "); + Serial.println(error.c_str()); + http.end(); + return false; + } + + String authToken = doc["token"]; + if (!authToken.isEmpty()) { + // Store the auth token in NVS + preferences.begin("auth", false); + preferences.putString("auth_token", authToken); + preferences.end(); + + authTokenGlobal = String(authToken); + http.end(); + return true; + } + } + + // If we get here, either the request failed or no token was found + http.end(); + return false; +} + +String urlEncode(const String &msg) +{ + String encodedMsg = ""; + char c; + char code0; + char code1; + for (int i = 0; i < msg.length(); i++) + { + c = msg.charAt(i); + if (c == ' ') + { + encodedMsg += '+'; + } + else if (isalnum(c)) + { + encodedMsg += c; + } + else + { + code1 = (c & 0xf) + '0'; + if ((c & 0xf) > 9) + { + code1 = (c & 0xf) - 10 + 'A'; + } + c = (c >> 4) & 0xf; + code0 = c + '0'; + if (c > 9) + { + code0 = c - 10 + 'A'; + } + encodedMsg += '%'; + encodedMsg += code0; + encodedMsg += code1; + } + } + return encodedMsg; +} + +// plays when new wifi network connects +void playStartupSound() { + // Check if startup.mp3 actually exists + // 2) Mount SPIFFS + // Check if startup.mp3 actually exists + if(!SPIFFS.begin(true)) { + Serial.println("SPIFFS mount failed!"); + while(true) { delay(10); } + } + + File f = SPIFFS.open(MP3_FILE, "r"); + if(!f){ + Serial.println("startup.mp3 missing in SPIFFS!"); + while(true) { delay(10); } + } else { + Serial.printf("startup.mp3 found, size=%d bytes\n", f.size()); + f.close(); + } + + // Configure I2S in TX mode + auto cfg = i2s.defaultConfig(TX_MODE); + cfg.pin_bck = 6; + cfg.pin_ws = 5; + cfg.pin_data = 7; + cfg.channels = 1; + cfg.sample_rate = 44100; + // cfg.port_no = I2S_PORT_OUT; + + if(!i2s.begin(cfg)) { + Serial.println("I2S begin failed!"); + while(true) { delay(10); } + } + + // Initialize the player + player.setVolume(1.0f); + if(!player.begin()) { + Serial.println("Player begin() failed!"); + while(true) { delay(10); } + } + + // **THIS IS THE MISSING LOOP!** + Serial.println("Playing startup sound..."); + while(true) { + size_t copied = player.copy(); + if (copied == 0) { + Serial.println("Playback finished."); + delay(500); + break; + } + delay(1); // Give CPU time for other tasks + } + + Serial.println("Startup sound played, set up websocket"); +} + +int wifiConnect() +{ + WiFi.mode(WIFI_MODE_STA); // Add this to ensure we're in station mode + + // Begin with no arguments to load last stored credentials from NVS + WiFi.begin(); + + // Wait until connected + unsigned long startMillis = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - startMillis < 10000) + { + delay(100); + } + + if (WiFi.status() == WL_CONNECTED) + { + Serial.printf("Quick reconnect: Connected to %s\n", WiFi.SSID().c_str()); + Serial.printf("IP: %s\n", WiFi.localIP().toString().c_str()); + WiFi.setSleep(false); // Disable power saving + + return 1; + } + + preferences.begin("wifi_store"); + int numNetworks = preferences.getInt("numNetworks", 0); + if (numNetworks == 0) + { + preferences.end(); + return 0; + } + + // Try each stored network + for (int i = 0; i < numNetworks; ++i) + { + String ssid = preferences.getString(("ssid" + String(i)).c_str(), ""); + String password = preferences.getString(("password" + String(i)).c_str(), ""); + + if (ssid.length() > 0 && password.length() > 0) + { + Serial.printf("Attempting connection to %s\n", ssid.c_str()); + + WiFi.begin(ssid.c_str(), password.c_str()); + + // More reasonable timeout: 15 seconds (10 * 500ms = 10s) + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 20) + { + delay(500); // Longer delay between attempts + Serial.print("."); + attempts++; + } + Serial.println(); + + if (WiFi.status() == WL_CONNECTED) + { + Serial.printf("Connected to %s\n", ssid.c_str()); + Serial.printf("IP: %s\n", WiFi.localIP().toString().c_str()); + WiFi.setSleep(false); // Disable power saving + preferences.end(); + + return 1; + } + + Serial.printf("Failed to connect to %s\n", ssid.c_str()); + } + } + + preferences.end(); + return 0; +} + +void handleComplete(AsyncWebServerRequest *request) +{ + bool isRegistered = !authTokenGlobal.isEmpty(); + + if (!isRegistered && request->hasParam("registration_attempted")) { + isRegistered = isDeviceRegistered(request); + } + + String content; + if (isRegistered) { + content = "

Setup Complete

" + "

Your device is ready to use.

" + "

The setup network will now close.

"; + } else { + content = "

One Last Step

" + "

Please register your personal device code on our website to start using your device.

" + "
" + "" + "" + "
" + "

Click the button above after registering your device on the website.

"; + } + + request->send(200, "text/html", "" + "" + "" + "" + "" + "" + "" + "
" + "
Elato AI - Setup Complete
" + "
" + + content + + "
" + "
" + "" + ""); +} + +void handleRoot(AsyncWebServerRequest *request) +{ + String notConnected = ""; // Initialize with empty string + if (request->hasParam("not_connected")) + { + notConnected = request->getParam("not_connected")->value(); + } + if (WiFi.status() != WL_CONNECTED) + { + String html = "" + "" + "" + "" + "" + "" + "" + "
" + "
Elato AI
" + "
" + "

Connect to Wi-Fi

"; + if (strcmp(notConnected.c_str(), "true") == 0) + { + html += "

The network connection failed, please try again.

"; + } + html += "
" + "SSID:
" + "Password:
" + "" + "
" + "
" + "
" + "" + ""; + request->send(200, "text/html", html); + } + else + { + request->redirect("/complete"); + } +} + +void handleWifiSave(AsyncWebServerRequest *request) +{ + Serial.println("Start Save!"); + String ssid = request->arg("ssid"); + String password = request->arg("password"); + + // Attempt to connect to the provided Wi-Fi credentials + Serial.print("Connecting to "); + Serial.println(ssid); + Serial.println(password); + WiFi.begin(ssid.c_str(), password.c_str()); + + // Wait for connection + int attempts = 30; // 30 * 100ms = 3 seconds + while (attempts-- && WiFi.status() != WL_CONNECTED) + { + delay(100); + } + + if (WiFi.status() == WL_CONNECTED) + { + Serial.println("Successfully connected to WiFi!"); + + // Now that we're connected, save/update the credentials + preferences.begin("wifi_store", false); + int numNetworks = preferences.getInt("numNetworks", 0); + + // Check if this SSID already exists + bool updated = false; + for (int i = 0; i < numNetworks; ++i) + { + String storedSsid = preferences.getString(("ssid" + String(i)).c_str(), ""); + if (storedSsid == ssid) + { + preferences.putString(("password" + String(i)).c_str(), password); + Serial.println("Success Update!"); + updated = true; + break; + } + } + + // If it's a new network, add it + if (!updated) + { + preferences.putString(("ssid" + String(numNetworks)).c_str(), ssid); + preferences.putString(("password" + String(numNetworks)).c_str(), password); + preferences.putInt("numNetworks", numNetworks + 1); + Serial.println("Success Save!"); + } + preferences.end(); + + // Check if the device is registered + request->redirect("/complete"); + } + else + { + Serial.println("Failed to connect to WiFi"); + request->redirect("/?not_connected=true"); + } +} + +void setUpDNSServer(DNSServer &dnsServer, const IPAddress &localIP) +{ +// Define the DNS interval in milliseconds between processing DNS requests +#define DNS_INTERVAL 30 + + // Set the TTL for DNS response and start the DNS server + dnsServer.setTTL(3600); + dnsServer.start(53, "*", localIP); +} + +void startSoftAccessPoint(const char *ssid, const char *password, const IPAddress &localIP, const IPAddress &gatewayIP) +{ +// Define the maximum number of clients that can connect to the server +#define MAX_CLIENTS 4 +// Define the WiFi channel to be used (channel 6 in this case) +#define WIFI_CHANNEL 6 + + // Set the WiFi mode to access point and station + WiFi.mode(WIFI_MODE_AP); + + // Define the subnet mask for the WiFi network + const IPAddress subnetMask(255, 255, 255, 0); + + // Configure the soft access point with a specific IP and subnet mask + WiFi.softAPConfig(localIP, gatewayIP, subnetMask); + + // Start the soft access point with the given ssid, password, channel, max number of clients + WiFi.softAP(ssid, password, WIFI_CHANNEL, 0, MAX_CLIENTS); + + // Disable AMPDU RX on the ESP32 WiFi to fix a bug on Android + esp_wifi_stop(); + esp_wifi_deinit(); + wifi_init_config_t my_config = WIFI_INIT_CONFIG_DEFAULT(); + my_config.ampdu_rx_enable = false; + esp_wifi_init(&my_config); + esp_wifi_start(); + vTaskDelay(100 / portTICK_PERIOD_MS); // Add a small delay +} + +void setUpWebserver(AsyncWebServer &server, const IPAddress &localIP) +{ + //======================== Webserver ======================== + // WARNING IOS (and maybe macos) WILL NOT POP UP IF IT CONTAINS THE WORD "Success" https://www.esp8266.com/viewtopic.php?f=34&t=4398 + // SAFARI (IOS) IS STUPID, G-ZIPPED FILES CAN'T END IN .GZ https://github.com/homieiot/homie-esp8266/issues/476 this is fixed by the webserver serve static function. + // SAFARI (IOS) there is a 128KB limit to the size of the HTML. The HTML can reference external resources/images that bring the total over 128KB + // SAFARI (IOS) popup browser has some severe limitations (javascript disabled, cookies disabled) + + // Required + server.on("/connecttest.txt", [](AsyncWebServerRequest *request) + { request->redirect("http://logout.net"); }); // windows 11 captive portal workaround + server.on("/wpad.dat", [](AsyncWebServerRequest *request) + { request->send(404); }); // Honestly don't understand what this is but a 404 stops win 10 keep calling this repeatedly and panicking the esp32 :) + + // Background responses: Probably not all are Required, but some are. Others might speed things up? + // A Tier (commonly used by modern systems) + server.on("/generate_204", [](AsyncWebServerRequest *request) + { request->redirect(localIPURL); }); // android captive portal redirect + server.on("/redirect", [](AsyncWebServerRequest *request) + { request->redirect(localIPURL); }); // microsoft redirect + server.on("/hotspot-detect.html", [](AsyncWebServerRequest *request) + { request->redirect(localIPURL); }); // apple call home + server.on("/canonical.html", [](AsyncWebServerRequest *request) + { request->redirect(localIPURL); }); // firefox captive portal call home + server.on("/success.txt", [](AsyncWebServerRequest *request) + { request->send(200); }); // firefox captive portal call home + server.on("/ncsi.txt", [](AsyncWebServerRequest *request) + { request->redirect(localIPURL); }); // windows call home + + // B Tier (uncommon) + // server.on("/chrome-variations/seed",[](AsyncWebServerRequest *request){request->send(200);}); //chrome captive portal call home + // server.on("/service/update2/json",[](AsyncWebServerRequest *request){request->send(200);}); //firefox? + // server.on("/chat",[](AsyncWebServerRequest *request){request->send(404);}); //No stop asking Whatsapp, there is no internet connection + // server.on("/startpage",[](AsyncWebServerRequest *request){request->redirect(localIPURL);}); + + // return 404 to webpage icon + server.on("/favicon.ico", [](AsyncWebServerRequest *request) + { request->send(404); }); // webpage icon + + server.on("/", HTTP_GET, handleRoot); + server.on("/wifi", HTTP_POST, handleWifiSave); + server.on("/complete", HTTP_GET, handleComplete); + + // the catch all + server.onNotFound([](AsyncWebServerRequest *request) + { + request->redirect(localIPURL); + Serial.print("onnotfound "); + Serial.print(request->host()); // This gives some insight into whatever was being requested on the serial monitor + Serial.print(" "); + Serial.print(request->url()); + Serial.print(" sent redirect to " + localIPURL + "\n"); }); +} + +String getAPSSIDName() +{ + // Get the MAC address of the device + String macAddress = WiFi.macAddress(); + macAddress.replace(":", ""); + String lastFourMac = macAddress.substring(macAddress.length() - 4); // Get the last 4 characters + String ssid = "Elato-" + lastFourMac; + return ssid; +} + +void openAP() +{ + deviceState = SETUP; + AP_status = 1; + startSoftAccessPoint(getAPSSIDName().c_str(), NULL, localIP, gatewayIP); + setUpDNSServer(dnsServer, localIP); + setUpWebserver(server, localIP); + server.begin(); +} + +void closeAP() +{ + deviceState = IDLE; + dnsServer.stop(); + server.end(); + WiFi.softAPdisconnect(true); + WiFi.mode(WIFI_MODE_STA); + AP_status = 0; + Serial.println("Closed Access Point and DNS server"); +} \ No newline at end of file diff --git a/firmware-cpp/test/audio_stream_test.cpp b/firmware-cpp/test/audio_stream_test.cpp new file mode 100644 index 0000000..850fdaa --- /dev/null +++ b/firmware-cpp/test/audio_stream_test.cpp @@ -0,0 +1,95 @@ +#include "Arduino.h" +#include "WiFi.h" +#include "Audio.h" +#include "SD.h" +#include "FS.h" + +// Digital I/O used +#define I2S_DOUT 7 +#define I2S_BCLK 6 +#define I2S_LRC 5 +#define I2S_SD_OUT 10 + +Audio audio; + +String ssid = "S_HOUSE_RESIDENTS_NW"; +String password = "Somerset_Residents!"; + +void setup() { + Serial.begin(115200); + WiFi.disconnect(); + WiFi.mode(WIFI_STA); + WiFi.begin(ssid.c_str(), password.c_str()); + while (WiFi.status() != WL_CONNECTED) delay(1500); + // setup I2S shutdown pin + pinMode(I2S_SD_OUT, OUTPUT); + digitalWrite(I2S_SD_OUT, HIGH); + audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT); + audio.setVolume(21); // default 0...21 +// or alternative +// audio.setVolumeSteps(64); // max 255 +// audio.setVolume(63); +// +// *** radio streams *** + audio.connecttohost("https://s6.yesstreaming.net:17082/stream"); // aac +// audio.connecttohost("http://mcrscast.mcr.iol.pt/cidadefm"); // mp3 +// audio.connecttohost("http://www.wdr.de/wdrlive/media/einslive.m3u"); // m3u +// audio.connecttohost("https://stream.srg-ssr.ch/rsp/aacp_48.asx"); // asx +// audio.connecttohost("http://tuner.classical102.com/listen.pls"); // pls +// audio.connecttohost("http://stream.radioparadise.com/flac"); // flac +// audio.connecttohost("http://stream.sing-sing-bis.org:8000/singsingFlac"); // flac (ogg) +// audio.connecttohost("http://s1.knixx.fm:5347/dein_webradio_vbr.opus"); // opus (ogg) +// audio.connecttohost("http://stream2.dancewave.online:8080/dance.ogg"); // vorbis (ogg) +// audio.connecttohost("http://26373.live.streamtheworld.com:3690/XHQQ_FMAAC/HLSTS/playlist.m3u8"); // HLS +// audio.connecttohost("http://eldoradolive02.akamaized.net/hls/live/2043453/eldorado/master.m3u8"); // HLS (ts) +// *** web files *** +// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/Pink-Panther.wav"); // wav +// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/Santiano-Wellerman.flac"); // flac +// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/Olsen-Banden.mp3"); // mp3 +// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/Miss-Marple.m4a"); // m4a (aac) +// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/Collide.ogg"); // vorbis +// audio.connecttohost("https://github.com/schreibfaul1/ESP32-audioI2S/raw/master/additional_info/Testfiles/sample.opus"); // opus +// *** local files *** +// audio.connecttoFS(SD, "/test.wav"); // SD +// audio.connecttoFS(SD_MMC, "/test.wav"); // SD_MMC +// audio.connecttoFS(SPIFFS, "/test.wav"); // SPIFFS + +// audio.connecttospeech("Wenn die Hunde schlafen, kann der Wolf gut Schafe stehlen.", "de"); // Google TTS +} + +void loop(){ + audio.loop(); + vTaskDelay(1); +} + +// optional +void audio_info(const char *info){ + Serial.print("info "); Serial.println(info); +} +void audio_id3data(const char *info){ //id3 metadata + Serial.print("id3data ");Serial.println(info); +} +void audio_eof_mp3(const char *info){ //end of file + Serial.print("eof_mp3 ");Serial.println(info); +} +void audio_showstation(const char *info){ + Serial.print("station ");Serial.println(info); +} +void audio_showstreamtitle(const char *info){ + Serial.print("streamtitle ");Serial.println(info); +} +void audio_bitrate(const char *info){ + Serial.print("bitrate ");Serial.println(info); +} +void audio_commercial(const char *info){ //duration in sec + Serial.print("commercial ");Serial.println(info); +} +void audio_icyurl(const char *info){ //homepage + Serial.print("icyurl ");Serial.println(info); +} +void audio_lasthost(const char *info){ //stream URL played + Serial.print("lasthost ");Serial.println(info); +} +void audio_eof_speech(const char *info){ + Serial.print("eof_speech ");Serial.println(info); +} diff --git a/firmware-cpp/test/eduroam_wifi_test.cpp b/firmware-cpp/test/eduroam_wifi_test.cpp new file mode 100644 index 0000000..3247593 --- /dev/null +++ b/firmware-cpp/test/eduroam_wifi_test.cpp @@ -0,0 +1,56 @@ +#include "Arduino.h" +#include //Wifi library +#define EAP_IDENTITY "username@city.ac.uk" // if connecting from another corporation, use identity@organization.domain in Eduroam +#define EAP_USERNAME "username@city.ac.uk" // oftentimes just a repeat of the identity +#define EAP_PASSWORD "your password" // your Eduroam password +const char *ssid = "eduroam"; // Eduroam SSID +const char *host = "arduino.php5.sk"; // external server domain for HTTP connection after authentication +int counter = 0; + +// NOTE: For some systems, various certification keys are required to connect to the wifi system. +// Usually you are provided these by the IT department of your organization when certs are required +// and you can't connect with just an identity and password. +// Most eduroam setups we have seen do not require this level of authentication, but you should contact +// your IT department to verify. +// You should uncomment these and populate with the contents of the files if this is required for your scenario (See Example 2 and Example 3 below). +// const char *ca_pem = "insert your CA cert from your .pem file here"; +// const char *client_cert = "insert your client cert from your .crt file here"; +// const char *client_key = "insert your client key from your .key file here"; + +void setup() +{ + Serial.begin(115200); + delay(10); + Serial.println(); + Serial.print("Connecting to network: "); + Serial.println(ssid); + WiFi.disconnect(true); // disconnect form wifi to set new wifi connection + WiFi.mode(WIFI_STA); // init wifi mode + + // Example1 (most common): a cert-file-free eduroam with PEAP (or TTLS) + WiFi.begin(ssid, WPA2_AUTH_PEAP, EAP_IDENTITY, EAP_USERNAME, EAP_PASSWORD); + + // Example 2: a cert-file WPA2 Enterprise with PEAP + // WiFi.begin(ssid, WPA2_AUTH_PEAP, EAP_IDENTITY, EAP_USERNAME, EAP_PASSWORD, ca_pem, client_cert, client_key); + + // Example 3: TLS with cert-files and no password + // WiFi.begin(ssid, WPA2_AUTH_TLS, EAP_IDENTITY, NULL, NULL, ca_pem, client_cert, client_key); + + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print("."); + counter++; + if (counter >= 60) + { // after 30 seconds timeout - reset board + ESP.restart(); + } + } + Serial.println(""); + Serial.println("WiFi connected"); + Serial.println("IP address set: "); + Serial.println(WiFi.localIP()); // print LAN IP +} +void loop() +{ +} diff --git a/firmware-cpp/test/fetch_auth_token_test.cpp b/firmware-cpp/test/fetch_auth_token_test.cpp new file mode 100644 index 0000000..d7f28b6 --- /dev/null +++ b/firmware-cpp/test/fetch_auth_token_test.cpp @@ -0,0 +1,121 @@ +#include +#include +#include // NVS for storage + +// Wi-Fi credentials +const char *ssid = ""; +const char *password = ""; + +// Server endpoint for checking registration +const char *serverEndpoint = ""; + +// Preferences for NVS storage +Preferences preferences; + +// Function to connect to WiFi +void connectToWiFi() +{ + Serial.println("Connecting to WiFi..."); + WiFi.begin(ssid, password); + + while (WiFi.status() != WL_CONNECTED) + { + delay(1000); + Serial.println("Connecting..."); + } + + Serial.println("WiFi connected."); + Serial.print("IP Address: "); + Serial.println(WiFi.localIP()); +} + +// Function to get auth token from the server +String getAuthTokenFromServer(String macAddress) +{ + HTTPClient http; + http.begin(serverEndpoint); // Specify the server URL + + // Set the POST request content type + http.addHeader("Content-Type", "application/json"); + + // JSON body with mac_address + String jsonBody = "{\"mac_address\": \"" + macAddress + "\"}"; + + // Send POST request + int httpResponseCode = http.POST(jsonBody); + + // Check the response code + if (httpResponseCode > 0) + { + String response = http.getString(); + Serial.println("Server response: " + response); + + // Assuming response contains the auth token + return response; + } + else + { + Serial.print("Error on sending POST: "); + Serial.println(httpResponseCode); + return ""; + } + + // Close the HTTP connection + http.end(); +} + +// Function to store auth token in NVS +void storeAuthToken(String authToken) +{ + preferences.begin("auth", false); + preferences.putString("auth_token", authToken); + preferences.end(); + Serial.println("Auth token stored in NVS."); +} + +// Function to check registration status +void checkRegistration() +{ + String macAddress = WiFi.macAddress(); // Get the ESP32's MAC address + Serial.println("Checking registration for MAC Address: " + macAddress); + + // Get the auth token from the server + String authToken = getAuthTokenFromServer(macAddress); + + if (authToken != "") + { + storeAuthToken(authToken); // Store the auth token in NVS + } + else + { + Serial.println("Device not registered yet."); + } +} + +void setup() +{ + // Initialize serial for debugging + Serial.begin(115200); + + // Connect to Wi-Fi + connectToWiFi(); + + // Initialize NVS preferences + preferences.begin("auth", true); + String storedToken = preferences.getString("auth_token", ""); + preferences.end(); + + if (storedToken != "") + { + Serial.println("Auth token found in NVS: " + storedToken); + } + else + { + checkRegistration(); // Check registration if no token is found + } +} + +void loop() +{ + // Main loop logic (e.g., periodic updates, authentication, etc.) +} diff --git a/firmware-cpp/test/light_button_test.cpp b/firmware-cpp/test/light_button_test.cpp new file mode 100644 index 0000000..9e5cb2f --- /dev/null +++ b/firmware-cpp/test/light_button_test.cpp @@ -0,0 +1,59 @@ +#include + +// Define pin assignments +const int ledPin = 0; // GPIO 2 connected to LED +const int buttonPin = D5; // GPIO 15 connected to button + +// Variables for button state tracking +bool ledState = LOW; +bool buttonState = HIGH; +bool lastButtonState = HIGH; +unsigned long lastDebounceTime = 0; +unsigned long debounceDelay = 50; // Adjust debounce delay as needed + +void setup() +{ + // Initialize serial communication (optional) + Serial.begin(115200); + + // Set the LED pin as output + pinMode(ledPin, OUTPUT); + digitalWrite(ledPin, ledState); + + // Set the button pin as input with internal pull-up resistor + pinMode(buttonPin, INPUT_PULLUP); +} + +void loop() +{ + // Read the state of the button + int reading = digitalRead(buttonPin); + + // Check for state change (debounce) + if (reading != lastButtonState) + { + lastDebounceTime = millis(); // Reset debounce timer + } + + if ((millis() - lastDebounceTime) > debounceDelay) + { + // If the button state has changed + if (reading != buttonState) + { + buttonState = reading; + + // Only toggle the LED if the new button state is LOW (button pressed) + if (buttonState == LOW) + { + ledState = !ledState; // Toggle LED state + digitalWrite(ledPin, ledState); + + // Optional: print the LED state to the Serial Monitor + Serial.print("LED is now "); + Serial.println(ledState == HIGH ? "ON" : "OFF"); + } + } + } + + lastButtonState = reading; // Save the reading for next loop +} diff --git a/firmware-cpp/test/mic_test.cpp b/firmware-cpp/test/mic_test.cpp new file mode 100644 index 0000000..d53aea1 --- /dev/null +++ b/firmware-cpp/test/mic_test.cpp @@ -0,0 +1,73 @@ +/** + * @file streams-i2s-webserver_wav.ino + * + * This sketch reads sound data from I2S. The result is provided as WAV stream which can be listened to in a Web Browser + * + * **ADD THIS** + * lib_deps = + https://github.com/pschatzmann/arduino-audio-tools.git + * + * @author Phil Schatzmann + * @copyright GPLv3 + */ +#include +#include "AudioTools.h" + +const char *ssid = ""; +const char *password = ""; +// AudioEncodedServer server(new WAVEncoder(),"ssid","password"); +AudioWAVServer server(ssid, password); // the same a above + +I2SStream i2sStream; // Access I2S as stream +ConverterFillLeftAndRight filler(LeftIsEmpty); // fill both channels - or change to RightIsEmpty + +void setup() +{ + Serial.begin(115200); + AudioLogger::instance().begin(Serial, AudioLogger::Info); + + // // Connect to Wi-Fi + Serial.println("Connecting to WiFi..."); + WiFi.begin(ssid, password); + while (WiFi.status() != WL_CONNECTED) + { + delay(1000); + Serial.println("Connecting..."); + } + Serial.println("Connected to WiFi"); + + // Print the IP address + Serial.print("IP address: "); + Serial.println(WiFi.localIP()); + + // start i2s input with default configuration + Serial.println("starting I2S..."); + auto config = i2sStream.defaultConfig(RX_MODE); + // config.i2s_format = I2S_LSB_FORMAT; // if quality is bad change to I2S_LSB_FORMAT https://github.com/pschatzmann/arduino-audio-tools/issues/23 + // config.sample_rate = 22050; + // config.channels = 2; + // config.bits_per_sample = 32; + + // working well + config.i2s_format = I2S_STD_FORMAT; + config.sample_rate = 44100; // INMP441 supports up to 44.1kHz + config.channels = 1; // INMP441 is mono + config.bits_per_sample = 16; // INMP441 is a 24-bit ADC + + config.pin_ws = 19; // Adjust these pins according to your wiring + config.pin_bck = 18; + config.pin_data = 21; + config.use_apll = true; // Try with APLL for better clock stability + i2sStream.begin(config); + Serial.println("I2S started"); + + // start data sink + server.begin(i2sStream, config, &filler); +} + +// Arduino loop +void loop() +{ + // Handle new connections + server.copy(); +} \ No newline at end of file diff --git a/firmware-cpp/test/opus_audio_loop_test.cpp b/firmware-cpp/test/opus_audio_loop_test.cpp new file mode 100644 index 0000000..be62e2e --- /dev/null +++ b/firmware-cpp/test/opus_audio_loop_test.cpp @@ -0,0 +1,153 @@ +#include +#include +#include + +// serial +#define SERIAL_BAUD_RATE 115200 + +// audio speaker +#define AUDIO_SPEAKER_BCLK 26 +#define AUDIO_SPEAKER_LRC 13 +#define AUDIO_SPEAKER_DIN 25 + +// audio microphone +#define AUDIO_MIC_SD 2 +#define AUDIO_MIC_WS 15 +#define AUDIO_MIC_SCK 4 + +#define AUDIO_SAMPLE_RATE 8000 // 44100 +#define AUDIO_OPUS_FRAME_MS 40 // one of 2.5, 5, 10, 20, 40, 60, 80, 100, 120 +#define AUDIO_OPUS_BITRATE 3200 // bit rate from 2400 to 512000 +#define AUDIO_OPUS_COMPLEXITY 0 // from 0 to 10 + +OpusEncoder *opus_encoder_; +OpusDecoder *opus_decoder_; + +TaskHandle_t audio_task_; + +int16_t *opus_samples_; +int opus_samples_size_; + +int16_t *opus_samples_out_; +int opus_samples_out_size_; + +uint8_t *opus_bits_; +int opus_bits_size_ = 1024; + +void setup() { + LOG_SET_LEVEL(DebugLogLevel::LVL_INFO); + LOG_SET_OPTION(false, false, true); // disable file, line, enable func + + Serial.begin(SERIAL_BAUD_RATE); + while (!Serial); + LOG_INFO("Board setup started"); + + // create i2s speaker + i2s_config_t i2s_speaker_config = { + .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX), + .sample_rate = AUDIO_SAMPLE_RATE, + .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, + .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT, + .communication_format = (i2s_comm_format_t)(I2S_COMM_FORMAT_I2S | I2S_COMM_FORMAT_I2S_MSB), + .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, + .dma_buf_count = 8, + .dma_buf_len = 1024, + .use_apll=0, + .tx_desc_auto_clear= true, + .fixed_mclk=-1 + }; + i2s_pin_config_t i2s_speaker_pin_config = { + .bck_io_num = AUDIO_SPEAKER_BCLK, + .ws_io_num = AUDIO_SPEAKER_LRC, + .data_out_num = AUDIO_SPEAKER_DIN, + .data_in_num = I2S_PIN_NO_CHANGE + }; + if (i2s_driver_install(I2S_NUM_0, &i2s_speaker_config, 0, NULL) != ESP_OK) { + LOG_ERROR("Failed to install i2s speaker driver"); + } + if (i2s_set_pin(I2S_NUM_0, &i2s_speaker_pin_config) != ESP_OK) { + LOG_ERROR("Failed to set i2s speaker pins"); + } + + // create i2s microphone + i2s_config_t i2s_mic_config = { + .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX), + .sample_rate = AUDIO_SAMPLE_RATE, + .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, + .channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT, + .communication_format = (i2s_comm_format_t)(I2S_COMM_FORMAT_I2S | I2S_COMM_FORMAT_I2S_MSB), + .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, + .dma_buf_count = 8, + .dma_buf_len = 1024, + .use_apll=0, + .tx_desc_auto_clear= true, + .fixed_mclk=-1 + }; + i2s_pin_config_t i2s_mic_pin_config = { + .bck_io_num = AUDIO_MIC_SCK, + .ws_io_num = AUDIO_MIC_WS, + .data_out_num = I2S_PIN_NO_CHANGE, + .data_in_num = AUDIO_MIC_SD + }; + if (i2s_driver_install(I2S_NUM_1, &i2s_mic_config, 0, NULL) != ESP_OK) { + LOG_ERROR("Failed to install i2s mic driver"); + } + if (i2s_set_pin(I2S_NUM_1, &i2s_mic_pin_config) != ESP_OK) { + LOG_ERROR("Failed to set i2s mic pins"); + } + + // run codec2 audio loopback on a separate task + xTaskCreate(&audio_task, "audio_task", 32000, NULL, 5, &audio_task_); + + LOG_INFO("Board setup completed"); +} + +void audio_task(void *param) { + // configure encoder + int encoder_error; + opus_encoder_ = opus_encoder_create(AUDIO_SAMPLE_RATE, 1, OPUS_APPLICATION_VOIP, &encoder_error); + if (encoder_error != OPUS_OK) { + LOG_ERROR("Failed to create OPUS encoder, error", encoder_error); + return; + } + encoder_error = opus_encoder_init(opus_encoder_, AUDIO_SAMPLE_RATE, 1, OPUS_APPLICATION_VOIP); + if (encoder_error != OPUS_OK) { + LOG_ERROR("Failed to initialize OPUS encoder, error", encoder_error); + return; + } + opus_encoder_ctl(opus_encoder_, OPUS_SET_BITRATE(AUDIO_OPUS_BITRATE)); + opus_encoder_ctl(opus_encoder_, OPUS_SET_COMPLEXITY(AUDIO_OPUS_COMPLEXITY)); + opus_encoder_ctl(opus_encoder_, OPUS_SET_SIGNAL(OPUS_SIGNAL_VOICE)); + + // configure decoder + int decoder_error; + opus_decoder_ = opus_decoder_create(AUDIO_SAMPLE_RATE, 1, &decoder_error); + if (decoder_error != OPUS_OK) { + LOG_ERROR("Failed to create OPUS decoder, error", decoder_error); + return; + } + + opus_samples_size_ = (int)(AUDIO_SAMPLE_RATE / 1000 * AUDIO_OPUS_FRAME_MS); + opus_samples_ = (int16_t*)malloc(sizeof(int16_t) * opus_samples_size_); + opus_samples_out_size_ = 10 * opus_samples_size_; + opus_samples_out_ = (int16_t*)malloc(sizeof(int16_t) * opus_samples_out_size_); + + opus_bits_ = (uint8_t*)malloc(sizeof(uint8_t) * opus_bits_size_); + + // run loopback record-encode-decode-playback loop + size_t bytes_read, bytes_written; + LOG_INFO("Audio task started"); + while(true) { + i2s_read(I2S_NUM_1, opus_samples_, sizeof(uint16_t) * opus_samples_size_, &bytes_read, portMAX_DELAY); + int encoded_size = opus_encode(opus_encoder_, opus_samples_, opus_samples_size_, opus_bits_, opus_bits_size_); + vTaskDelay(1); + int decoded_size = opus_decode(opus_decoder_, opus_bits_, encoded_size, opus_samples_out_, opus_samples_out_size_, 0); + i2s_write(I2S_NUM_0, opus_samples_out_, sizeof(uint16_t) * decoded_size, &bytes_written, portMAX_DELAY); + vTaskDelay(1); + } +} + +void loop() { + // do nothing + delay(100); +} \ No newline at end of file diff --git a/firmware-cpp/test/opus_test.cpp b/firmware-cpp/test/opus_test.cpp new file mode 100644 index 0000000..3d53638 --- /dev/null +++ b/firmware-cpp/test/opus_test.cpp @@ -0,0 +1,121 @@ +/** + * @file test-codec-opus.ino + * @author Phil Schatzmann + * @brief generate sine wave -> encoder -> decoder -> audiokit (i2s) + * @version 0.1 + * @date 2022-04-30 + * + * @copyright Copyright (c) 2022 + * + */ +#include "AudioTools.h" +#include "AudioTools/AudioLibs/AudioBoardStream.h" +#include "AudioTools/AudioCodecs/CodecOpus.h" +#include "Button.h" + +esp_err_t getErr = ESP_OK; + +const gpio_num_t BUTTON_PIN = GPIO_NUM_2; + +void enterSleep() +{ + Serial.println("Going to sleep..."); + + // Flush any remaining serial output + Serial.flush(); + + // Now enter deep sleep + esp_deep_sleep_start(); +} + +void printOutESP32Error(esp_err_t err) +{ + switch (err) + { + case ESP_OK: + Serial.println("ESP_OK no errors"); + break; + case ESP_ERR_INVALID_ARG: + Serial.println("ESP_ERR_INVALID_ARG if the selected GPIO is not an RTC GPIO, or the mode is invalid"); + break; + case ESP_ERR_INVALID_STATE: + Serial.println("ESP_ERR_INVALID_STATE if wakeup triggers conflict or wireless not stopped"); + break; + default: + Serial.printf("Unknown error code: %d\n", err); + break; + } +} + +static void onButtonLongPressUpEventCb(void *button_handle, void *usr_data) +{ + Serial.println("Button long press end"); + delay(10); + enterSleep(); +} + +static void onButtonDoubleClickCb(void *button_handle, void *usr_data) +{ + Serial.println("Button double click"); + delay(10); + enterSleep(); +} + + + +AudioInfo info(24000, 1, 16); + +SineWaveGenerator sineWave( 32000); // subclass of SoundGenerator with max amplitude of 32000 +GeneratedSoundStream sound( sineWave); // Stream generated from sine wave +I2SStream out; +OpusAudioDecoder dec; +OpusAudioEncoder enc; +EncodedAudioStream decoder(&out, &dec); // encode and write +EncodedAudioStream encoder(&decoder, &enc); // encode and write +StreamCopy copier(encoder, sound); + +void setup() { + Serial.begin(115200); + AudioToolsLogger.begin(Serial, AudioToolsLogLevel::Warning); + + // start I2S + Serial.println("starting I2S..."); + auto cfgi = out.defaultConfig(TX_MODE); + +cfgi.pin_bck = 6; + cfgi.pin_ws = 5; + cfgi.pin_data = 7; + cfgi.channels = 1; + + cfgi.copyFrom(info); + out.begin(cfgi); + // Setup sine wave + sineWave.begin(info, N_B4); + + // Opus encoder and decoder need to know the audio info + decoder.begin(info); + encoder.begin(info); + + getErr = esp_sleep_enable_ext0_wakeup(BUTTON_PIN, LOW); + printOutESP32Error(getErr); + + // BUTTON + Button *btn = new Button(BUTTON_PIN, false); + btn->attachLongPressUpEventCb(&onButtonLongPressUpEventCb, NULL); + btn->attachDoubleClickEventCb(&onButtonDoubleClickCb, NULL); + btn->detachSingleClickEvent(); + + + // configure additinal parameters + // auto &enc_cfg = enc.config() + // enc_cfg.application = OPUS_APPLICATION_RESTRICTED_LOWDELAY; + // enc_cfg.frame_sizes_ms_x2 = OPUS_FRAMESIZE_20_MS; + // enc_cfg.complexity = 5; + + Serial.println("Test started..."); +} + + +void loop() { + copier.copy(); +} \ No newline at end of file diff --git a/firmware-cpp/test/ota_test.cpp b/firmware-cpp/test/ota_test.cpp new file mode 100644 index 0000000..5e2c7dc --- /dev/null +++ b/firmware-cpp/test/ota_test.cpp @@ -0,0 +1,69 @@ +// This sketch provide the functionality of OTA Firmware Upgrade +#include "WiFi.h" +#include "HttpsOTAUpdate.h" +// This sketch shows how to implement HTTPS firmware update Over The Air. +// Please provide your WiFi credentials, https URL to the firmware image and the server certificate. + +static const char *ssid = "EE-PPA1GZ"; // your network SSID (name of wifi network) +static const char *password = "9JkyRJHXTDTKb3"; // your network password + +#define TOUCH_MODE + +#ifdef TOUCH_MODE +static const char *url = "https://elato.s3.us-east-1.amazonaws.com/firmware-touch.bin"; //state url of your firmware image +#else +static const char *url = "https://elato.s3.us-east-1.amazonaws.com/firmware-button.bin"; //state url of your firmware image +#endif + +static const char *server_certificate = R"EOF( +-----BEGIN CERTIFICATE----- + +-----END CERTIFICATE----- +)EOF"; + +static HttpsOTAStatus_t otastatus; + +void HttpEventCb(HttpEvent_t *event) { + switch (event->event_id) { + case HTTP_EVENT_ERROR: Serial.println("Http Event Error"); break; + case HTTP_EVENT_ON_CONNECTED: Serial.println("Http Event On Connected"); break; + case HTTP_EVENT_HEADER_SENT: Serial.println("Http Event Header Sent"); break; + case HTTP_EVENT_ON_HEADER: Serial.printf("Http Event On Header, key=%s, value=%s\n", event->header_key, event->header_value); break; + case HTTP_EVENT_ON_DATA: break; + case HTTP_EVENT_ON_FINISH: Serial.println("Http Event On Finish"); break; + case HTTP_EVENT_DISCONNECTED: Serial.println("Http Event Disconnected"); break; + } +} + +void setup() { + + Serial.begin(115200); + Serial.print("Attempting to connect to SSID: "); + WiFi.begin(ssid, password); + + // attempt to connect to Wifi network: + while (WiFi.status() != WL_CONNECTED) { + Serial.print("."); + delay(1000); + } + + Serial.print("Connected to "); + Serial.println(ssid); + + HttpsOTA.onHttpEvent(HttpEventCb); + Serial.println("Starting OTA"); + HttpsOTA.begin(url, server_certificate); + + Serial.println("Please Wait it takes some time ..."); +} + +void loop() { + otastatus = HttpsOTA.status(); + if (otastatus == HTTPS_OTA_SUCCESS) { + Serial.println("Firmware written successfully. To reboot device, call API ESP.restart() or PUSH restart button on device"); + ESP.restart(); + } else if (otastatus == HTTPS_OTA_FAIL) { + Serial.println("Firmware Upgrade Fail"); + } + delay(1000); +} diff --git a/firmware-cpp/test/print_mac_address_test.cpp b/firmware-cpp/test/print_mac_address_test.cpp new file mode 100644 index 0000000..fdbafd6 --- /dev/null +++ b/firmware-cpp/test/print_mac_address_test.cpp @@ -0,0 +1,17 @@ +#include + +void setup() { + Serial.begin(115200); + delay(1000); + + WiFi.mode(WIFI_STA); // Ensure WiFi is initialized +} + +void loop() { + // Print MAC address using the simple WiFi.macAddress() method + Serial.print("Wi-Fi MAC Address: "); + Serial.println(WiFi.macAddress()); + + // Delay for 1 second before printing again + delay(1000); +} \ No newline at end of file diff --git a/firmware-cpp/test/rgb_led_test.cpp b/firmware-cpp/test/rgb_led_test.cpp new file mode 100644 index 0000000..6ff8a8c --- /dev/null +++ b/firmware-cpp/test/rgb_led_test.cpp @@ -0,0 +1,36 @@ +#include + +int redPin = 9; // Red LED pin +int greenPin = 8; // Green LED pin +int bluePin = 13; // Blue LED pin + +void setup() +{ + pinMode(redPin, OUTPUT); + pinMode(greenPin, OUTPUT); + pinMode(bluePin, OUTPUT); +} + +void loop() +{ + // Example: turn Red on (LOW), Green off (HIGH), Blue off (HIGH) for Common Anode + digitalWrite(redPin, LOW); // Red on + digitalWrite(greenPin, HIGH); // Green off + digitalWrite(bluePin, HIGH); // Blue off + + delay(1000); // Wait 1 second + + // Turn Green on and others off + digitalWrite(redPin, HIGH); // Red off + digitalWrite(greenPin, LOW); // Green on + digitalWrite(bluePin, HIGH); // Blue off + + delay(1000); // Wait 1 second + + // Turn Green on and others off + digitalWrite(redPin, HIGH); // Red off + digitalWrite(greenPin, HIGH); // Green on + digitalWrite(bluePin, LOW); // Blue off + + delay(1000); // Wait 1 second +} \ No newline at end of file diff --git a/firmware-cpp/test/simple_led_test.cpp b/firmware-cpp/test/simple_led_test.cpp new file mode 100644 index 0000000..2ef67d3 --- /dev/null +++ b/firmware-cpp/test/simple_led_test.cpp @@ -0,0 +1,18 @@ +#include + +// define led according to pin diagram in article +const int led = D10; // there is no LED_BUILTIN available for the XIAO ESP32C3. + +void setup() +{ + // initialize digital pin led as an output + pinMode(led, OUTPUT); +} + +void loop() +{ + digitalWrite(led, HIGH); // turn the LED on + delay(1000); // wait for a second + digitalWrite(led, LOW); // turn the LED off + delay(1000); // wait for a second +} \ No newline at end of file diff --git a/firmware-cpp/test/speaker_radio_shutdown_test.cpp b/firmware-cpp/test/speaker_radio_shutdown_test.cpp new file mode 100644 index 0000000..34d9bf4 --- /dev/null +++ b/firmware-cpp/test/speaker_radio_shutdown_test.cpp @@ -0,0 +1,198 @@ +#include +/* + Simple Internet Radio Demo + esp32-i2s-simple-radio.ino + Simple ESP32 I2S radio + Uses MAX98357 I2S Amplifier Module + Uses ESP32-audioI2S Library - https://github.com/schreibfaul1/ESP32-audioI2S + + ** ADD THIS ** + lib_deps = + esphome/ESP32-audioI2S@^2.0.7 + + + DroneBot Workshop 2022 + https://dronebotworkshop.com +*/ + +// Include required libraries +#include "WiFi.h" +#include "Audio.h" + +// Define I2S connections +#define I2S_LRC D0 +#define I2S_BCLK D1 +#define I2S_DOUT D2 +#define I2S_SD D3 +#define BUTTON_PIN 0 + +// #define I2S_LRC 18 +// #define I2S_BCLK 21 +// #define I2S_DOUT 17 + +// Create audio object +Audio audio; + +// // Wifi Credentials +String ssid = ""; +String password = ""; + +bool isPlaying = true; // Track whether audio is playing or not +unsigned long lastDebounceTime = 0; // for debouncing +unsigned long debounceDelay = 50; // debounce delay in ms +int lastButtonState = HIGH; // the previous button state +int buttonState = HIGH; // current button state + +// Toggle audio playback by controlling the SD pin +void toggleAudio() +{ + if (isPlaying) + { + // Mute the amplifier (pull SD_PIN low) + digitalWrite(I2S_SD, LOW); + Serial.println("Audio Muted"); + } + else + { + // Unmute the amplifier (pull SD_PIN high) + digitalWrite(I2S_SD, HIGH); + Serial.println("Audio Unmuted"); + } + isPlaying = !isPlaying; // Toggle playback state +} + +void setup() +{ + + // Start Serial Monitor + Serial.begin(115200); + + // Setup WiFi in Station mode + WiFi.disconnect(); + WiFi.mode(WIFI_STA); + WiFi.begin(ssid.c_str(), password.c_str()); + + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print("."); + } + + // WiFi Connected, print IP to serial monitor + Serial.println(""); + Serial.println("WiFi connected"); + Serial.println("IP address: "); + Serial.println(WiFi.localIP()); + Serial.println(""); + + // Connect MAX98357 I2S Amplifier Module + audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT); + + // Set thevolume (0-100) + audio.setVolume(10); + + // Connect to an Internet radio station (select one as desired) + // audio.connecttohost("http://vis.media-ice.musicradio.com/CapitalMP3"); + // audio.connecttohost("mediaserv30.live-nect MAX98357 I2S Amplifier Module + // audio.connecttohost("www.surfmusic.de/m3u/100-5-das-hitradio,4529.m3u"); + // audio.connecttohost("stream.1a-webradio.de/deutsch/mp3-128/vtuner-1a"); + // audio.connecttohost("www.antenne.de/webradio/antenne.m3u"); + audio.connecttohost("0n-80s.radionetz.de:8000/0n-70s.mp3"); + + // Set SD_PIN as output and initialize to HIGH (unmuted) + pinMode(I2S_SD, OUTPUT); + digitalWrite(I2S_SD, HIGH); + + // Set BUTTON_PIN as input with internal pull-up resistor + pinMode(BUTTON_PIN, INPUT_PULLUP); +} + +void loop() + +{ + // Run audio player + audio.loop(); + + // Read the button state + int reading = digitalRead(BUTTON_PIN); + + // Check for button press (debounced) + if (reading != lastButtonState) + { + lastDebounceTime = millis(); + } + + if ((millis() - lastDebounceTime) > debounceDelay) + { + // Check if the button was pressed (LOW) and change state + if (reading == LOW) + { + if (buttonState == HIGH) + { // Only toggle on button press, not release + toggleAudio(); + } + } + buttonState = reading; + } + + // Update the last button state + lastButtonState = reading; +} + +// Audio status functions + +void audio_info(const char *info) +{ + Serial.print("info "); + Serial.println(info); +} +void audio_id3data(const char *info) +{ // id3 metadata + Serial.print("id3data "); + Serial.println(info); +} +void audio_eof_mp3(const char *info) +{ // end of file + Serial.print("eof_mp3 "); + Serial.println(info); +} +void audio_showstation(const char *info) +{ + Serial.print("station "); + Serial.println(info); +} +void audio_showstreaminfo(const char *info) +{ + Serial.print("streaminfo "); + Serial.println(info); +} +void audio_showstreamtitle(const char *info) +{ + Serial.print("streamtitle "); + Serial.println(info); +} +void audio_bitrate(const char *info) +{ + Serial.print("bitrate "); + Serial.println(info); +} +void audio_commercial(const char *info) +{ // duration in sec + Serial.print("commercial "); + Serial.println(info); +} +void audio_icyurl(const char *info) +{ // homepage + Serial.print("icyurl "); + Serial.println(info); +} +void audio_lasthost(const char *info) +{ // stream URL played + Serial.print("lasthost "); + Serial.println(info); +} +void audio_eof_speech(const char *info) +{ + Serial.print("eof_speech "); + Serial.println(info); +} \ No newline at end of file diff --git a/firmware-cpp/test/speaker_radio_test.cpp b/firmware-cpp/test/speaker_radio_test.cpp new file mode 100644 index 0000000..610a395 --- /dev/null +++ b/firmware-cpp/test/speaker_radio_test.cpp @@ -0,0 +1,146 @@ + +#include +/* + Simple Internet Radio Demo + esp32-i2s-simple-radio.ino + Simple ESP32 I2S radio + Uses MAX98357 I2S Amplifier Module + Uses ESP32-audioI2S Library - https://github.com/schreibfaul1/ESP32-audioI2S + + ** ADD THIS ** + lib_deps = + esphome/ESP32-audioI2S@^2.0.7 + + DroneBot Workshop 2022 + https://dronebotworkshop.com +*/ + +// Include required libraries +#include "WiFi.h" +#include "Audio.h" + +// Define I2S connections +#define I2S_LRC D0 +#define I2S_BCLK D1 +#define I2S_DOUT D2 +#define I2S_SD_OUT D3 + +// #define I2S_LRC 18 +// #define I2S_BCLK 21 +// #define I2S_DOUT 17 + +// Create audio object +Audio audio; + +// // Wifi Credentials +String ssid = ""; // replace your WiFi name +String password = ""; // replace your WiFi password + +void setup() +{ + // Start Serial Monitor + Serial.begin(115200); + + Serial.println("Connecting to WiFi"); + + // Setup WiFi in Station mode + WiFi.disconnect(); + WiFi.mode(WIFI_STA); + WiFi.begin(ssid.c_str(), password.c_str()); + + // Set SD_PIN as output and initialize to HIGH (unmuted) + pinMode(I2S_SD_OUT, OUTPUT); + digitalWrite(I2S_SD_OUT, HIGH); + + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print("."); + } + + // WiFi Connected, print IP to serial monitor + Serial.println(""); + Serial.println("WiFi connected"); + Serial.println("IP address: "); + Serial.println(WiFi.localIP()); + Serial.println(""); + + // Connect MAX98357 I2S Amplifier Module + audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT); + + // Set thevolume (0-100) + audio.setVolume(10); + + // Connect to an Internet radio station (select one as desired) + // audio.connecttohost("http://vis.media-ice.musicradio.com/CapitalMP3"); + // audio.connecttohost("mediaserv30.live-nect MAX98357 I2S Amplifier Module + // audio.connecttohost("www.surfmusic.de/m3u/100-5-das-hitradio,4529.m3u"); + // audio.connecttohost("stream.1a-webradio.de/deutsch/mp3-128/vtuner-1a"); + // audio.connecttohost("www.antenne.de/webradio/antenne.m3u"); + audio.connecttohost("http://vis.media-ice.musicradio.com/CapitalMP3"); +} + +void loop() + +{ + // Run audio player + audio.loop(); +} + +// Audio status functions + +void audio_info(const char *info) +{ + Serial.print("info "); + Serial.println(info); +} +void audio_id3data(const char *info) +{ // id3 metadata + Serial.print("id3data "); + Serial.println(info); +} +void audio_eof_mp3(const char *info) +{ // end of file + Serial.print("eof_mp3 "); + Serial.println(info); +} +void audio_showstation(const char *info) +{ + Serial.print("station "); + Serial.println(info); +} +void audio_showstreaminfo(const char *info) +{ + Serial.print("streaminfo "); + Serial.println(info); +} +void audio_showstreamtitle(const char *info) +{ + Serial.print("streamtitle "); + Serial.println(info); +} +void audio_bitrate(const char *info) +{ + Serial.print("bitrate "); + Serial.println(info); +} +void audio_commercial(const char *info) +{ // duration in sec + Serial.print("commercial "); + Serial.println(info); +} +void audio_icyurl(const char *info) +{ // homepage + Serial.print("icyurl "); + Serial.println(info); +} +void audio_lasthost(const char *info) +{ // stream URL played + Serial.print("lasthost "); + Serial.println(info); +} +void audio_eof_speech(const char *info) +{ + Serial.print("eof_speech "); + Serial.println(info); +} \ No newline at end of file diff --git a/firmware-cpp/test/touch_led_test.cpp b/firmware-cpp/test/touch_led_test.cpp new file mode 100644 index 0000000..bd3f02f --- /dev/null +++ b/firmware-cpp/test/touch_led_test.cpp @@ -0,0 +1,123 @@ +#include +#include +#include + +// Pin definitions +#define TOUCH_PIN 2 +#define RED_LED_PIN 8 +#define GREEN_LED_PIN 9 +#define BLUE_LED_PIN 13 + +// Touch sensor threshold (adjust based on your environment) +#define TOUCH_THRESHOLD 40 + +// Color sequence and timing +const uint8_t colorSequence[][3] = { + {0, 255, 255}, // Cyan + {255, 0, 255}, // Pink + {255, 255, 0}, // Yellow +}; +const int NUM_COLORS = sizeof(colorSequence) / sizeof(colorSequence[0]); + +// RTOS task handle and state variables +TaskHandle_t colorPulseTaskHandle = NULL; +volatile bool ledOn = false; + +void setup() { + Serial.begin(115200); + + // Initialize LED pins + pinMode(RED_LED_PIN, OUTPUT); + pinMode(GREEN_LED_PIN, OUTPUT); + pinMode(BLUE_LED_PIN, OUTPUT); + analogWrite(RED_LED_PIN, 0); + analogWrite(GREEN_LED_PIN, 0); + analogWrite(BLUE_LED_PIN, 0); + + // Create color pulse task (suspended initially) + xTaskCreate( + colorPulseTask, // Task function + "ColorPulse", // Task name + 4096, // Stack size + NULL, // Parameters + 1, // Priority + &colorPulseTaskHandle + ); + vTaskSuspend(colorPulseTaskHandle); +} + +void loop() { + static bool lastTouchState = false; + static unsigned long lastDebounceTime = 0; + const unsigned long debounceDelay = 50; + + // Read touch sensor + int touchValue = touchRead(TOUCH_PIN); + bool currentTouchState = (touchValue < TOUCH_THRESHOLD); + + // Debounce logic + if (currentTouchState != lastTouchState) { + lastDebounceTime = millis(); + } + + if ((millis() - lastDebounceTime) > debounceDelay) { + if (currentTouchState && !lastTouchState) { + ledOn = !ledOn; + if (ledOn) { + vTaskResume(colorPulseTaskHandle); + } else { + vTaskSuspend(colorPulseTaskHandle); + analogWrite(RED_LED_PIN, 0); + analogWrite(GREEN_LED_PIN, 0); + analogWrite(BLUE_LED_PIN, 0); + } + } + lastTouchState = currentTouchState; + } + + vTaskDelay(10 / portTICK_PERIOD_MS); // Yield to other tasks +} + +void colorPulseTask(void *pvParameters) { + while (1) { + unsigned long currentTime = millis(); + loopCyanPinkYellowPulse(currentTime); + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void loopCyanPinkYellowPulse(unsigned long currentTime) { + const unsigned long transitionDuration = 1000; // 1 second per transition + + static int colorIndex = 0; + static uint8_t startColor[3]; + static uint8_t endColor[3]; + static unsigned long transitionStartTime = 0; + static bool initialized = false; + + if (!initialized) { + memcpy(startColor, colorSequence[colorIndex], 3); + memcpy(endColor, colorSequence[(colorIndex + 1) % NUM_COLORS], 3); + transitionStartTime = currentTime; + initialized = true; + } + + unsigned long elapsed = currentTime - transitionStartTime; + float t = (float)elapsed / (float)transitionDuration; + t = t > 1.0f ? 1.0f : t; + + uint8_t r = startColor[0] + (endColor[0] - startColor[0]) * t; + uint8_t g = startColor[1] + (endColor[1] - startColor[1]) * t; + uint8_t b = startColor[2] + (endColor[2] - startColor[2]) * t; + + analogWrite(RED_LED_PIN, r); + analogWrite(GREEN_LED_PIN, g); + analogWrite(BLUE_LED_PIN, b); + + if (elapsed >= transitionDuration) { + colorIndex = (colorIndex + 1) % NUM_COLORS; + memcpy(startColor, endColor, 3); + memcpy(endColor, colorSequence[(colorIndex + 1) % NUM_COLORS], 3); + transitionStartTime = currentTime; + } +} \ No newline at end of file diff --git a/firmware-cpp/test/touch_sleep.cpp b/firmware-cpp/test/touch_sleep.cpp new file mode 100644 index 0000000..babe7c7 --- /dev/null +++ b/firmware-cpp/test/touch_sleep.cpp @@ -0,0 +1,149 @@ +#include +#include +#include + +// Use touch pad channel 2 (T2) +#define TOUCH_PAD_CHANNEL TOUCH_PAD_NUM2 +// Set the threshold value – adjust this value for your hardware +#define TOUCH_THRESHOLD 60000 +// Duration (in milliseconds) required for a long press +#define LONG_PRESS_MS 1000 + +// Task handles (optional, for task management) +TaskHandle_t fibTaskHandle = NULL; +TaskHandle_t touchTaskHandle = NULL; + +// Define RGB LED pins +#define READ_PIN_T 8 +#define GREEN_PIN_T 9 +#define BLUE_PIN_T 13 + +//------------------------------------------------------------------------------ +// RGB LED Task: Cycle through colors every 1 second +//------------------------------------------------------------------------------ +void ledTaskT(void* parameter) { + // Set pins as outputs + pinMode(READ_PIN_T, OUTPUT); + pinMode(GREEN_PIN_T, OUTPUT); + pinMode(BLUE_PIN_T, OUTPUT); + + while (true) { + // Red + digitalWrite(READ_PIN_T, HIGH); + digitalWrite(GREEN_PIN_T, LOW); + digitalWrite(BLUE_PIN_T, LOW); + delay(1000); + + // Green + digitalWrite(READ_PIN_T, LOW); + digitalWrite(GREEN_PIN_T, HIGH); + digitalWrite(BLUE_PIN_T, LOW); + delay(1000); + + // Blue + digitalWrite(READ_PIN_T, LOW); + digitalWrite(GREEN_PIN_T, LOW); + digitalWrite(BLUE_PIN_T, HIGH); + delay(1000); + } +} + +//------------------------------------------------------------------------------ +// enterSleep: Wait for a stable release, enable touch wake, and enter deep sleep +//------------------------------------------------------------------------------ +void enterSleep() { + Serial.println("Preparing to sleep..."); + + // Wait until the touch pad reading shows "release" + // (Assuming that a reading above TOUCH_THRESHOLD means a touch.) + while (touchRead(TOUCH_PAD_CHANNEL) > TOUCH_THRESHOLD) { + delay(50); + } + // Extra delay to allow any residual contact or noise to settle + delay(500); + + // Enable touchpad wakeup using the Arduino API. + // This configures the ESP32 so that a new touch on channel 2 will wake it. + touchSleepWakeUpEnable(TOUCH_PAD_CHANNEL, TOUCH_THRESHOLD); + + Serial.println("Entering deep sleep now."); + esp_deep_sleep_start(); + // Execution stops here until a wakeup occurs. +} + +//------------------------------------------------------------------------------ +// Touch Task: Monitor the touch pad to detect a long press and trigger sleep +//------------------------------------------------------------------------------ +void touchTask(void* parameter) { + touch_pad_init(); + touch_pad_config(TOUCH_PAD_NUM2); + + bool touched = false; + unsigned long pressStartTime = 0; + + while (true) { + // Read the touch sensor + uint32_t touchValue = touchRead(TOUCH_PAD_CHANNEL); + Serial.printf("Touch Pad Value: %u\n", touchValue); + + // On the ESP32-S3, a reading above TOUCH_THRESHOLD indicates a touch. + bool isTouched = (touchValue > TOUCH_THRESHOLD); + + // Detect transition from "not touched" to "touched" + if (isTouched && !touched) { + touched = true; + pressStartTime = millis(); + Serial.println("Touch detected – press started."); + } + + // Detect transition from "touched" to "released" + if (!isTouched && touched) { + touched = false; + unsigned long pressDuration = millis() - pressStartTime; + if (pressDuration >= LONG_PRESS_MS) { + Serial.print("Long press detected ("); + Serial.print(pressDuration); + Serial.println(" ms) – going to sleep."); + // Call enterSleep() which will wait for a stable release, enable wake, and sleep. + enterSleep(); + // (The device will reset on wake, so code execution won't continue here.) + } else { + Serial.print("Short press detected ("); + Serial.print(pressDuration); + Serial.println(" ms) – ignoring."); + } + } + + delay(50); // Small delay to avoid spamming readings + } + // (This point is never reached.) + vTaskDelete(NULL); +} + +//------------------------------------------------------------------------------ +// setup: Initialize Serial, print wakeup cause, and create tasks +//------------------------------------------------------------------------------ +void setup() { + Serial.begin(115200); + delay(500); + + // Check the wakeup cause and print a message + esp_sleep_wakeup_cause_t wakeupCause = esp_sleep_get_wakeup_cause(); + if (wakeupCause == ESP_SLEEP_WAKEUP_TOUCHPAD) { + Serial.println("Woke up from touchpad deep sleep."); + } else { + Serial.println("Normal startup."); + } + + // Create the Fibonacci task (prints every 1 second) + xTaskCreate(ledTaskT, "FibonacciTask", 2048, NULL, 1, &fibTaskHandle); + // Create the Touch task (monitors the touch pad) + xTaskCreate(touchTask, "TouchTask", 2048, NULL, 1, &touchTaskHandle); +} + +//------------------------------------------------------------------------------ +// loop: Not used as tasks handle all work +//------------------------------------------------------------------------------ +void loop() { + // Nothing to do here. +} diff --git a/firmware-cpp/test/touch_test.cpp b/firmware-cpp/test/touch_test.cpp new file mode 100644 index 0000000..8ab2162 --- /dev/null +++ b/firmware-cpp/test/touch_test.cpp @@ -0,0 +1,29 @@ +#include + +// Adjust to the pin you are using: +#define TOUCH_PIN 2 + +void setup() { + Serial.begin(115200); + while (!Serial); // wait for Serial to be ready + + Serial.println("ESP32 Touch Threshold Test"); + Serial.println("Touch or release the pad and watch the values."); + Serial.println("Use these values to decide on a threshold."); +} + +void loop() { + uint16_t touchValue = touchRead(TOUCH_PIN); + Serial.println(touchValue); + + // If you'd like to show a quick guess for touched vs not touched, + // you can temporarily hardcode a threshold for testing: + // int threshold = 40; + // if (touchValue < threshold) { + // Serial.println("Touched!"); + // } else { + // Serial.println("Not touched"); + // } + + delay(250); // read ~4 times per second +} diff --git a/firmware-cpp/test/wifi_manager_test.cpp b/firmware-cpp/test/wifi_manager_test.cpp new file mode 100644 index 0000000..2b793b8 --- /dev/null +++ b/firmware-cpp/test/wifi_manager_test.cpp @@ -0,0 +1,139 @@ +#include +/* + Simple Internet Radio Demo + esp32-i2s-simple-radio.ino + Simple ESP32 I2S radio + Uses MAX98357 I2S Amplifier Module + Uses ESP32-audioI2S Library - https://github.com/schreibfaul1/ESP32-audioI2S + +**ADD THIS** +lib_deps = + https://github.com/tzapu/WiFiManager.git + + DroneBot Workshop 2022 + https://dronebotworkshop.com +*/ + +// Include required libraries +#include // Include the WiFiManager library +#include "Audio.h" + +// Define I2S connections +#define I2S_LRC D0 +#define I2S_BCLK D1 +#define I2S_DOUT D2 +#define I2S_SD_OUT D3 + +// #define I2S_LRC 18 +// #define I2S_BCLK 21 +// #define I2S_DOUT 17 + +// Create audio object +Audio audio; + +void setup() +{ + + // Start Serial Monitor + Serial.begin(115200); + + // Set SD_PIN as output and initialize to HIGH (unmuted) + pinMode(I2S_SD_OUT, OUTPUT); + digitalWrite(I2S_SD_OUT, HIGH); + + // Initialize WiFiManager + WiFiManager wifiManager; + + // Uncomment for testing to reset saved settings + // wifiManager.resetSettings(); + + // Automatically connect using saved credentials, + // or start the captive portal to enter new credentials + if (!wifiManager.autoConnect("ESP32RadioAP")) + { + Serial.println("Failed to connect and hit timeout"); + ESP.restart(); // Optionally, restart or handle the failure + } + + // If you get here, you have connected to the Wi-Fi + Serial.println("Connected to Wi-Fi!"); + + // Connect MAX98357 I2S Amplifier Module + audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT); + + // Set thevolume (0-100) + audio.setVolume(90); + + // Connect to an Internet radio station (select one as desired) + // audio.connecttohost("http://vis.media-ice.musicradio.com/CapitalMP3"); + // audio.connecttohost("mediaserv30.live-nect MAX98357 I2S Amplifier Module + // audio.connecttohost("www.surfmusic.de/m3u/100-5-das-hitradio,4529.m3u"); + // audio.connecttohost("stream.1a-webradio.de/deutsch/mp3-128/vtuner-1a"); + // audio.connecttohost("www.antenne.de/webradio/antenne.m3u"); + audio.connecttohost("0n-80s.radionetz.de:8000/0n-70s.mp3"); +} + +void loop() + +{ + // Run audio player + audio.loop(); +} + +// Audio status functions + +void audio_info(const char *info) +{ + Serial.print("info "); + Serial.println(info); +} +void audio_id3data(const char *info) +{ // id3 metadata + Serial.print("id3data "); + Serial.println(info); +} +void audio_eof_mp3(const char *info) +{ // end of file + Serial.print("eof_mp3 "); + Serial.println(info); +} +void audio_showstation(const char *info) +{ + Serial.print("station "); + Serial.println(info); +} +void audio_showstreaminfo(const char *info) +{ + Serial.print("streaminfo "); + Serial.println(info); +} +void audio_showstreamtitle(const char *info) +{ + Serial.print("streamtitle "); + Serial.println(info); +} +void audio_bitrate(const char *info) +{ + Serial.print("bitrate "); + Serial.println(info); +} +void audio_commercial(const char *info) +{ // duration in sec + Serial.print("commercial "); + Serial.println(info); +} +void audio_icyurl(const char *info) +{ // homepage + Serial.print("icyurl "); + Serial.println(info); +} +void audio_lasthost(const char *info) +{ // stream URL played + Serial.print("lasthost "); + Serial.println(info); +} +void audio_eof_speech(const char *info) +{ + Serial.print("eof_speech "); + Serial.println(info); +} \ No newline at end of file diff --git a/frontend-nextjs/.DS_Store b/frontend-nextjs/.DS_Store new file mode 100644 index 0000000..08b54b9 Binary files /dev/null and b/frontend-nextjs/.DS_Store differ diff --git a/frontend-nextjs/.env.example b/frontend-nextjs/.env.example new file mode 100644 index 0000000..bee8eb2 --- /dev/null +++ b/frontend-nextjs/.env.example @@ -0,0 +1,16 @@ + +# Update these with your Supabase details from your project settings > API +# https://app.supabase.com/project/_/settings/api +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +JWT_SECRET_KEY= +ENCRYPTION_KEY= +OPENAI_API_KEY= +ENCRYPTION_KEY= + +# NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321 +GOOGLE_OAUTH=True + +# Stripe +STRIPE_SECRET_KEY= +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= diff --git a/frontend-nextjs/.gitignore b/frontend-nextjs/.gitignore new file mode 100644 index 0000000..3fd5f43 --- /dev/null +++ b/frontend-nextjs/.gitignore @@ -0,0 +1,4 @@ +node_modules +.env +.env.local +.next diff --git a/frontend-nextjs/Dockerfile b/frontend-nextjs/Dockerfile new file mode 100644 index 0000000..92270c8 --- /dev/null +++ b/frontend-nextjs/Dockerfile @@ -0,0 +1,83 @@ +# FROM --platform=linux/amd64 node:20-alpine AS base +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. + +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_ANON_KEY +ARG OPENAI_API_KEY +ARG GOOGLE_OAUTH +ARG JWT_SECRET_KEY + +# ENV NEXT_TELEMETRY_DISABLED=1 + +ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL +ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY +ENV OPENAI_API_KEY=$OPENAI_API_KEY +ENV GOOGLE_OAUTH=$GOOGLE_OAUTH +ENV JWT_SECRET_KEY=$JWT_SECRET_KEY + + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] \ No newline at end of file diff --git a/frontend-nextjs/README.md b/frontend-nextjs/README.md new file mode 100644 index 0000000..59e6385 --- /dev/null +++ b/frontend-nextjs/README.md @@ -0,0 +1,96 @@ + + Next.js and Supabase Starter Kit - the fastest way to build apps with Next.js and Supabase +

Next.js and Supabase Starter Kit

+
+ +

+ The fastest way to build apps with Next.js and Supabase +

+ +

+ Features · + Demo · + Deploy to Vercel · + Clone and run locally · + Feedback and issues + More Examples +

+
+ +## Features + +- Works across the entire [Next.js](https://nextjs.org) stack + - App Router + - Pages Router + - Middleware + - Client + - Server + - It just works! +- supabase-ssr. A package to configure Supabase Auth to use cookies +- Styling with [Tailwind CSS](https://tailwindcss.com) +- Components with [shadcn/ui](https://ui.shadcn.com/) +- Optional deployment with [Supabase Vercel Integration and Vercel deploy](#deploy-your-own) + - Environment variables automatically assigned to Vercel project + +## Demo + +You can view a fully working demo at [demo-nextjs-with-supabase.vercel.app](https://demo-nextjs-with-supabase.vercel.app/). + +## Deploy to Vercel + +Vercel deployment will guide you through creating a Supabase account and project. + +After installation of the Supabase integration, all relevant environment variables will be assigned to the project so the deployment is fully functioning. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&project-name=nextjs-with-supabase&repository-name=nextjs-with-supabase&demo-title=nextjs-with-supabase&demo-description=This%20starter%20configures%20Supabase%20Auth%20to%20use%20cookies%2C%20making%20the%20user's%20session%20available%20throughout%20the%20entire%20Next.js%20app%20-%20Client%20Components%2C%20Server%20Components%2C%20Route%20Handlers%2C%20Server%20Actions%20and%20Middleware.&demo-url=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2F&external-id=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&demo-image=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2Fopengraph-image.png&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6) + +The above will also clone the Starter kit to your GitHub, you can clone that locally and develop locally. + +If you wish to just develop locally and not deploy to Vercel, [follow the steps below](#clone-and-run-locally). + +## Clone and run locally + +1. You'll first need a Supabase project which can be made [via the Supabase dashboard](https://database.new) + +2. Create a Next.js app using the Supabase Starter template npx command + + ```bash + npx create-next-app -e with-supabase + ``` + +3. Use `cd` to change into the app's directory + + ```bash + cd name-of-new-app + ``` + +4. Rename `.env.local.example` to `.env.local` and update the following: + + ``` + NEXT_PUBLIC_SUPABASE_URL=[INSERT SUPABASE PROJECT URL] + NEXT_PUBLIC_SUPABASE_ANON_KEY=[INSERT SUPABASE PROJECT API ANON KEY] + ``` + + Both `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` can be found in [your Supabase project's API settings](https://app.supabase.com/project/_/settings/api) + +5. You can now run the Next.js local development server: + + ```bash + npm run dev + ``` + + The starter kit should now be running on [localhost:3000](http://localhost:3000/). + +6. This template comes with the default shadcn/ui style initialized. If you instead want other ui.shadcn styles, delete `components.json` and [re-install shadcn/ui](https://ui.shadcn.com/docs/installation/next) + +> Check out [the docs for Local Development](https://supabase.com/docs/guides/getting-started/local-development) to also run Supabase locally. + +## Feedback and issues + +Please file feedback and issues over on the [Supabase GitHub org](https://github.com/supabase/supabase/issues/new/choose). + +## More Supabase examples + +- [Next.js Subscription Payments Starter](https://github.com/vercel/nextjs-subscription-payments) +- [Cookie-based Auth and the Next.js 13 App Router (free course)](https://youtube.com/playlist?list=PL5S4mPUpp4OtMhpnp93EFSo42iQ40XjbF) +- [Supabase Auth and the Next.js App Router](https://github.com/supabase/supabase/tree/master/examples/auth/nextjs) diff --git a/frontend-nextjs/app/(auth-pages)/forgot-password/page.tsx b/frontend-nextjs/app/(auth-pages)/forgot-password/page.tsx new file mode 100644 index 0000000..5a098a2 --- /dev/null +++ b/frontend-nextjs/app/(auth-pages)/forgot-password/page.tsx @@ -0,0 +1,58 @@ +import { forgotPasswordAction } from "@/app/actions"; +import { FormMessage, Message } from "@/components/form-message"; +import { SubmitButton } from "@/components/submit-button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@radix-ui/react-dropdown-menu"; +import { Sparkles } from "lucide-react"; +import Link from "next/link"; + +export default function ForgotPassword({ + searchParams, +}: { + searchParams: Message; +}) { + return ( +
+ + + + Reset Password + + + + Enter your email to reset your account password. + + + + {/* */} +
+
+ + + + Reset Password + + +
+
+

+ Already have an account?{" "} + + Sign in + +

+
+
+
+
+
+ ); +} diff --git a/frontend-nextjs/app/(auth-pages)/layout.tsx b/frontend-nextjs/app/(auth-pages)/layout.tsx new file mode 100644 index 0000000..4190355 --- /dev/null +++ b/frontend-nextjs/app/(auth-pages)/layout.tsx @@ -0,0 +1,19 @@ +import { Metadata } from "next"; +import { getOpenGraphMetadata } from "@/lib/utils"; + +export const metadata: Metadata = { + title: "Login", + ...getOpenGraphMetadata("Login"), +}; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/frontend-nextjs/app/(auth-pages)/login/messages.tsx b/frontend-nextjs/app/(auth-pages)/login/messages.tsx new file mode 100644 index 0000000..3906f24 --- /dev/null +++ b/frontend-nextjs/app/(auth-pages)/login/messages.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +export default function Messages() { + const searchParams = useSearchParams(); + const error = searchParams.get("error"); + const message = searchParams.get("message"); + return ( + <> + {error && ( +

+ {error} +

+ )} + {message && ( +

+ {message} +

+ )} + + ); +} diff --git a/frontend-nextjs/app/(auth-pages)/login/page.tsx b/frontend-nextjs/app/(auth-pages)/login/page.tsx new file mode 100644 index 0000000..2239a63 --- /dev/null +++ b/frontend-nextjs/app/(auth-pages)/login/page.tsx @@ -0,0 +1,155 @@ +import Link from "next/link"; +import { headers } from "next/headers"; +import { createClient } from "@/utils/supabase/server"; +import { redirect } from "next/navigation"; +import { SubmitButton } from "./submit-button"; +import { Separator } from "@/components/ui/separator"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Sparkles } from "lucide-react"; +import { Label } from "@/components/ui/label"; +import GoogleLoginButton from "../../components/GoogleLoginButton"; +import Image from "next/image"; + +interface LoginProps { + searchParams?: { [key: string]: string | string[] | undefined }; +} + +export default async function Login({ searchParams }: LoginProps) { + const toy_id = searchParams?.toy_id as string | undefined; + const personality_id = searchParams?.personality_id as string | undefined; + const isGoogleOAuthEnabled = process.env.GOOGLE_OAUTH === "True"; + // const supabase = createClient(); + + // const { + // data: { user }, + // } = await supabase.auth.getUser(); + + // if (user) { + // return redirect("/home"); + // } + + const signInOrSignUp = async (formData: FormData) => { + "use server"; + + const email = formData.get("email") as string; + const password = formData.get("password") as string; + const supabase = createClient(); + + // Try to sign in first + const { error: signInError } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + // If sign in succeeds, redirect to home + if (!signInError) { + return redirect("/home"); + } + + // If sign in fails, try to sign up + const origin = headers().get("origin"); + const { error: signUpError } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + toy_id: toy_id, + personality_id: personality_id, + }, + emailRedirectTo: `${origin}/auth/callback`, + }, + }); + + if (signUpError) { + return redirect(`/login?message=${signUpError.message}`); + } + + // if (process.env.NEXT_PUBLIC_ENV === "local") { + // return redirect("/login?message=Sussessfully signed up"); + // } else { + // return redirect("/login?message=Check email to continue sign in process"); + // } + + return redirect("/login?message=Check email to continue sign in process"); + }; + + return ( +
+ + + + Login to Elato + + + + Login or sign up your account to continue + + + + {/* */} + + {isGoogleOAuthEnabled && ( + + )} + + {/* +
+ Elato Login +
*/} + + {/*
+ + + + + + + + Forgot Password? + + + + Continue with Email + + {searchParams?.message && ( +

+ {searchParams.message} +

+ )} +
*/} +
+
+
+ ); +} diff --git a/frontend-nextjs/app/(auth-pages)/login/submit-button.tsx b/frontend-nextjs/app/(auth-pages)/login/submit-button.tsx new file mode 100644 index 0000000..9d85533 --- /dev/null +++ b/frontend-nextjs/app/(auth-pages)/login/submit-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useFormStatus } from "react-dom"; +import { type ComponentProps } from "react"; + +type Props = ComponentProps<"button"> & { + pendingText?: string; +}; + +export function SubmitButton({ children, pendingText, ...props }: Props) { + const { pending, action } = useFormStatus(); + + const isPending = pending && action === props.formAction; + + return ( + + ); +} diff --git a/frontend-nextjs/app/actions.ts b/frontend-nextjs/app/actions.ts new file mode 100644 index 0000000..ce0cca2 --- /dev/null +++ b/frontend-nextjs/app/actions.ts @@ -0,0 +1,262 @@ +"use server"; + +import { encodedRedirect } from "@/utils/utils"; +import { createClient } from "@/utils/supabase/server"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { encryptSecret, getMacAddressFromDeviceCode, isValidMacAddress } from "@/lib/utils"; +import { addUserToDevice, dbCheckUserCode } from "@/db/devices"; +import { getSimpleUserById, updateUser } from "@/db/users"; + +export async function deleteUserApiKey(userId: string) { + const supabase = createClient(); + const { error } = await supabase.from('api_keys').delete().eq('user_id', userId); + return error; +} + +export const signInAction = async (formData: FormData) => { + const email = formData.get("email") as string; + const password = formData.get("password") as string; + const supabase = createClient(); + + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + return encodedRedirect("error", "/login", error.message); + } + + return redirect("/home"); +}; + +export const forgotPasswordAction = async (formData: FormData) => { + const email = formData.get("email")?.toString(); + const supabase = createClient(); + const origin = headers().get("origin"); + const callbackUrl = formData.get("callbackUrl")?.toString(); + + if (!email) { + return encodedRedirect( + "error", + "/forgot-password", + "Email is required" + ); + } + + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`, + }); + + if (error) { + console.error(error.message); + return encodedRedirect( + "error", + "/forgot-password", + "Could not reset password" + ); + } + + if (callbackUrl) { + return redirect(callbackUrl); + } + + return encodedRedirect( + "success", + "/forgot-password", + "Check your email for a link to reset your password." + ); +}; + +export const resetPasswordAction = async (formData: FormData) => { + const supabase = createClient(); + + const password = formData.get("password") as string; + const confirmPassword = formData.get("confirmPassword") as string; + + if (!password || !confirmPassword) { + encodedRedirect( + "error", + "/protected/reset-password", + "Password and confirm password are required" + ); + } + + if (password !== confirmPassword) { + encodedRedirect( + "error", + "/protected/reset-password", + "Passwords do not match" + ); + } + + const { error } = await supabase.auth.updateUser({ + password: password, + }); + + if (error) { + encodedRedirect( + "error", + "/protected/reset-password", + "Password update failed" + ); + } + + encodedRedirect("success", "/protected/reset-password", "Password updated"); +}; + +export const signOutAction = async () => { + const supabase = createClient(); + await supabase.auth.signOut(); + return redirect("/login"); +}; + +export const checkDoctorAction = async (authCode: string) => { + return authCode === "kiwi-subtle-emu"; +}; + +export const connectUserToDevice = async ( + userId: string, + userDeviceCode: string +) => { + const supabase = createClient(); + + const isCodeValid = await dbCheckUserCode(supabase, userDeviceCode.trim()); + if (!isCodeValid) { + return false; + } + + // if user code is valid, add user to device + const successfullyAdded = await addUserToDevice( + supabase, + userDeviceCode, + userId + ); + return successfullyAdded; +}; + +export const fetchGithubStars = async (repo: string) => { + try { + const response = await fetch(`https://api.github.com/repos/${repo}`, { + headers: { + Accept: "application/vnd.github.v3+json", + }, + next: { + revalidate: 3600, + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } + + const data = await response.json(); + return { + stars: data.stargazers_count, + error: null, + }; + } catch (error) { + console.error("Error fetching GitHub stats:", error); + return { + stars: null, + error: "Failed to load GitHub stats", + }; + } +}; + +export const isPremiumUser = async (userId: string) => { + const supabase = createClient(); + const dbUser = await getSimpleUserById(supabase, userId); + return dbUser?.is_premium; +}; + +export const setDeviceReset = async (userId: string) => { + const supabase = createClient(); + await supabase + .from("users") + .update({ is_reset: true }) + .eq("user_id", userId); +}; + +export const setDeviceOta = async (userId: string) => { + const supabase = createClient(); + await supabase.from("users").update({ is_ota: true }).eq("user_id", userId); +}; + +export async function storeUserApiKey(userId: string, rawApiKey: string) { + const supabase = createClient(); + const { iv, encryptedData } = encryptSecret(rawApiKey, process.env.ENCRYPTION_KEY!); + + const { error } = await supabase + .from('api_keys') + .upsert({ + user_id: userId, + encrypted_key: encryptedData, + iv: iv, + }); + + if (error) { + console.error('Error inserting or updating user secret:', error); + throw error; + } + + console.log(`Encrypted API key for user ${userId} stored successfully.`); + } + + export async function checkIfUserHasApiKey(userId: string): Promise { + const supabase = createClient(); + const { data, error } = await supabase + .from('api_keys') + .select('*', { count: 'exact' }) + .eq('user_id', userId); + + if (error) { + console.error('Error checking if user has API key:', error); + throw error; + } + + return data.length > 0; + } + + + export async function registerDevice(userId: string, deviceCode: string) { + // check if deviceCode is valid mac address + if (!isValidMacAddress(deviceCode)) { + return { error: "Invalid device code" }; + } + + const supabase = createClient(); + const { data, error } = await supabase + .from('devices') + .insert({ user_id: userId, user_code: deviceCode.toLowerCase(), mac_address: getMacAddressFromDeviceCode(deviceCode).toUpperCase() }).select(); + + + if (error) { + console.log(error) + return { error: "Error registering device" }; + } + + if (data && data.length > 0) { + await updateUser(supabase, { device_id: data[0].device_id }, userId); + } + + return { error: null }; + } + + export const createPersonality = async (userId: string, personality: IPersonality): Promise => { + const supabase = createClient(); + const { data, error } = await supabase + .from('personalities') + .insert({ + ...personality, + creator_id: userId + }).select(); + + if (error) { + console.error('Error creating personality:', error); + throw error; + } + + return data ? data[0] : null; + } diff --git a/frontend-nextjs/app/animation/page.tsx b/frontend-nextjs/app/animation/page.tsx new file mode 100644 index 0000000..fc9bd7b --- /dev/null +++ b/frontend-nextjs/app/animation/page.tsx @@ -0,0 +1,11 @@ +import FluidAISpeakingAnimation from "../components/Realtime/Animation"; +import FluidBubbleAnimation from "../components/Realtime/Animation1"; + +export default async function Home() { + return ( +
+ {/* */} + +
+ ); +} diff --git a/frontend-nextjs/app/api/checkout/route.ts b/frontend-nextjs/app/api/checkout/route.ts new file mode 100644 index 0000000..14373b4 --- /dev/null +++ b/frontend-nextjs/app/api/checkout/route.ts @@ -0,0 +1,351 @@ +// api/checkout/route.ts + +import { createClient } from "@/utils/supabase/server"; +import Stripe from "stripe"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", { + apiVersion: "2024-10-28.acacia", +}); + +// ... existing imports and stripe initialization ... + +export async function POST(req: Request) { + const { + quantity, + color, + free_shipping, + }: { quantity: number; color: string; free_shipping: boolean } = + await req.json(); + + console.log("foobar", quantity, color, free_shipping); + + const supabase = createClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + const successUrl = session + ? `${req.headers.get("origin")}/home?session_id={CHECKOUT_SESSION_ID}` + : `${req.headers.get("origin")}/login?session_id={CHECKOUT_SESSION_ID}`; + + try { + const stripeSession = await stripe.checkout.sessions.create({ + line_items: [ + { + price: "price_1R4nqzLTb7Djmo1RZuUmWj6c", + quantity: quantity, + }, + ], + mode: "payment", + success_url: successUrl, + cancel_url: `${req.headers.get("origin")}`, + billing_address_collection: "required", + phone_number_collection: { + enabled: true, // Enable phone number collection + }, + shipping_address_collection: { + allowed_countries: [ + "AC", + "AD", + "AE", + "AF", + "AG", + "AI", + "AL", + "AM", + "AO", + "AQ", + "AR", + "AT", + "AU", + "AW", + "AX", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BL", + "BM", + "BN", + "BO", + "BQ", + "BR", + "BS", + "BT", + "BV", + "BW", + "BY", + "BZ", + "CA", + "CD", + "CF", + "CG", + "CH", + "CI", + "CK", + "CL", + "CM", + "CN", + "CO", + "CR", + "CV", + "CW", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "EH", + "ER", + "ES", + "ET", + "FI", + "FJ", + "FK", + "FO", + "FR", + "GA", + "GB", + "GD", + "GE", + "GF", + "GG", + "GH", + "GI", + "GL", + "GM", + "GN", + "GP", + "GQ", + "GR", + "GS", + "GT", + "GU", + "GW", + "GY", + "HK", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IM", + "IN", + "IO", + "IQ", + "IS", + "IT", + "JE", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KR", + "KW", + "KY", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MF", + "MG", + "MK", + "ML", + "MM", + "MN", + "MO", + "MQ", + "MR", + "MS", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NC", + "NE", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NU", + "NZ", + "OM", + "PA", + "PE", + "PF", + "PG", + "PH", + "PK", + "PL", + "PM", + "PN", + "PR", + "PS", + "PT", + "PY", + "QA", + "RE", + "RO", + "RS", + "RU", + "RW", + "SA", + "SB", + "SC", + "SE", + "SG", + "SH", + "SI", + "SJ", + "SK", + "SL", + "SM", + "SN", + "SO", + "SR", + "SS", + "ST", + "SV", + "SX", + "SZ", + "TA", + "TC", + "TD", + "TF", + "TG", + "TH", + "TJ", + "TK", + "TL", + "TM", + "TN", + "TO", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "US", + "UY", + "UZ", + "VA", + "VC", + "VE", + "VG", + "VN", + "VU", + "WF", + "WS", + "XK", + "YE", + "YT", + "ZA", + "ZM", + "ZW", + "ZZ", + ], + }, + allow_promotion_codes: true, + shipping_options: [ + { + shipping_rate_data: { + type: "fixed_amount", + fixed_amount: { + amount: 0, + currency: "usd", + }, + display_name: "Global Shipping", + delivery_estimate: { + minimum: { + unit: "week", + value: 2, + }, + maximum: { + unit: "week", + value: 3, + }, + }, + }, + }, + ], + // shipping_options: free_shipping + // ? [] + // : [ + // { + // shipping_rate_data: { + // type: "fixed_amount", + // fixed_amount: { + // amount: 1000, + // currency: "usd", + // }, + // display_name: "Global Shipping", + // delivery_estimate: { + // minimum: { + // unit: "week", + // value: 2, + // }, + // maximum: { + // unit: "week", + // value: 3, + // }, + // }, + // }, + // }, + // ], + metadata: { + device_color: color, + }, + }); + + return new Response(JSON.stringify({ url: stripeSession.url }), { + status: 200, + }); + } catch (error) { + console.log("Error creating checkout session", error); + return new Response( + JSON.stringify({ error: (error as Error).message }), + { + status: 500, + } + ); + } +} diff --git a/frontend-nextjs/app/api/factory_reset_handler/route.ts b/frontend-nextjs/app/api/factory_reset_handler/route.ts new file mode 100644 index 0000000..a438800 --- /dev/null +++ b/frontend-nextjs/app/api/factory_reset_handler/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import jwt from "jsonwebtoken"; +import { createClient } from '@/utils/supabase/server'; + + +export async function POST(req: Request) { + try { + const { authToken } = await req.json(); + const supabase = createClient({ + global: { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }, + }); + + const { data: user, error: userError } = await supabase.auth.getUser(); + + if (userError) { + return NextResponse.json( + { error: userError.message }, + { status: 401 } + ); + } + + // set is_reset to false + const { data, error } = await supabase.from('devices').update({ + is_reset: false, + }).eq('user_id', user.user.id).select(); + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/frontend-nextjs/app/api/generate_auth_token/route.ts b/frontend-nextjs/app/api/generate_auth_token/route.ts new file mode 100644 index 0000000..71f1fe3 --- /dev/null +++ b/frontend-nextjs/app/api/generate_auth_token/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from 'next/server'; +import jwt from "jsonwebtoken"; +import { createClient } from '@/utils/supabase/server'; + +const ALGORITHM = "HS256"; + +interface TokenPayload { + [key: string]: any; +} + +const createSupabaseToken = ( + jwtSecretKey: string, + data: TokenPayload, + // Set expiration to null for no expiration, or use a very large number like 10 years + expireDays: number | null = 3650 // Default to 10 years +): string => { + const toEncode = { + aud: 'authenticated', + role: 'authenticated', + sub: data.user_id, + email: data.email, + // Only include exp if expireDays is not null + ...(expireDays && { + exp: Math.floor(Date.now() / 1000) + (expireDays * 86400) + }), + user_metadata: { + ...data + } + }; + + const encodedJwt = jwt.sign(toEncode, jwtSecretKey, { + algorithm: ALGORITHM + }); + return encodedJwt; +}; + +const getUserByMacAddress = async (macAddress: string) => { + const supabase = createClient(); + const { data, error } = await supabase.from('devices').select('*, user:user_id(*)').eq('mac_address', macAddress).single(); + if (error) { + throw new Error(error.message); + } + return data.user; +}; + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const macAddress = searchParams.get('macAddress'); + + if (!macAddress) { + return NextResponse.json( + { error: 'MAC address is required' }, + { status: 400 } + ); + } + + const user = await getUserByMacAddress(macAddress); + + if (!user) { + return NextResponse.json( + { error: 'User not found' }, + { status: 400 } + ); + } + + const payload = { + email: user.email, + user_id: user.user_id, + created_time: new Date() + }; + + const token = createSupabaseToken(process.env.JWT_SECRET_KEY!, payload, null); + + return NextResponse.json({ token }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/frontend-nextjs/app/api/ota_update_handler/route.ts b/frontend-nextjs/app/api/ota_update_handler/route.ts new file mode 100644 index 0000000..e4514d6 --- /dev/null +++ b/frontend-nextjs/app/api/ota_update_handler/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import jwt from "jsonwebtoken"; +import { createClient } from '@/utils/supabase/server'; + + +export async function POST(req: Request) { + try { + const { authToken } = await req.json(); + const supabase = createClient({ + global: { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }, + }); + + const { data: user, error: userError } = await supabase.auth.getUser(); + + if (userError) { + return NextResponse.json( + { error: userError.message }, + { status: 401 } + ); + } + + // set is_ota to false + const { data, error } = await supabase.from('devices').update({ + is_ota: false, + }).eq('user_id', user.user.id).select(); + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/frontend-nextjs/app/api/session/route.ts b/frontend-nextjs/app/api/session/route.ts new file mode 100644 index 0000000..d431b6d --- /dev/null +++ b/frontend-nextjs/app/api/session/route.ts @@ -0,0 +1,266 @@ +import { createClient } from "@/utils/supabase/server"; +import { SupabaseClient } from "@supabase/supabase-js"; +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import { getUserById } from "@/db/users"; + +interface IPayload { + user: IUser; + supabase: SupabaseClient; + timestamp: string; +} + +const getDoctorGuidanceHistory = async ( + supabase: SupabaseClient, + userId: string, +): Promise => { + const { data, error } = await supabase.from('conversations').select('*').eq('user_id', userId).eq('role', 'doctor').order('created_at', { ascending: false }).limit(10); + return data?.map((chat: IConversation) => { + const timestamp = chat.created_at ? new Date(chat.created_at).toLocaleString() : ""; + return `${chat.role} [${timestamp}]: ${chat.content}`; + }).join("") ?? "";} + +const getChatHistory = async ( + supabase: SupabaseClient, + userId: string, + personalityKey: string | null, +): Promise => { + try { + let query = supabase + .from('conversations') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(10); + +if (personalityKey) { + query = query.eq('personality_key', personalityKey); +} + + const { data, error } = await query; + if (error) throw error; + + const messages = data.map((chat: IConversation) => `${chat.role}: ${chat.content}`) + .join('\n'); + + return messages; + } catch (error: any) { + throw new Error(`Failed to get chat history: ${error.message}`); + } +}; + +const UserPromptTemplate = (user: IUser) => ` +YOU ARE TALKING TO someone whose name is: ${user.supervisee_name} and age is: ${user.supervisee_age} with a personality described as: ${user.supervisee_persona}. + +Do not ask for personal information. +Your physical form is in the form of a physical object or a toy. +A person interacts with you by pressing a button, sends you instructions and you must respond in a concise conversational style. +`; + +const DoctorPromptTemplate = (user: IUser) => { + const userMetadata = user.user_info.user_metadata as IDoctorMetadata; + const doctorName = userMetadata.doctor_name || 'Doctor'; + const hospitalName = userMetadata.hospital_name || 'An amazing hospital'; + const specialization = userMetadata.specialization || 'general medicine'; + const favoritePhrases = userMetadata.favorite_phrases || "You're doing an amazing job"; + + return ` +YOU ARE TALKING TO a patient under the care of doctor ${doctorName} from hospital or clinic ${hospitalName}. The child may be undergoing procedures such as ${specialization}. + +YOU ARE: A friendly, compassionate toy designed to offer comfort and care. You specialize in calming children and answering their questions with simple, concise and soothing explanations. + +YOUR FAVORITE PHRASES ARE: ${favoritePhrases} + `; +}; + +const getCommonPromptTemplate = (chatHistory: string, user: IUser, timestamp: string) => ` +YOUR VOICE IS: ${user.personality?.voice_prompt} + +YOUR CHARACTER PROMPT IS: ${user.personality?.character_prompt} +CHAT HISTORY: +${chatHistory} + +USER'S CURRENT TIME IS: ${timestamp} + +LANGUAGE: +You may talk in any language the user would like, but the default language is ${user?.language?.name ?? 'English'}. +`; + +const getStoryPromptTemplate = (user: IUser, chatHistory: string) => { + const childName = user.supervisee_name; + const childAge = user.supervisee_age; + const childInterests = user.supervisee_persona; + const title = user.personality?.title; + const characterPrompt = user.personality?.character_prompt; + const voicePrompt = user.personality?.voice_prompt; + + return ` + You are a lively, imaginative storyteller character named ${title}. You are about to create a fun and exciting adventure story for ${childName}, who is ${childAge} years old. ${childName} loves ${childInterests}. + +Your storytelling style must: +- Be creative, immersive, and interactive. +- Include frequent pauses or questions to let ${childName} influence what happens next. +- Feature themes and elements closely related to ${childName}'s interests. +- Be age-appropriate, friendly, playful, and positive. + +Your Character Description: +${characterPrompt} + +Your Voice Description: +${voicePrompt} + +Storytelling Guidelines: +- Begin the story by directly addressing ${childName} and introducing an interesting scenario related to their interests. +- After every 4-5 sentences or at key decision moments, pause and ask ${childName} what should happen next or present a choice. +- Incorporate their responses naturally and creatively to shape the ongoing narrative. +- Conclude the story positively, reinforcing curiosity, creativity, kindness, or bravery. + +Chat History: +${chatHistory} + +Let's begin the adventure now! + `; +}; + +const getDoctorGuidanceTemplate = async ({user, supabase, timestamp}: IPayload) => { + const chatHistory = await getDoctorGuidanceHistory(supabase, user.user_id); + const userMetadata = user.user_info.user_metadata as IDoctorMetadata; + const doctorName = userMetadata.doctor_name || 'Doctor'; + const hospitalName = userMetadata.hospital_name || 'An amazing hospital'; + const specialization = userMetadata.specialization || 'general medicine'; + return ` +- You are talking to the doctor. Your physical form is actually a medical wellness toy for children. +- The doctor will either ask you questions or give you instructions on how to help this child. +- You must respond in a concise conversational style. + +Your voice: +- Talk in a serious, sincere and professional tone. +- Do not add exclamations or excited words. + +Current time: +${new Date(timestamp).toLocaleString()}. + +Chat history with the doctor: +${chatHistory} + +Doctor background: +The doctor's name is ${doctorName} and the hospital is ${hospitalName}. The doctor is a specialist in ${specialization}. + ` +}; + +const createSystemPrompt = async ( + payload: IPayload, +): Promise => { + const { user, supabase, timestamp } = payload; + const chatHistory = await getChatHistory(supabase, user.user_id, user.personality?.key ?? null); + const commonPrompt = getCommonPromptTemplate(chatHistory, user, timestamp); + + const isStory = user.personality?.is_story; + if (isStory) { + const storyPrompt = getStoryPromptTemplate(user, chatHistory); + return storyPrompt; + } + + let systemPrompt; + switch (user.user_info.user_type) { + case 'user': + systemPrompt = UserPromptTemplate(user); + break; + case 'doctor': + systemPrompt = DoctorPromptTemplate(user); + break; + default: + throw new Error('Invalid user type'); + } + return systemPrompt + commonPrompt; +}; + +/** + * Decrypts an encrypted secret with the same master encryption key. + * @param encryptedData - base64 string from the database + * @param iv - base64 IV from the database + * @param masterKey - 32-byte string or buffer + * @returns the original plaintext secret + */ +function decryptSecret(encryptedData: string, iv: string, masterKey: string) { + // Decode the base64 master key + const decodedKey = Buffer.from(masterKey, 'base64'); + if (decodedKey.length !== 32) { + throw new Error('ENCRYPTION_KEY must be 32 bytes when decoded from base64.'); + } + + const decipher = crypto.createDecipheriv( + 'aes-256-cbc' as any, + Buffer.from(masterKey, 'base64') as any, + Buffer.from(iv, 'base64') as any + ); + + let decrypted = decipher.update(encryptedData, 'base64', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +} + +const getOpenAiApiKey = async ( + supabase: SupabaseClient, + userId: string, +): Promise => { + const { data, error } = await supabase + .from('api_keys') + .select('encrypted_key, iv') + .eq('user_id', userId) + .single(); + + if (error) throw error; + + const { encrypted_key, iv } = data; + const masterKey = process.env.ENCRYPTION_KEY!; + + const decryptedKey = decryptSecret(encrypted_key, iv, masterKey); + + return decryptedKey; +}; + + +export async function GET(request: NextRequest) { + const supabase = createClient(); + + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const dbUser = await getUserById(supabase, user.id); + if (!dbUser) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const isDoctor = dbUser.user_info.user_type === "doctor"; + const openAiApiKey = await getOpenAiApiKey(supabase, user.id); + const systemPrompt = isDoctor ? await getDoctorGuidanceTemplate({ user: dbUser, supabase, timestamp: new Date().toISOString() }) : await createSystemPrompt({ user: dbUser, supabase, timestamp: new Date().toISOString() }); + + try { + const response = await fetch( + "https://api.openai.com/v1/realtime/sessions", + { + method: "POST", + headers: { + Authorization: `Bearer ${openAiApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: 'gpt-4o-mini-realtime-preview-2024-12-17', + instructions: systemPrompt, + voice: isDoctor ? 'ballad' : dbUser.personality?.oai_voice ?? 'ballad', + }), + } + ); + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error("Error in /session:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 } + ); + } +} diff --git a/frontend-nextjs/app/auth/callback/route.ts b/frontend-nextjs/app/auth/callback/route.ts new file mode 100644 index 0000000..e95966d --- /dev/null +++ b/frontend-nextjs/app/auth/callback/route.ts @@ -0,0 +1,47 @@ +import { createUser, doesUserExist } from "@/db/users"; +import { createClient } from "@/utils/supabase/server"; +import { NextResponse } from "next/server"; +import { defaultPersonalityId, defaultToyId } from "@/lib/data"; +import { getBaseUrl } from "@/lib/utils"; + +export async function GET(request: Request) { + // The `/auth/callback` route is required for the server-side auth flow implemented + // by the SSR package. It exchanges an auth code for the user's session. + // https://supabase.com/docs/guides/auth/server-side/nextjs + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get("code"); + const queryParamsToyId = requestUrl.searchParams.get("toy_id"); + + // const origin = requestUrl.origin; + // const origin = "http://localhost:3000"; + const origin = getBaseUrl(); + + if (code) { + const supabase = createClient(); + await supabase.auth.exchangeCodeForSession(code); + const { + data: { user }, + } = await supabase.auth.getUser(); + + // console.log("user+++++", user); + + if (user) { + // console.log("user+++++2", user); + const userExists = await doesUserExist(supabase, user); + if (!userExists) { + // Create user if they don't exist + await createUser(supabase, user, { + language_code: "en-US", + personality_id: + user?.user_metadata?.personality_id ?? + defaultPersonalityId, + }); + + return NextResponse.redirect(`${origin}/onboard`); + } + } + } + + // URL to redirect to after sign up process completes + return NextResponse.redirect(`${origin}/home`); +} diff --git a/frontend-nextjs/app/children/page.tsx b/frontend-nextjs/app/children/page.tsx new file mode 100644 index 0000000..42d9a8b --- /dev/null +++ b/frontend-nextjs/app/children/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default async function Home() { + redirect("/healthcare"); +} diff --git a/frontend-nextjs/app/components/AuthTokenModal.tsx b/frontend-nextjs/app/components/AuthTokenModal.tsx new file mode 100644 index 0000000..f4288a4 --- /dev/null +++ b/frontend-nextjs/app/components/AuthTokenModal.tsx @@ -0,0 +1,92 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Check, Key, Trash } from "lucide-react"; +import { checkIfUserHasApiKey, storeUserApiKey, deleteUserApiKey } from "../actions"; +import { useState } from "react"; +import { useToast } from "@/components/ui/use-toast"; + +interface AuthTokenModalProps { + user: IUser; + userHasApiKey: () => void; + hasApiKey: boolean; + setHasApiKey: (hasApiKey: boolean) => void; +} + +const AuthTokenModal: React.FC = ({ user, userHasApiKey, hasApiKey, setHasApiKey }) => { + const { toast } = useToast(); + const [apiKey, setApiKey] = useState(""); + + + return ( + + + + + + + Set your OpenAI API Key + + This key is kept encrypted and never stored on our servers as plain text. + + +
+ { + setApiKey(e.target.value); + }} + /> + +
+
+
+ ); +}; + +export default AuthTokenModal; diff --git a/frontend-nextjs/app/components/ChatAvatar.tsx b/frontend-nextjs/app/components/ChatAvatar.tsx new file mode 100644 index 0000000..5a04e5e --- /dev/null +++ b/frontend-nextjs/app/components/ChatAvatar.tsx @@ -0,0 +1,36 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + getAssistantAvatar, + getPersonalityImageSrc, + getUserAvatar, +} from "@/lib/utils"; + +interface ChatAvatarProps { + role: string; + user: IUser; +} + +const ChatAvatar: React.FC = ({ + role, + user, +}) => { + const imageSrc: string = + role === "input" + ? getUserAvatar(user.avatar_url) + : `/personality/${user.personality?.key}.jpeg`; + + return ( + + + + {user.email.slice(0, 2).toUpperCase()} + + + ); +}; + +export default ChatAvatar; diff --git a/frontend-nextjs/app/components/CreateCharacter/BuildDashboard.tsx b/frontend-nextjs/app/components/CreateCharacter/BuildDashboard.tsx new file mode 100644 index 0000000..681b675 --- /dev/null +++ b/frontend-nextjs/app/components/CreateCharacter/BuildDashboard.tsx @@ -0,0 +1,452 @@ +"use client"; + +import React, { useState } from "react"; +import { updateUser } from "@/db/users"; +import { createClient } from "@/utils/supabase/client"; +import HomePageSubtitles from "./../HomePageSubtitles"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { ArrowLeft, ArrowRight, Check, Mic, Volume2 } from "lucide-react"; +import Twemoji from "react-twemoji"; +import { createPersonality } from "../../actions"; +import { v4 as uuidv4 } from 'uuid'; +import { toast } from "@/components/ui/use-toast"; +import { useRouter } from "next/navigation"; +import { z } from "zod"; +import { emotionOptions, r2UrlAudio, voices } from "@/lib/data"; +import EmojiComponent from "./EmojiComponent"; + +interface SettingsDashboardProps { + selectedUser: IUser; + allLanguages: ILanguage[]; +} + +const formSchema = z.object({ + title: z.string().min(2, "Minimum 2 characters").max(50, "Maximum 50 characters"), + description: z.string().min(50, "Minimum 50 characters").max(200, "Maximum 200 characters"), + prompt: z.string().min(100, "Minimum 100 characters").max(1000, "Maximum 1000 characters"), + voice: z.string().min(1, "Voice selection is required"), + voiceCharacteristics: z.object({ + features: z.string().min(10, "Minimum 10 characters").max(150, "Maximum 150 characters"), + emotion: z.string() + }) +}); + +type FormData = z.infer; + +const SettingsDashboard: React.FC = ({ + selectedUser, + allLanguages, +}) => { + const supabase = createClient(); + const router = useRouter(); + const [currentStep, setCurrentStep] = useState<'personality' | 'voice'>('personality'); + const [isSubmitting, setIsSubmitting] = useState(false); + + + const [languageState, setLanguageState] = useState( + selectedUser.language_code! // Initial value from props + ); + + const [formData, setFormData] = useState({ + title: '', + description: '', + prompt: '', + voice: '', + voiceCharacteristics: { + features: '', + emotion: 'neutral' + } + }); + + const [touchedFields, setTouchedFields] = useState>({}); + + + const [formErrors, setFormErrors] = useState>>({}); + + + const [previewingVoice, setPreviewingVoice] = useState(null); + + const handleBlur = (field: keyof FormData | 'features') => { + // Mark the field as touched + setTouchedFields(prev => ({ ...prev, [field]: true })); + + // Validate the field + if (field === 'features') { + validateField(field, formData.voiceCharacteristics.features); + } else { + validateField(field, formData[field] as string); + } + }; + + const validateField = (field: keyof FormData | 'features', value: string) => { + try { + if (field === 'features') { + formSchema.shape.voiceCharacteristics.shape.features.parse(value); + } else if (field === 'voiceCharacteristics') { + formSchema.shape.voiceCharacteristics.parse(value); + } else { + formSchema.shape[field].parse(value); + } + // Clear error if validation passes + setFormErrors(prev => ({ ...prev, [field]: undefined })); + } catch (error: unknown) { + if (error instanceof z.ZodError) { + const zodError = error as z.ZodError; + setFormErrors(prev => ({ ...prev, [field]: zodError.errors[0].message })); + } + } + }; + + const handleInputChange = (field: keyof FormData, value: string) => { + const newFormData = { ...formData, [field]: value }; + setFormData(newFormData); + + // Only validate if the field has been touched before + if (touchedFields[field]) { + validateField(field, value); + } + }; + + const handleVoiceCharacteristicChange = (characteristic: 'features' | 'emotion', value: string) => { + const newVoiceCharacteristics = { + ...formData.voiceCharacteristics, + [characteristic]: value + }; + + // Validate just this nested field + try { + formSchema.shape.voiceCharacteristics.shape[characteristic].parse(value); + // Clear error if validation passes + setFormErrors(prev => ({ ...prev, [characteristic]: undefined })); + } catch (error: unknown) { + if (error instanceof z.ZodError) { + // Type assertion is needed here + const zodError = error as z.ZodError; + setFormErrors(prev => ({ ...prev, [characteristic]: zodError.errors[0].message })); + } + } + + setFormData({ + ...formData, + voiceCharacteristics: newVoiceCharacteristics + }); + }; + + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Set submitting state to true + setIsSubmitting(true); + + // Validate the entire form + const result = formSchema.safeParse(formData); + console.log(result); + + if (!result.success) { + // Extract and set all validation errors + const errors: Partial> = {}; + result.error.errors.forEach(err => { + const path = err.path.join('.'); + if (path === 'voiceCharacteristics.features') { + errors['features'] = err.message; + } else { + errors[err.path[0] as keyof FormData] = err.message; + } + }); + setFormErrors(errors); + setIsSubmitting(false); // Reset submitting state + return; + } + + try { + const personality = await createPersonality(selectedUser.user_id, { + title: formData.title, + subtitle: "", + character_prompt: formData.prompt, + oai_voice: formData.voice as OaiVoice, + voice_prompt: formData.voiceCharacteristics.features + "\nThe voice should be " + formData.voiceCharacteristics.emotion, + is_doctor: false, + is_child_voice: false, + is_story: false, + key: formData.title.toLowerCase().replace(/ /g, '_') + "_" + uuidv4(), + creator_id: selectedUser.user_id, + short_description: formData.description + }); + + if (personality) { + toast({ + title: "New AI Character created", + description: "Your character has been created!", + duration: 3000, + }); + router.push(`/home`); + } + } catch (error) { + console.error("Error creating personality:", error); + toast({ + title: "Error", + description: "Failed to create your character. Please try again.", + variant: "destructive", + duration: 3000, + }); + } finally { + setIsSubmitting(false); // Reset submitting state + } +}; + + const [audioElement, setAudioElement] = useState(null); + + + const previewVoice = (voiceId: string) => { + // Stop any currently playing preview + if (audioElement) { + audioElement.pause(); + audioElement.currentTime = 0; + } + + const audioSampleUrl = `${r2UrlAudio}/${voiceId}.wav`; + setPreviewingVoice(voiceId); + + // Create and play audio element + const audio = new Audio(audioSampleUrl); + setAudioElement(audio); + + // Play the audio + audio.play().catch(error => { + console.error("Error playing audio:", error); + setPreviewingVoice(null); + }); + + // Reset the previewing state when audio ends + audio.onended = () => { + setPreviewingVoice(null); + }; + + // Fallback in case audio doesn't trigger onended + setTimeout(() => { + if (previewingVoice === voiceId) { + setPreviewingVoice(null); + } + }, 10000); // 10 second fallback + }; + + const Heading = () => { + return ( +
+
+
+

Create your AI Character

+
+
+ +
+ ); + }; + + return ( +
+ +
+ + {currentStep === 'personality' ?
+

+ Character Details +

+
+ + handleInputChange('title', e.target.value)} + onBlur={() => handleBlur('title')} + /> +

+ + {formErrors.title || "Give your AI character a name or title."} + + {formData.title.length}/50 +

+
+
+ +