v1
This commit is contained in:
parent
8c089a87d7
commit
5c76b9cd92
312 changed files with 34375 additions and 0 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
5
firmware-cpp/.gitignore
vendored
Normal file
5
firmware-cpp/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.pio
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
10
firmware-cpp/.vscode/extensions.json
vendored
Normal file
10
firmware-cpp/.vscode/extensions.json
vendored
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
194
firmware-cpp/README.md
Normal file
194
firmware-cpp/README.md
Normal file
|
|
@ -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
|
||||
|
||||
<!-- ### For Seeed Studio XIAO ESP32S3 -->
|
||||
|
||||
| **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 |
|
||||
|
||||
<!--
|
||||
I2S Input (Microphone)
|
||||
|
||||
- SD: D9
|
||||
- WS: D7
|
||||
- SCK: GD8
|
||||
|
||||
I2S Output (Speaker with amp MAX98357A)
|
||||
|
||||
- WS: D0
|
||||
- BCK: D1
|
||||
- DATA: D2
|
||||
- SD: D3 (shutdown)
|
||||
|
||||
Other
|
||||
|
||||
- LED: D4
|
||||
- Button: D5
|
||||
|
||||
### For a general ESP32 dev board
|
||||
|
||||
I2S Input (Microphone)
|
||||
|
||||
- SD: GPIO 13
|
||||
- WS: GPIO 5
|
||||
- SCK: GPIO 18
|
||||
|
||||
I2S Output (Speaker)
|
||||
|
||||
- WS: GPIO 32
|
||||
- BCK: GPIO 33
|
||||
- DATA: GPIO 25
|
||||
|
||||
Other
|
||||
|
||||
- LED: GPIO 2
|
||||
- Button: 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 = "<your-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 = "<your-auth-token-here>"; // 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.
|
||||
|
||||
<!-- ## Features -->
|
||||
|
||||
<!-- - Real-time audio streaming using WebSocket
|
||||
- Full-duplex I2S audio input (microphone) and I2S audio output (speaker)
|
||||
- WiFi connectivity
|
||||
- LED status indicator -->
|
||||
<!-- - Button interrupt for connection management -->
|
||||
|
||||
<!-- ## Hardware Requirements
|
||||
|
||||
- ESP32 development board
|
||||
- INMP441 MEMS microphone (I2S input)
|
||||
- MAX98357A amplifier (I2S output)
|
||||
- LED (for status indication)
|
||||
- Push button (for connection control)
|
||||
- USB Type-C or Micro USB power cable -->
|
||||
|
||||
|
||||
## 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:
|
||||
|
||||
<!-- - Audio sample rate (`SAMPLE_RATE`) -->
|
||||
- Buffer sizes (`bufferCnt`, `bufferLen`)
|
||||
<!-- - LED brightness levels (`MIN_BRIGHTNESS`, `MAX_BRIGHTNESS`) -->
|
||||
- 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.
|
||||
BIN
firmware-cpp/data/startup.mp3
Normal file
BIN
firmware-cpp/data/startup.mp3
Normal file
Binary file not shown.
39
firmware-cpp/include/README
Normal file
39
firmware-cpp/include/README
Normal file
|
|
@ -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
|
||||
46
firmware-cpp/lib/README
Normal file
46
firmware-cpp/lib/README
Normal file
|
|
@ -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 <Foo.h>
|
||||
#include <Bar.h>
|
||||
|
||||
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
|
||||
6
firmware-cpp/partition.csv
Normal file
6
firmware-cpp/partition.csv
Normal file
|
|
@ -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,
|
||||
|
43
firmware-cpp/platformio.ini
Normal file
43
firmware-cpp/platformio.ini
Normal file
|
|
@ -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
|
||||
370
firmware-cpp/src/Audio.cpp
Normal file
370
firmware-cpp/src/Audio.cpp
Normal file
|
|
@ -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 <WebSocketsClient.h>
|
||||
#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<uint8_t>& 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<uint8_t>& _buffer;
|
||||
};
|
||||
|
||||
BufferPrint bufferPrint(audioBuffer);
|
||||
OpusAudioDecoder opusDecoder;
|
||||
BufferRTOS<uint8_t> audioBuffer(AUDIO_BUFFER_SIZE, AUDIO_CHUNK_SIZE);
|
||||
I2SStream i2s;
|
||||
VolumeStream volume(i2s);
|
||||
QueueStream<uint8_t> 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<int>();
|
||||
bool is_ota = doc["is_ota"].as<bool>();
|
||||
bool is_reset = doc["is_reset"].as<bool>();
|
||||
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<int>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
49
firmware-cpp/src/Audio.h
Normal file
49
firmware-cpp/src/Audio.h
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
|
||||
#include "Print.h"
|
||||
#include "Config.h"
|
||||
#include "AudioTools.h"
|
||||
// #include "AudioTools/Concurrency/RTOS.h"
|
||||
#include "AudioTools/AudioCodecs/CodecOpus.h"
|
||||
#include <WebSocketsClient.h>
|
||||
|
||||
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<uint8_t> audioBuffer;
|
||||
extern I2SStream i2s;
|
||||
extern VolumeStream volume;
|
||||
extern QueueStream<uint8_t> 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);
|
||||
82
firmware-cpp/src/Config.cpp
Normal file
82
firmware-cpp/src/Config.cpp
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
#include "Config.h"
|
||||
#include <nvs_flash.h>
|
||||
|
||||
// ! 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-----
|
||||
// <YOUR HOST CERTIFICATE HERE>
|
||||
// -----END CERTIFICATE-----
|
||||
// )EOF";
|
||||
|
||||
const char *Vercel_CA_cert = R"EOF(
|
||||
-----BEGIN CERTIFICATE-----
|
||||
<YOUR VERCEL CERTIFICATE HERE>
|
||||
-----END CERTIFICATE-----
|
||||
)EOF";
|
||||
|
||||
|
||||
// talkedge.deno.dev CA cert
|
||||
const char *CA_cert = R"EOF(
|
||||
-----BEGIN CERTIFICATE-----
|
||||
<YOUR TALKEDGE CERTIFICATE HERE>
|
||||
-----END CERTIFICATE-----
|
||||
)EOF";
|
||||
91
firmware-cpp/src/Config.h
Normal file
91
firmware-cpp/src/Config.h
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#ifndef CONFIG_H
|
||||
#define CONFIG_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <driver/i2s.h>
|
||||
#include <Preferences.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <WebSocketsClient.h>
|
||||
|
||||
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
|
||||
80
firmware-cpp/src/FactoryReset.h
Normal file
80
firmware-cpp/src/FactoryReset.h
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#include <Config.h>
|
||||
#include <nvs_flash.h>
|
||||
#include <ESPAsyncWebServer.h> //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;
|
||||
}
|
||||
}
|
||||
332
firmware-cpp/src/LEDHandler.cpp
Normal file
332
firmware-cpp/src/LEDHandler.cpp
Normal file
|
|
@ -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()`
|
||||
}
|
||||
}
|
||||
14
firmware-cpp/src/LEDHandler.h
Normal file
14
firmware-cpp/src/LEDHandler.h
Normal file
|
|
@ -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
|
||||
127
firmware-cpp/src/OTA.cpp
Normal file
127
firmware-cpp/src/OTA.cpp
Normal file
|
|
@ -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-----
|
||||
|
||||
<YOUR HOST CERTIFICATE HERE>
|
||||
|
||||
-----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);
|
||||
}
|
||||
|
||||
13
firmware-cpp/src/OTA.h
Normal file
13
firmware-cpp/src/OTA.h
Normal file
|
|
@ -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
|
||||
1246
firmware-cpp/src/WifiManager.cpp
Normal file
1246
firmware-cpp/src/WifiManager.cpp
Normal file
File diff suppressed because it is too large
Load diff
140
firmware-cpp/src/WifiManager.h
Normal file
140
firmware-cpp/src/WifiManager.h
Normal file
|
|
@ -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 <Arduino.h>
|
||||
#include <Preferences.h>
|
||||
#if ASYNC_WEBSERVER == true
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#else
|
||||
#include <WebServer.h>
|
||||
#endif
|
||||
#include <Audio.h>
|
||||
|
||||
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
|
||||
265
firmware-cpp/src/main.cpp
Normal file
265
firmware-cpp/src/main.cpp
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
#include "OTA.h"
|
||||
#include <Arduino.h>
|
||||
#include <driver/rtc_io.h>
|
||||
#include "LEDHandler.h"
|
||||
#include "Config.h"
|
||||
#include "SPIFFS.h"
|
||||
#include "WifiManager.h"
|
||||
#include <driver/touch_sensor.h>
|
||||
#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();
|
||||
}
|
||||
}
|
||||
11
firmware-cpp/test/README
Normal file
11
firmware-cpp/test/README
Normal file
|
|
@ -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
|
||||
528
firmware-cpp/test/WifiSetup.h
Normal file
528
firmware-cpp/test/WifiSetup.h
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
#include <AsyncTCP.h> //https://github.com/me-no-dev/AsyncTCP using the latest dev version from @me-no-dev
|
||||
#include <DNSServer.h>
|
||||
#include <ESPAsyncWebServer.h> //https://github.com/me-no-dev/ESPAsyncWebServer using the latest dev version from @me-no-dev
|
||||
#include <esp_wifi.h> //Used for mpdu_rx_disable android workaround
|
||||
#include <driver/i2s.h>
|
||||
#include <SPIFFS.h>
|
||||
#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 = "<h1>Setup Complete</h1>"
|
||||
"<p>Your device is ready to use.</p>"
|
||||
"<p>The setup network will now close.</p>";
|
||||
} else {
|
||||
content = "<h1>One Last Step</h1>"
|
||||
"<p>Please register your personal device code on our website to start using your device.</p>"
|
||||
"<form action='/complete' method='GET'>"
|
||||
"<input type='hidden' name='registration_attempted' value='true'>"
|
||||
"<input type='submit' value='Register Device' class='button'>"
|
||||
"</form>"
|
||||
"<p class='note'>Click the button above after registering your device on the website.</p>";
|
||||
}
|
||||
|
||||
request->send(200, "text/html", "<!DOCTYPE html>"
|
||||
"<html lang='en'>"
|
||||
"<head>"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
|
||||
"<style>"
|
||||
"body { font-family: Arial, sans-serif; margin: 0; padding: 0; background-color: #fff8e1; }"
|
||||
".container { padding: 20px; max-width: 600px; margin: auto; }"
|
||||
".header { background: #facc15; color: black; padding: 15px 0; text-align: center; font-weight: bold; border-radius: 8px 8px 0 0; }"
|
||||
".content { background: #ffffff; border-radius: 0 0 8px 8px; padding: 25px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); text-align: center; }"
|
||||
"h1 { color: #333; margin-bottom: 20px; }"
|
||||
"p { color: #666; margin: 10px 0; }"
|
||||
".button { background: #facc15; color: black; padding: 12px 24px; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin: 20px 0; }"
|
||||
".button:hover { background: #fdd835; }"
|
||||
".note { font-size: 0.9em; color: #888; margin-top: 20px; }"
|
||||
"</style>"
|
||||
"</head>"
|
||||
"<body>"
|
||||
"<div class='container'>"
|
||||
"<div class='header'>Elato AI - Setup Complete</div>"
|
||||
"<div class='content'>" +
|
||||
content +
|
||||
"</div>"
|
||||
"</div>"
|
||||
"</body>"
|
||||
"</html>");
|
||||
}
|
||||
|
||||
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 = "<!DOCTYPE html>"
|
||||
"<html lang='en'>"
|
||||
"<head>"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
|
||||
"<style>"
|
||||
"body { font-family: Arial, sans-serif; margin: 0; padding: 0; background-color: #fff8e1; }" // Light yellow background
|
||||
".container { padding: 20px; max-width: 600px; margin: auto; }"
|
||||
".header { background: #facc15; color: black; padding: 15px 0; text-align: center; font-weight: bold; border-radius: 8px 8px 0 0; }" // Yellow header with black text and rounded top corners
|
||||
".content { background: #ffffff; border-radius: 0 0 8px 8px; padding: 25px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); }" // Rounded bottom corners
|
||||
".error { margin: 20px 0; padding: 10px; background: #ffebee; border-radius: 5px; color: #c62828; }"
|
||||
"input[type='text'], input[type='password'] { width: calc(100% - 22px); padding: 10px; margin: 10px 0; border: 1px solid #ccc; border-radius: 5px; box-sizing: border-box; }"
|
||||
"input[type='submit'] { background: #facc15; color: black; padding: 10px; border: none; border-radius: 5px; cursor: pointer; width: 100%; font-weight: bold; }" // Yellow button with black text
|
||||
"input[type='submit']:hover { background: #fdd835; }" // Slightly darker yellow on hover
|
||||
"</style>"
|
||||
"</head>"
|
||||
"<body>"
|
||||
"<div class='container'>"
|
||||
"<div class='header'>Elato AI</div>"
|
||||
"<div class='content'>"
|
||||
"<h1>Connect to Wi-Fi</h1>";
|
||||
if (strcmp(notConnected.c_str(), "true") == 0)
|
||||
{
|
||||
html += "<p class='error'>The network connection failed, please try again.</p>";
|
||||
}
|
||||
html += "<form action='/wifi' method='POST'>"
|
||||
"SSID: <input type='text' name='ssid' required><br>"
|
||||
"Password: <input type='password' name='password' required><br>"
|
||||
"<input type='submit' value='Connect'>"
|
||||
"</form>"
|
||||
"</div>"
|
||||
"</div>"
|
||||
"</body>"
|
||||
"</html>";
|
||||
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");
|
||||
}
|
||||
95
firmware-cpp/test/audio_stream_test.cpp
Normal file
95
firmware-cpp/test/audio_stream_test.cpp
Normal file
|
|
@ -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);
|
||||
}
|
||||
56
firmware-cpp/test/eduroam_wifi_test.cpp
Normal file
56
firmware-cpp/test/eduroam_wifi_test.cpp
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#include "Arduino.h"
|
||||
#include <WiFi.h> //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()
|
||||
{
|
||||
}
|
||||
121
firmware-cpp/test/fetch_auth_token_test.cpp
Normal file
121
firmware-cpp/test/fetch_auth_token_test.cpp
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
#include <WiFi.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <Preferences.h> // NVS for storage
|
||||
|
||||
// Wi-Fi credentials
|
||||
const char *ssid = "<wifi>";
|
||||
const char *password = "<pw>";
|
||||
|
||||
// Server endpoint for checking registration
|
||||
const char *serverEndpoint = "<server_endpoint>";
|
||||
|
||||
// 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.)
|
||||
}
|
||||
59
firmware-cpp/test/light_button_test.cpp
Normal file
59
firmware-cpp/test/light_button_test.cpp
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#include <Arduino.h>
|
||||
|
||||
// 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
|
||||
}
|
||||
73
firmware-cpp/test/mic_test.cpp
Normal file
73
firmware-cpp/test/mic_test.cpp
Normal file
|
|
@ -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 <WiFi.h>
|
||||
#include "AudioTools.h"
|
||||
|
||||
const char *ssid = "<wifi>";
|
||||
const char *password = "<pw>";
|
||||
// AudioEncodedServer server(new WAVEncoder(),"ssid","password");
|
||||
AudioWAVServer server(ssid, password); // the same a above
|
||||
|
||||
I2SStream i2sStream; // Access I2S as stream
|
||||
ConverterFillLeftAndRight<int16_t> 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();
|
||||
}
|
||||
153
firmware-cpp/test/opus_audio_loop_test.cpp
Normal file
153
firmware-cpp/test/opus_audio_loop_test.cpp
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
#include <DebugLog.h>
|
||||
#include <driver/i2s.h>
|
||||
#include <opus.h>
|
||||
|
||||
// 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);
|
||||
}
|
||||
121
firmware-cpp/test/opus_test.cpp
Normal file
121
firmware-cpp/test/opus_test.cpp
Normal file
|
|
@ -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<int16_t> sineWave( 32000); // subclass of SoundGenerator with max amplitude of 32000
|
||||
GeneratedSoundStream<int16_t> 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();
|
||||
}
|
||||
69
firmware-cpp/test/ota_test.cpp
Normal file
69
firmware-cpp/test/ota_test.cpp
Normal file
|
|
@ -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-----
|
||||
<YOUR HOST CERTIFICATE HERE>
|
||||
-----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);
|
||||
}
|
||||
17
firmware-cpp/test/print_mac_address_test.cpp
Normal file
17
firmware-cpp/test/print_mac_address_test.cpp
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#include <WiFi.h>
|
||||
|
||||
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);
|
||||
}
|
||||
36
firmware-cpp/test/rgb_led_test.cpp
Normal file
36
firmware-cpp/test/rgb_led_test.cpp
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#include <Arduino.h>
|
||||
|
||||
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
|
||||
}
|
||||
18
firmware-cpp/test/simple_led_test.cpp
Normal file
18
firmware-cpp/test/simple_led_test.cpp
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
#include <Arduino.h>
|
||||
|
||||
// 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
|
||||
}
|
||||
198
firmware-cpp/test/speaker_radio_shutdown_test.cpp
Normal file
198
firmware-cpp/test/speaker_radio_shutdown_test.cpp
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
#include <Arduino.h>
|
||||
/*
|
||||
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 = "<wifi>";
|
||||
String password = "<pw>";
|
||||
|
||||
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);
|
||||
}
|
||||
146
firmware-cpp/test/speaker_radio_test.cpp
Normal file
146
firmware-cpp/test/speaker_radio_test.cpp
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
|
||||
#include <Arduino.h>
|
||||
/*
|
||||
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 = "<wifi>"; // replace your WiFi name
|
||||
String password = "<pw>"; // 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);
|
||||
}
|
||||
123
firmware-cpp/test/touch_led_test.cpp
Normal file
123
firmware-cpp/test/touch_led_test.cpp
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
#include <Arduino.h>
|
||||
#include <FreeRTOS.h>
|
||||
#include <task.h>
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
149
firmware-cpp/test/touch_sleep.cpp
Normal file
149
firmware-cpp/test/touch_sleep.cpp
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
#include <Arduino.h>
|
||||
#include <esp_sleep.h>
|
||||
#include <driver/touch_sensor.h>
|
||||
|
||||
// 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.
|
||||
}
|
||||
29
firmware-cpp/test/touch_test.cpp
Normal file
29
firmware-cpp/test/touch_test.cpp
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#include <Arduino.h>
|
||||
|
||||
// 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
|
||||
}
|
||||
139
firmware-cpp/test/wifi_manager_test.cpp
Normal file
139
firmware-cpp/test/wifi_manager_test.cpp
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
#include <Arduino.h>
|
||||
/*
|
||||
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 <WiFiManager.h> // 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);
|
||||
}
|
||||
BIN
frontend-nextjs/.DS_Store
vendored
Normal file
BIN
frontend-nextjs/.DS_Store
vendored
Normal file
Binary file not shown.
16
frontend-nextjs/.env.example
Normal file
16
frontend-nextjs/.env.example
Normal file
|
|
@ -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=<YOUR_SUPABASE_URL>
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<YOUR_SUPABASE_ANON_KEY>
|
||||
JWT_SECRET_KEY=<YOUR_JWT_SECRET_KEY>
|
||||
ENCRYPTION_KEY=<YOUR_ENCRYPTION_KEY>
|
||||
OPENAI_API_KEY=<YOUR_OPENAI_API_KEY>
|
||||
ENCRYPTION_KEY=<YOUR_ENCRYPTION_KEY>
|
||||
|
||||
# NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
|
||||
GOOGLE_OAUTH=True
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=<YOUR_STRIPE_SECRET_KEY>
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=<YOUR_STRIPE_PUBLISHABLE_KEY>
|
||||
4
frontend-nextjs/.gitignore
vendored
Normal file
4
frontend-nextjs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
.env
|
||||
.env.local
|
||||
.next
|
||||
83
frontend-nextjs/Dockerfile
Normal file
83
frontend-nextjs/Dockerfile
Normal file
|
|
@ -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"]
|
||||
96
frontend-nextjs/README.md
Normal file
96
frontend-nextjs/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<a href="https://demo-nextjs-with-supabase.vercel.app/">
|
||||
<img alt="Next.js and Supabase Starter Kit - the fastest way to build apps with Next.js and Supabase" src="https://demo-nextjs-with-supabase.vercel.app/opengraph-image.png">
|
||||
<h1 align="center">Next.js and Supabase Starter Kit</h1>
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
The fastest way to build apps with Next.js and Supabase
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#features"><strong>Features</strong></a> ·
|
||||
<a href="#demo"><strong>Demo</strong></a> ·
|
||||
<a href="#deploy-to-vercel"><strong>Deploy to Vercel</strong></a> ·
|
||||
<a href="#clone-and-run-locally"><strong>Clone and run locally</strong></a> ·
|
||||
<a href="#feedback-and-issues"><strong>Feedback and issues</strong></a>
|
||||
<a href="#more-supabase-examples"><strong>More Examples</strong></a>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
## 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.
|
||||
|
||||
[](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)
|
||||
58
frontend-nextjs/app/(auth-pages)/forgot-password/page.tsx
Normal file
58
frontend-nextjs/app/(auth-pages)/forgot-password/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex-1 flex flex-col w-full px-8 sm:max-w-md justify-center gap-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-row gap-1 items-center">
|
||||
Reset Password
|
||||
<Sparkles size={20} fill="black" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email to reset your account password.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
{/* <ToyPreview /> */}
|
||||
<form className="flex-1 flex flex-col w-full justify-center gap-4">
|
||||
<div className="flex flex-col gap-2 [&>input]:mb-3">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input name="email" placeholder="you@example.com" required />
|
||||
<SubmitButton formAction={forgotPasswordAction}>
|
||||
Reset Password
|
||||
</SubmitButton>
|
||||
<FormMessage message={searchParams} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-secondary-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link className="text-primary underline" href="/login">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend-nextjs/app/(auth-pages)/layout.tsx
Normal file
19
frontend-nextjs/app/(auth-pages)/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-1 flex-col mx-auto w-full max-w-[1440px] min-h-screen items-center mt-16">
|
||||
<div className="w-full flex justify-center">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend-nextjs/app/(auth-pages)/login/messages.tsx
Normal file
23
frontend-nextjs/app/(auth-pages)/login/messages.tsx
Normal file
|
|
@ -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 && (
|
||||
<p className="p-4 rounded-md border bg-red-200 border-red-300 text-gray-800 text-center text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p className="p-4 rounded-md border bg-green-50 border-green-400 text-gray-900 text-center text-sm">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
155
frontend-nextjs/app/(auth-pages)/login/page.tsx
Normal file
155
frontend-nextjs/app/(auth-pages)/login/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex-1 flex flex-col w-full px-8 sm:max-w-md justify-center gap-2">
|
||||
<Card className="shadow-md sm:bg-white bg-transparent shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-row gap-1 items-center">
|
||||
Login to Elato
|
||||
<Sparkles size={20} fill="black" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Login or sign up your account to continue
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
{/* <ToyPreview /> */}
|
||||
|
||||
{isGoogleOAuthEnabled && (
|
||||
<GoogleLoginButton
|
||||
toy_id={toy_id}
|
||||
personality_id={personality_id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* <Separator className="mt-2" />
|
||||
<div className="flex flex-row gap-2 items-center w-full h-[300px] mx-auto relative rounded-xl overflow-hidden">
|
||||
<Image src="/teddy.png" alt="Elato Login" fill className="object-cover" />
|
||||
</div> */}
|
||||
|
||||
{/*<form className="flex-1 flex flex-col w-full justify-center gap-4">
|
||||
<Label className="text-md" htmlFor="email">
|
||||
Email
|
||||
</Label>
|
||||
<input
|
||||
className="rounded-md px-4 py-2 bg-inherit border"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
<Label className="text-md" htmlFor="email">
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<input
|
||||
className="rounded-md px-4 py-2 bg-inherit border"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
|
||||
<Link
|
||||
className="text-xs text-foreground underline"
|
||||
href="/forgot-password"
|
||||
>
|
||||
Forgot Password?
|
||||
</Link>
|
||||
|
||||
<SubmitButton
|
||||
formAction={signInOrSignUp}
|
||||
className="text-sm font-medium bg-gray-100 hover:bg-gray-50 dark:text-stone-900 border-[0.1px] rounded-md px-4 py-2 text-foreground my-2"
|
||||
pendingText="Signing In..."
|
||||
>
|
||||
Continue with Email
|
||||
</SubmitButton>
|
||||
{searchParams?.message && (
|
||||
<p className="p-4 rounded-md border bg-green-50 border-green-400 text-gray-900 text-center text-sm">
|
||||
{searchParams.message}
|
||||
</p>
|
||||
)}
|
||||
</form> */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend-nextjs/app/(auth-pages)/login/submit-button.tsx
Normal file
20
frontend-nextjs/app/(auth-pages)/login/submit-button.tsx
Normal file
|
|
@ -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 (
|
||||
<button {...props} type="submit" aria-disabled={pending}>
|
||||
{isPending ? pendingText : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
262
frontend-nextjs/app/actions.ts
Normal file
262
frontend-nextjs/app/actions.ts
Normal file
|
|
@ -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<boolean> {
|
||||
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<IPersonality | null> => {
|
||||
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;
|
||||
}
|
||||
11
frontend-nextjs/app/animation/page.tsx
Normal file
11
frontend-nextjs/app/animation/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import FluidAISpeakingAnimation from "../components/Realtime/Animation";
|
||||
import FluidBubbleAnimation from "../components/Realtime/Animation1";
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* <FluidAISpeakingAnimation /> */}
|
||||
<FluidBubbleAnimation />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
351
frontend-nextjs/app/api/checkout/route.ts
Normal file
351
frontend-nextjs/app/api/checkout/route.ts
Normal file
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
38
frontend-nextjs/app/api/factory_reset_handler/route.ts
Normal file
38
frontend-nextjs/app/api/factory_reset_handler/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
82
frontend-nextjs/app/api/generate_auth_token/route.ts
Normal file
82
frontend-nextjs/app/api/generate_auth_token/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
frontend-nextjs/app/api/ota_update_handler/route.ts
Normal file
38
frontend-nextjs/app/api/ota_update_handler/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
266
frontend-nextjs/app/api/session/route.ts
Normal file
266
frontend-nextjs/app/api/session/route.ts
Normal file
|
|
@ -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<string> => {
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
47
frontend-nextjs/app/auth/callback/route.ts
Normal file
47
frontend-nextjs/app/auth/callback/route.ts
Normal file
|
|
@ -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`);
|
||||
}
|
||||
5
frontend-nextjs/app/children/page.tsx
Normal file
5
frontend-nextjs/app/children/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Home() {
|
||||
redirect("/healthcare");
|
||||
}
|
||||
92
frontend-nextjs/app/components/AuthTokenModal.tsx
Normal file
92
frontend-nextjs/app/components/AuthTokenModal.tsx
Normal file
|
|
@ -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<AuthTokenModalProps> = ({ user, userHasApiKey, hasApiKey, setHasApiKey }) => {
|
||||
const { toast } = useToast();
|
||||
const [apiKey, setApiKey] = useState<string>("");
|
||||
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex flex-row items-center gap-2"
|
||||
onClick={async () => {
|
||||
userHasApiKey();
|
||||
}}
|
||||
>
|
||||
<Key size={16} />
|
||||
<span>Set your key</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set your OpenAI API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
This key is kept encrypted and never stored on our servers as plain text.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-row gap-2 py-4">
|
||||
<Input
|
||||
id="api_key"
|
||||
value={apiKey}
|
||||
disabled={hasApiKey}
|
||||
placeholder={hasApiKey ? "sk-... (OpenAI API Key already set)" : "sk-..."}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
// disabled={!apiKey || hasApiKey}
|
||||
onClick={async () => {
|
||||
if (!hasApiKey) {
|
||||
await storeUserApiKey(user.user_id, apiKey);
|
||||
setHasApiKey(true); // Set this immediately
|
||||
userHasApiKey();
|
||||
toast({
|
||||
description: "OpenAI API Key added",
|
||||
});
|
||||
setApiKey("********************");
|
||||
} else {
|
||||
await deleteUserApiKey(user.user_id);
|
||||
setHasApiKey(false);
|
||||
userHasApiKey();
|
||||
toast({
|
||||
description: "OpenAI API Key removed",
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{!hasApiKey ?<Check className="flex-shrink-0" size={18} /> : <Trash className="flex-shrink-0" size={18} />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthTokenModal;
|
||||
36
frontend-nextjs/app/components/ChatAvatar.tsx
Normal file
36
frontend-nextjs/app/components/ChatAvatar.tsx
Normal file
|
|
@ -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<ChatAvatarProps> = ({
|
||||
role,
|
||||
user,
|
||||
}) => {
|
||||
const imageSrc: string =
|
||||
role === "input"
|
||||
? getUserAvatar(user.avatar_url)
|
||||
: `/personality/${user.personality?.key}.jpeg`;
|
||||
|
||||
return (
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage
|
||||
src={imageSrc}
|
||||
alt="@shadcn"
|
||||
className="object-contain"
|
||||
/>
|
||||
<AvatarFallback className="text-sm">
|
||||
{user.email.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatAvatar;
|
||||
|
|
@ -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<typeof formSchema>;
|
||||
|
||||
const SettingsDashboard: React.FC<SettingsDashboardProps> = ({
|
||||
selectedUser,
|
||||
allLanguages,
|
||||
}) => {
|
||||
const supabase = createClient();
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState<'personality' | 'voice'>('personality');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
|
||||
const [languageState, setLanguageState] = useState<string>(
|
||||
selectedUser.language_code! // Initial value from props
|
||||
);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
prompt: '',
|
||||
voice: '',
|
||||
voiceCharacteristics: {
|
||||
features: '',
|
||||
emotion: 'neutral'
|
||||
}
|
||||
});
|
||||
|
||||
const [touchedFields, setTouchedFields] = useState<Record<string, boolean>>({});
|
||||
|
||||
|
||||
const [formErrors, setFormErrors] = useState<Partial<Record<keyof FormData | 'features', string>>>({});
|
||||
|
||||
|
||||
const [previewingVoice, setPreviewingVoice] = useState<string | null>(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<HTMLFormElement>) => {
|
||||
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<Record<keyof FormData | 'features', string>> = {};
|
||||
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<HTMLAudioElement | null>(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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row gap-4 items-center sm:justify-normal justify-between max-w-screen-sm">
|
||||
<div className="flex flex-row gap-4 items-center justify-between w-full">
|
||||
<h1 className="text-3xl font-normal">Create your AI Character</h1>
|
||||
</div>
|
||||
</div>
|
||||
<HomePageSubtitles user={selectedUser} page="create" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden pb-2 w-full flex-auto flex flex-col pl-1 max-w-screen-sm">
|
||||
<Heading />
|
||||
<form onSubmit={handleSubmit} className="space-y-6 mt-8 w-full pr-1">
|
||||
|
||||
{currentStep === 'personality' ? <div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold border-b border-gray-200 pb-2">
|
||||
Character Details
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="E.g., 'Storytelling Assistant'"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
onBlur={() => handleBlur('title')}
|
||||
/>
|
||||
<p className="text-sm flex justify-between">
|
||||
<span className={formErrors.title ? "text-red-500" : "text-gray-500"}>
|
||||
{formErrors.title || "Give your AI character a name or title."}
|
||||
</span>
|
||||
<span className="text-gray-500">{formData.title.length}/50</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Describe what your AI character does and its personality..."
|
||||
rows={2}
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
onBlur={() => handleBlur('description')} />
|
||||
<p className="text-sm flex justify-between">
|
||||
<span className={formErrors.description ? "text-red-500" : "text-gray-500"}>
|
||||
{formErrors.description || "Briefly describe your character's purpose and personality."}
|
||||
</span>
|
||||
<span className="text-gray-500">{formData.description.length}/200</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt">Prompt</Label>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
placeholder="Enter specific instructions for how your AI should respond..."
|
||||
rows={4}
|
||||
value={formData.prompt}
|
||||
onChange={(e) => handleInputChange('prompt', e.target.value)}
|
||||
onBlur={() => handleBlur('prompt')}
|
||||
/>
|
||||
<p className="text-sm flex justify-between">
|
||||
<span className={formErrors.prompt ? "text-red-500" : "text-gray-500"}>
|
||||
{formErrors.prompt || "Detailed instructions that define how your AI responds to users."}
|
||||
</span>
|
||||
<span className="text-gray-500">{formData.prompt.length}/1000</span>
|
||||
</p>
|
||||
</div>
|
||||
</div> :
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold border-b border-gray-200 pb-2">
|
||||
Voice Details
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="voice">Pick a voice</Label>
|
||||
<p className="text-sm text-gray-500">
|
||||
Click a voice to preview how it sounds. Select one for your character.
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{voices.map((voice) => (
|
||||
<div
|
||||
key={voice.id}
|
||||
className={`
|
||||
rounded-lg border p-3 transition-all relative
|
||||
${formData.voice === voice.id
|
||||
? 'border-2 border-blue-500 shadow-sm ' + voice.color
|
||||
: 'border-gray-200 hover:border-gray-300 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
onClick={() => {
|
||||
handleInputChange('voice', voice.id);
|
||||
previewVoice(voice.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-3">
|
||||
<div className="text-2xl mt-0.5">
|
||||
<EmojiComponent emoji={voice.emoji} />
|
||||
</div>
|
||||
<div className="flex flex-col text-center sm:text-left">
|
||||
<span className="font-medium">{voice.name}</span>
|
||||
<span className="text-xs text-gray-600">{voice.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewingVoice === voice.id && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="animate-pulse text-blue-500">
|
||||
<Volume2 size={20} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="voiceCharacteristics">Characteristics</Label>
|
||||
<Textarea
|
||||
id="voiceCharacteristics"
|
||||
placeholder="e.g., Medium pitch, Normal speed, Clear voice"
|
||||
className="w-full min-h-16"
|
||||
rows={2}
|
||||
value={formData.voiceCharacteristics.features}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
voiceCharacteristics: {
|
||||
...prev.voiceCharacteristics,
|
||||
features: value
|
||||
}
|
||||
}));
|
||||
// Only validate if touched
|
||||
if (touchedFields['features']) {
|
||||
validateField('features', value);
|
||||
}
|
||||
}}
|
||||
onBlur={() => handleBlur('features')}
|
||||
/>
|
||||
<p className="text-sm flex justify-between">
|
||||
<span className={formErrors.features ? "text-red-500" : "text-gray-500"}>
|
||||
{formErrors.features || "Describe the voice characteristics."}
|
||||
</span>
|
||||
<span className="text-gray-500">{formData.voiceCharacteristics.features.length}/150</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label className="block mb-2">Emotional Tone</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{emotionOptions.map((emotion) => (
|
||||
<div
|
||||
key={emotion.value}
|
||||
className={`
|
||||
rounded-lg border p-3 cursor-pointer transition-all
|
||||
${formData.voiceCharacteristics.emotion === emotion.value
|
||||
? 'border-2 border-blue-500 shadow-sm ' + emotion.color
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
onClick={() => handleVoiceCharacteristicChange('emotion', emotion.value)}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<EmojiComponent emoji={emotion.icon} />
|
||||
<span className="text-sm font-medium">{emotion.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
|
||||
{currentStep === 'personality' ? (
|
||||
<Button
|
||||
onClick={() => setCurrentStep('voice')}
|
||||
className="ml-auto flex flex-row gap-2 items-center"
|
||||
>
|
||||
Add Voice Features <ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="w-full flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-row gap-2 items-center"
|
||||
onClick={() => setCurrentStep('personality')}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
className="flex flex-row gap-2 items-center"
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
formData.title === '' ||
|
||||
formData.description === '' ||
|
||||
formData.prompt === '' ||
|
||||
formData.voice === '' ||
|
||||
formData.voiceCharacteristics.features === ''
|
||||
}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create"} {!isSubmitting && <Check className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsDashboard;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import Twemoji from "react-twemoji";
|
||||
|
||||
const EmojiComponent = ({ emoji }: { emoji: string | undefined }) => {
|
||||
return (
|
||||
<div className="w-7 h-7 flex items-center justify-center">
|
||||
<Twemoji
|
||||
options={{ className: "twemoji w-7 h-7 flex-shrink-0" }}
|
||||
>
|
||||
{emoji}
|
||||
</Twemoji>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiComponent;
|
||||
44
frontend-nextjs/app/components/CreditsRemaining.tsx
Normal file
44
frontend-nextjs/app/components/CreditsRemaining.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getCreditsRemaining } from "@/lib/utils";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import AddCreditsModal from "./Upsell/AddCreditsModal";
|
||||
|
||||
const CreditsRemaining: React.FC<{
|
||||
user: IUser;
|
||||
languageCode: string;
|
||||
}> = ({ user, languageCode }) => {
|
||||
const creditsRemaining = getCreditsRemaining(user);
|
||||
const hasNoCredits = creditsRemaining <= 0;
|
||||
|
||||
if (user.is_premium) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<p
|
||||
className={`text-sm ${hasNoCredits ? "text-gray-400" : "text-gray-400"}`}
|
||||
>
|
||||
{creditsRemaining} {"credits remaining"}
|
||||
</p>
|
||||
{creditsRemaining <= 50 && (
|
||||
<AddCreditsModal>
|
||||
<Button
|
||||
variant="upsell_link"
|
||||
className="flex flex-row items-center gap-2 pl-0"
|
||||
size="sm"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{hasNoCredits
|
||||
? "Upgrade to continue"
|
||||
: "Get unlimited access"}
|
||||
</Button>
|
||||
</AddCreditsModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreditsRemaining;
|
||||
93
frontend-nextjs/app/components/Expressions.tsx
Normal file
93
frontend-nextjs/app/components/Expressions.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { expressionColors, isExpressionColor } from "@/lib/expressionColors";
|
||||
import { motion } from "framer-motion";
|
||||
import { CSSProperties } from "react";
|
||||
import * as R from "remeda";
|
||||
|
||||
export default function Expressions({
|
||||
values,
|
||||
}: {
|
||||
values: Record<string, number>;
|
||||
}) {
|
||||
const top3 = R.pipe(
|
||||
values,
|
||||
R.entries(),
|
||||
R.sortBy(R.pathOr([1], 0)),
|
||||
R.reverse(),
|
||||
R.take(3)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"text-xs p-3 w-full border-t border-border flex flex-col md:flex-row gap-3 bg-stone-50 rounded-b-[7px]"
|
||||
}
|
||||
>
|
||||
{top3.map(([key, value], index) => (
|
||||
<div className={"w-full overflow-hidden"} key={index}>
|
||||
<div
|
||||
className={"flex items-center justify-between gap-1 font-mono pb-1"}
|
||||
>
|
||||
{/* <Badge
|
||||
variant="outline"
|
||||
className="font-medium truncate text-xs opacity-70 text-white"
|
||||
style={
|
||||
|
||||
}
|
||||
>
|
||||
{key}
|
||||
</Badge> */}
|
||||
<div className="flex flex-row items-center gap-2 px-2">
|
||||
<div
|
||||
className="rounded-full w-4 h-4 opacity-70"
|
||||
style={
|
||||
{
|
||||
backgroundColor: isExpressionColor(key)
|
||||
? expressionColors[key]
|
||||
: "var(--bg)",
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
<div className={"font-medium truncate"}>{key}</div>
|
||||
<div className={"tabular-nums text-xs opacity-40"}>
|
||||
{Math.round(100 * value)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* */}
|
||||
</div>
|
||||
{/* <div
|
||||
className={"relative h-1"}
|
||||
style={
|
||||
{
|
||||
"--bg": isExpressionColor(key)
|
||||
? expressionColors[key]
|
||||
: "var(--bg)",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"absolute top-0 left-0 size-full rounded-full opacity-10 bg-[var(--bg)]"
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
className={
|
||||
"absolute top-0 left-0 h-full bg-[var(--bg)] rounded-full"
|
||||
}
|
||||
initial={{ width: 0 }}
|
||||
animate={{
|
||||
width: `${R.pipe(
|
||||
value,
|
||||
R.clamp({ min: 0, max: 1 }),
|
||||
(value) => `${value * 100}%`
|
||||
)}`,
|
||||
}}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend-nextjs/app/components/Footer.tsx
Normal file
93
frontend-nextjs/app/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Mail } from "lucide-react";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FaDiscord, FaGithub } from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
discordInviteLink,
|
||||
githubPublicLink,
|
||||
feedbackFormLink,
|
||||
} from "@/lib/data";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
|
||||
export default function Footer() {
|
||||
const pathname = usePathname();
|
||||
const isHome = pathname.includes("/home");
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const isRoot = pathname === "/";
|
||||
return (
|
||||
<footer
|
||||
className={`w-full ${
|
||||
isHome ? "pb-16" : "pb-2"
|
||||
} ${
|
||||
isRoot ? "bg-gradient-to-r bg-purple-900 text-white border-transparent" : "border-gray-200"
|
||||
} flex flex-col sm:flex-row items-center sm:justify-center border-t-[1px] mx-auto text-center text-xs sm:gap-8 sm:py-1 py-2`}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-8">
|
||||
<a href={feedbackFormLink} target="_blank">
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="font-normal text-grey-700 text-xs"
|
||||
aria-label="Mail"
|
||||
>
|
||||
<Mail size={18} className="mr-2" />
|
||||
Send feedback
|
||||
</Button>
|
||||
</a>
|
||||
<Label className={`font-normal text-xs ${isRoot ? "text-gray-100" : "text-gray-500"}`}>
|
||||
Elato AI © {new Date().getFullYear()} All rights
|
||||
reserved.
|
||||
</Label>
|
||||
</div>
|
||||
{/* <Separator orientation="vertical" /> */}
|
||||
<div
|
||||
className={`flex-row items-center gap-8 ${
|
||||
isHome && isMobile ? "hidden" : "flex"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Link href={githubPublicLink} passHref>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`w-7 h-7 ${isRoot ? "text-gray-100" : "text-gray-500"}`}
|
||||
>
|
||||
<FaGithub />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={discordInviteLink} passHref>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`w-7 h-7 ${isRoot ? "text-gray-100" : "text-gray-500"}`}
|
||||
>
|
||||
<FaDiscord />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/privacy"
|
||||
className={`font-normal underline ${isRoot ? "text-gray-100" : "text-gray-500"} text-xs`}
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/terms"
|
||||
className={`font-normal underline ${isRoot ? "text-gray-100" : "text-gray-500"} text-xs`}
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* <ThemeSwitcher /> */}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
37
frontend-nextjs/app/components/GetInTouch.tsx
Normal file
37
frontend-nextjs/app/components/GetInTouch.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { SendHorizonal } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { businessDemoLink } from "@/lib/data";
|
||||
|
||||
interface PreorderButtonProps {
|
||||
size: "sm" | "lg";
|
||||
className?: string;
|
||||
iconOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const GetInTouchButton = ({
|
||||
size,
|
||||
className,
|
||||
iconOnMobile,
|
||||
}: PreorderButtonProps) => {
|
||||
return (
|
||||
<Link href={businessDemoLink} passHref>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-4 font-medium text-base bg-stone-800 leading-8 rounded-full",
|
||||
iconOnMobile ? "px-3" : "px-4",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
>
|
||||
{<SendHorizonal size={18} strokeWidth={3} />}
|
||||
{!iconOnMobile && <span>Get in touch</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default GetInTouchButton;
|
||||
52
frontend-nextjs/app/components/GoogleLoginButton.tsx
Normal file
52
frontend-nextjs/app/components/GoogleLoginButton.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { FaGoogle } from "react-icons/fa";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
import { defaultPersonalityId, defaultToyId } from "@/lib/data";
|
||||
|
||||
interface GoogleLoginButtonProps {
|
||||
toy_id?: string;
|
||||
personality_id?: string;
|
||||
}
|
||||
|
||||
export const loginWithGoogle = async (
|
||||
toy_id: string,
|
||||
personality_id: string
|
||||
) => {
|
||||
const supabase = createClient();
|
||||
|
||||
const redirectTo = `${location.origin}/auth/callback`;
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "google",
|
||||
options: {
|
||||
redirectTo,
|
||||
queryParams: {
|
||||
toy_id,
|
||||
personality_id,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default function GoogleLoginButton({
|
||||
toy_id,
|
||||
personality_id,
|
||||
}: GoogleLoginButtonProps) {
|
||||
// console.log("1324355345435", toy_id);
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
loginWithGoogle(
|
||||
toy_id ?? defaultToyId,
|
||||
personality_id ?? defaultPersonalityId
|
||||
)
|
||||
}
|
||||
>
|
||||
<FaGoogle className="w-4 h-4 mr-4" />
|
||||
<span>Continue with Google</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
45
frontend-nextjs/app/components/HomePageSubtitles.tsx
Normal file
45
frontend-nextjs/app/components/HomePageSubtitles.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import CreditsRemaining from "./CreditsRemaining";
|
||||
interface HomePageSubtitlesProps {
|
||||
user: IUser;
|
||||
page: "home" | "settings" | "create";
|
||||
languageCode?: string;
|
||||
}
|
||||
|
||||
const HomePageSubtitles: React.FC<HomePageSubtitlesProps> = ({
|
||||
user,
|
||||
page,
|
||||
languageCode = "en-US",
|
||||
}) => {
|
||||
if (page === "home") {
|
||||
if (user.user_info.user_type === "doctor") {
|
||||
return (
|
||||
<p className="text-sm text-gray-600">
|
||||
{"Use this playground or your device to engage your patients"}
|
||||
</p>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<p className="text-sm text-gray-600">
|
||||
{"Talk to any AI character below"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
} else if (page === "settings") {
|
||||
return (
|
||||
<p className="text-sm text-gray-600">
|
||||
{"You can update your settings below"}
|
||||
</p>
|
||||
);
|
||||
} else if (page === "create") {
|
||||
return (
|
||||
<p className="text-sm text-gray-600">
|
||||
{"Customize your character's voice, language, accent and much more"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// if they are a regular user
|
||||
// return <CreditsRemaining user={user} languageCode={languageCode} />;
|
||||
};
|
||||
|
||||
export default HomePageSubtitles;
|
||||
136
frontend-nextjs/app/components/Insights/BarChart.tsx
Normal file
136
frontend-nextjs/app/components/Insights/BarChart.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use client";
|
||||
|
||||
// install (please try to align the version of installed @nivo packages)
|
||||
// yarn add @nivo/bar
|
||||
import { ResponsiveBar } from "@nivo/bar";
|
||||
import { FC } from "react";
|
||||
|
||||
// make sure parent container have a defined height when using
|
||||
// responsive component, otherwise height will be 0 and
|
||||
// no chart will be rendered.
|
||||
// website examples showcase many properties,
|
||||
// you'll often use just a few of them.
|
||||
|
||||
type MyResponsiveBarProps = {
|
||||
data: BarData[];
|
||||
filter: string;
|
||||
};
|
||||
|
||||
export const MyResponsiveBar: FC<MyResponsiveBarProps> = ({ data, filter }) => {
|
||||
// Determine the keys based on the filter
|
||||
let currentPeriodLabel = "Current Period";
|
||||
let previousPeriodLabel = "Previous Period";
|
||||
|
||||
if (filter === "days") {
|
||||
currentPeriodLabel = "Today";
|
||||
previousPeriodLabel = "Yesterday";
|
||||
} else if (filter === "weeks") {
|
||||
currentPeriodLabel = "This month";
|
||||
previousPeriodLabel = "Last month";
|
||||
}
|
||||
|
||||
const keys = [currentPeriodLabel, previousPeriodLabel];
|
||||
|
||||
return (
|
||||
<ResponsiveBar
|
||||
data={data}
|
||||
keys={keys}
|
||||
indexBy="emotion"
|
||||
margin={{ top: 20, right: 54, bottom: 80, left: 55 }}
|
||||
padding={0.3}
|
||||
groupMode="grouped"
|
||||
valueScale={{ type: "linear" }}
|
||||
indexScale={{ type: "band", round: true }}
|
||||
// // colors={{ scheme: "nivo" }}
|
||||
colors={["#fbbf24", "#c084fc"]}
|
||||
borderRadius={5}
|
||||
borderColor={{
|
||||
from: "color",
|
||||
modifiers: [["darker", 1.6]],
|
||||
}}
|
||||
axisTop={null}
|
||||
axisRight={null}
|
||||
axisBottom={{
|
||||
tickSize: 5,
|
||||
tickPadding: 5,
|
||||
tickRotation: 30,
|
||||
// legend: "country",
|
||||
legendPosition: "middle",
|
||||
legendOffset: 32,
|
||||
truncateTickAt: 0,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 5,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legend: "Score (%)",
|
||||
legendPosition: "middle",
|
||||
legendOffset: -50,
|
||||
truncateTickAt: 0,
|
||||
}}
|
||||
enableGridY={true}
|
||||
labelSkipWidth={20}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor="white"
|
||||
// // labelTextColor={{
|
||||
// // from: "color",
|
||||
// // modifiers: [["brighter", 3]],
|
||||
// // }}
|
||||
legends={[
|
||||
{
|
||||
dataFrom: "keys",
|
||||
anchor: "bottom",
|
||||
direction: "row",
|
||||
justify: false,
|
||||
translateX: 0,
|
||||
translateY: 80,
|
||||
itemsSpacing: 0,
|
||||
itemWidth: 100,
|
||||
itemHeight: 20,
|
||||
itemDirection: "left-to-right",
|
||||
itemOpacity: 1,
|
||||
symbolSize: 16,
|
||||
symbolShape: "circle",
|
||||
effects: [
|
||||
{
|
||||
on: "hover",
|
||||
style: {
|
||||
itemTextColor: "#000",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
// role="application"
|
||||
// ariaLabel="Nivo bar chart demo"
|
||||
barAriaLabel={(e) =>
|
||||
`${e.id}: ${e.formattedValue} in emotion: ${e.indexValue}`
|
||||
}
|
||||
theme={{
|
||||
axis: {
|
||||
legend: {
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fill: "#4b5563",
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
fill: "#4b5563",
|
||||
},
|
||||
},
|
||||
},
|
||||
legends: {
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fill: "#4b5563",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
107
frontend-nextjs/app/components/Insights/Heatmap.tsx
Normal file
107
frontend-nextjs/app/components/Insights/Heatmap.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
// install (please try to align the version of installed @nivo packages)
|
||||
// yarn add @nivo/heatmap
|
||||
import { ResponsiveHeatMap } from "@nivo/heatmap";
|
||||
import { FC } from "react";
|
||||
|
||||
// make sure parent container have a defined height when using
|
||||
// responsive component, otherwise height will be 0 and
|
||||
// no chart will be rendered.
|
||||
// website examples showcase many properties,
|
||||
// you'll often use just a few of them.
|
||||
|
||||
type MyResponsiveHeatMapProps = {
|
||||
data: HeatMapData[];
|
||||
};
|
||||
|
||||
export const MyResponsiveHeatMap: FC<MyResponsiveHeatMapProps> = ({ data }) => (
|
||||
<ResponsiveHeatMap
|
||||
data={data}
|
||||
margin={{ top: 60, right: 10, bottom: 10, left: 70 }}
|
||||
valueFormat=">-.2s"
|
||||
axisTop={{
|
||||
tickSize: 5,
|
||||
tickPadding: 8,
|
||||
tickRotation: -45,
|
||||
legend: "",
|
||||
legendOffset: 46,
|
||||
truncateTickAt: 0,
|
||||
}}
|
||||
// axisRight={{
|
||||
// tickSize: 5,
|
||||
// tickPadding: 5,
|
||||
// tickRotation: 0,
|
||||
// // legend: "country",
|
||||
// // legendPosition: "middle",
|
||||
// legendOffset: 70,
|
||||
// truncateTickAt: 0,
|
||||
// }}
|
||||
axisLeft={{
|
||||
tickSize: 5,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
// legend: "country",
|
||||
// legendPosition: "middle",
|
||||
legendOffset: -70,
|
||||
truncateTickAt: 0,
|
||||
}}
|
||||
colors={{
|
||||
type: "diverging",
|
||||
scheme: "yellow_green",
|
||||
minValue: -100000,
|
||||
maxValue: 100000,
|
||||
divergeAt: 0.82,
|
||||
}}
|
||||
emptyColor="#555555"
|
||||
cellComponent="circle"
|
||||
sizeVariation={{
|
||||
sizes: [0.3, 0.97],
|
||||
}}
|
||||
// forceSquare
|
||||
enableGridX={true}
|
||||
enableGridY={true}
|
||||
borderWidth={3}
|
||||
borderColor="#fdba74"
|
||||
// borderColor={{
|
||||
// from: "color",
|
||||
// modifiers: [["darker", 0.5]],
|
||||
// }}
|
||||
// legends={[
|
||||
// {
|
||||
// anchor: "bottom",
|
||||
// translateX: 0,
|
||||
// translateY: 40,
|
||||
// length: 400,
|
||||
// thickness: 15,
|
||||
// direction: "row",
|
||||
// tickPosition: "after",
|
||||
// tickSize: 3,
|
||||
// tickSpacing: 4,
|
||||
// tickOverlap: false,
|
||||
// tickFormat: ">-.2s",
|
||||
// title: "Value →",
|
||||
// titleAlign: "start",
|
||||
// titleOffset: 4,
|
||||
// },
|
||||
// ]}
|
||||
theme={{
|
||||
axis: {
|
||||
ticks: {
|
||||
text: {
|
||||
fontSize: 12, // Change this to your desired font size
|
||||
fontWeight: 600,
|
||||
fill: "#4b5563", // Change this to your desired text color
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
text: {
|
||||
fontSize: 12, // Change this to your desired font size
|
||||
fontWeight: 600,
|
||||
fill: "#1f2937", // Change this to your desired text color
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
128
frontend-nextjs/app/components/Insights/LineChart.tsx
Normal file
128
frontend-nextjs/app/components/Insights/LineChart.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"use client";
|
||||
|
||||
import { ResponsiveLine } from "@nivo/line";
|
||||
import { FC } from "react";
|
||||
|
||||
// make sure parent container have a defined height when using
|
||||
// responsive component, otherwise height will be 0 and
|
||||
// no chart will be rendered.
|
||||
// website examples showcase many properties,
|
||||
// you'll often use just a few of them.
|
||||
|
||||
type MyResponsiveLineProps = {
|
||||
data: LineChartData[];
|
||||
};
|
||||
|
||||
export const MyResponsiveLine: FC<MyResponsiveLineProps> = ({ data }) => (
|
||||
<ResponsiveLine
|
||||
curve="monotoneX"
|
||||
lineWidth={3}
|
||||
margin={{ top: 20, right: 54, bottom: 68, left: 50 }}
|
||||
data={data}
|
||||
xFormat="time:%Y-%m-%d"
|
||||
yScale={{
|
||||
type: "linear",
|
||||
}}
|
||||
yFormat=" >-.2f"
|
||||
axisTop={null}
|
||||
axisRight={null}
|
||||
axisBottom={{
|
||||
// format: "%b %d",
|
||||
tickSize: 5,
|
||||
tickPadding: 5,
|
||||
tickRotation: 30,
|
||||
// legend: "Date",
|
||||
legendOffset: 36,
|
||||
legendPosition: "middle",
|
||||
truncateTickAt: 0,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 5,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legend: "Score (%)",
|
||||
legendOffset: -45,
|
||||
legendPosition: "middle",
|
||||
truncateTickAt: 0,
|
||||
}}
|
||||
enableTouchCrosshair={true}
|
||||
pointSize={10}
|
||||
pointColor={{ from: "color", modifiers: [] }}
|
||||
pointBorderWidth={2}
|
||||
pointBorderColor={{ from: "serieColor" }}
|
||||
pointLabel="data.yFormatted"
|
||||
pointLabelYOffset={-12}
|
||||
useMesh={true}
|
||||
colors={[
|
||||
"#fb7185",
|
||||
// "#FCCCD4",
|
||||
"#a8a29e",
|
||||
// "#e7e5e4",
|
||||
"#22c55e",
|
||||
// "#CCFBDD",
|
||||
]}
|
||||
legends={[
|
||||
{
|
||||
anchor: "bottom",
|
||||
direction: "row",
|
||||
justify: false,
|
||||
translateX: -20,
|
||||
translateY: 70,
|
||||
itemsSpacing: 10,
|
||||
itemDirection: "left-to-right",
|
||||
itemWidth: 65,
|
||||
itemHeight: 20,
|
||||
itemOpacity: 1,
|
||||
symbolSize: 16,
|
||||
symbolShape: "circle",
|
||||
symbolBorderColor: "rgba(0, 0, 0, .9)",
|
||||
effects: [
|
||||
{
|
||||
on: "hover",
|
||||
style: {
|
||||
itemBackground: "rgba(0, 0, 0, .03)",
|
||||
itemOpacity: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
tooltip={({ point }) => {
|
||||
const series = data.find((serie) => serie.id === point.serieId);
|
||||
return (
|
||||
<div className="bg-white p-2 rounded-lg shadow-md text-sm border">
|
||||
<strong>{series?.name}</strong>
|
||||
<br />
|
||||
{/* <strong>id:</strong> {point.data.xFormatted} */}
|
||||
{/* <br /> */}
|
||||
<strong>score:</strong> {point.data.yFormatted}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
theme={{
|
||||
axis: {
|
||||
legend: {
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fill: "#4b5563",
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
fill: "#4b5563",
|
||||
},
|
||||
},
|
||||
},
|
||||
legends: {
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fill: "#4b5563",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
125
frontend-nextjs/app/components/Insights/PieChart.tsx
Normal file
125
frontend-nextjs/app/components/Insights/PieChart.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"use client";
|
||||
// install (please try to align the version of installed @nivo packages)
|
||||
// yarn add @nivo/bar
|
||||
import { ResponsivePie } from "@nivo/pie";
|
||||
import { FC } from "react";
|
||||
|
||||
// make sure parent container have a defined height when using
|
||||
// responsive component, otherwise height will be 0 and
|
||||
// no chart will be rendered.
|
||||
// website examples showcase many properties,
|
||||
// you'll often use just a few of them.
|
||||
|
||||
// const favorit = localFont({
|
||||
// src: "./fonts/ABCFavorit-Bold.woff2",
|
||||
// variable: "--font-favorit",
|
||||
// });
|
||||
|
||||
// const fonts = `${favorit.variable}`;
|
||||
|
||||
type MyResponsivePieProps = {
|
||||
data: PieData[];
|
||||
};
|
||||
|
||||
// Define the theme object
|
||||
const theme = {
|
||||
legends: {
|
||||
text: {
|
||||
fontSize: 12, // Change this value to your desired font size
|
||||
fontWeight: 600,
|
||||
// fontFamily: fonts,
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
text: {
|
||||
fontSize: 16, // Change this value to your desired font size for the labels inside the pie
|
||||
fontWeight: 700,
|
||||
// fontFamily: fonts,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MyResponsivePie: FC<MyResponsivePieProps> = ({ data }) => (
|
||||
<ResponsivePie
|
||||
data={data}
|
||||
margin={{ top: 15, right: 5, bottom: 40, left: 5 }}
|
||||
innerRadius={0.5}
|
||||
padAngle={0.7}
|
||||
cornerRadius={3}
|
||||
activeOuterRadiusOffset={4}
|
||||
borderWidth={1}
|
||||
borderColor={{
|
||||
from: "color",
|
||||
modifiers: [["darker", 0.2]],
|
||||
}}
|
||||
enableArcLinkLabels={false}
|
||||
arcLinkLabelsSkipAngle={10}
|
||||
arcLinkLabelsTextColor="#333333"
|
||||
arcLinkLabelsThickness={2}
|
||||
arcLinkLabelsColor={{ from: "color" }}
|
||||
arcLabelsSkipAngle={10}
|
||||
arcLabelsTextColor="#fff"
|
||||
arcLabel={(d) => `${d.value.toFixed(2)}%`} // Display value in percentage
|
||||
// defs={[
|
||||
// {
|
||||
// id: "dots",
|
||||
// type: "patternDots",
|
||||
// background: "inherit",
|
||||
// color: "rgba(255, 255, 255, 0.3)",
|
||||
// size: 4,
|
||||
// padding: 1,
|
||||
// stagger: true,
|
||||
// },
|
||||
// {
|
||||
// id: "lines",
|
||||
// type: "patternLines",
|
||||
// background: "inherit",
|
||||
// color: "rgba(255, 255, 255, 0.3)",
|
||||
// rotation: -45,
|
||||
// lineWidth: 6,
|
||||
// spacing: 10,
|
||||
// },
|
||||
// ]}
|
||||
fill={[
|
||||
{
|
||||
match: {
|
||||
id: "Negative",
|
||||
},
|
||||
id: "dots",
|
||||
},
|
||||
{
|
||||
match: {
|
||||
id: "Positive",
|
||||
},
|
||||
id: "lines",
|
||||
},
|
||||
]}
|
||||
legends={[
|
||||
{
|
||||
anchor: "bottom",
|
||||
direction: "row",
|
||||
justify: false,
|
||||
translateX: 2,
|
||||
translateY: 40,
|
||||
itemsSpacing: 4,
|
||||
itemWidth: 80,
|
||||
itemHeight: 18,
|
||||
itemTextColor: "#4b5563",
|
||||
itemDirection: "left-to-right",
|
||||
itemOpacity: 1,
|
||||
symbolSize: 16,
|
||||
symbolShape: "circle",
|
||||
effects: [
|
||||
{
|
||||
on: "hover",
|
||||
style: {
|
||||
itemTextColor: "#000",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
theme={theme} // Apply the theme here
|
||||
colors={["#4ade80", "#d6d3d1", "#fb7185"]}
|
||||
/>
|
||||
);
|
||||
79
frontend-nextjs/app/components/Insights/TopCard.tsx
Normal file
79
frontend-nextjs/app/components/Insights/TopCard.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { TrendingDown } from "lucide-react";
|
||||
|
||||
interface CardProps {
|
||||
title: string | null;
|
||||
value: number | string | null;
|
||||
delta: number | null;
|
||||
filter: string;
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
const TopCard: React.FC<CardProps> = ({
|
||||
title,
|
||||
value,
|
||||
delta,
|
||||
filter,
|
||||
type,
|
||||
}) => {
|
||||
const isPositiveDelta = delta !== null && delta >= 0;
|
||||
// get the user data from the selected user and period
|
||||
|
||||
const bgColor = type === "top" ? "bg-amber-500" : "bg-violet-500";
|
||||
const titleColor = type === "top" ? "text-amber-50" : "text-violet-50";
|
||||
const footerColor = type === "top" ? "text-amber-100" : "text-violet-100";
|
||||
|
||||
const getTimePeriod = (filter: string): string => {
|
||||
if (filter === "weeks") return "last week";
|
||||
if (filter === "days") return "yesterday";
|
||||
if (filter === "months") return "last month";
|
||||
return ""; // Default case, can be adjusted based on your needs
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={`${bgColor}`}>
|
||||
<CardHeader className="pt-4 pb-2 ">
|
||||
<CardTitle
|
||||
className={`text-base font-medium ${titleColor}`}
|
||||
>
|
||||
<div className="flex justify-between items-center w-full">
|
||||
{title}
|
||||
</div>
|
||||
</CardTitle>
|
||||
{/* <CardDescription className="text-2xl font-bold text-gray-800">
|
||||
+ 1.8%
|
||||
</CardDescription> */}
|
||||
</CardHeader>
|
||||
<CardContent className="py-1 text-2xl font-bold text-white">
|
||||
<p>{value}</p>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter
|
||||
className={`text-sm ${footerColor} flex items-start`}
|
||||
>
|
||||
{delta !== null &&
|
||||
(isPositiveDelta ? (
|
||||
<TrendingUp className="h-[20px] text-green-500 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="h-[20px] text-rose-500 mr-1" />
|
||||
))}
|
||||
<p>
|
||||
{delta}% from {getTimePeriod(filter)}
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopCard;
|
||||
41
frontend-nextjs/app/components/LandingPage/AnimatedText.tsx
Normal file
41
frontend-nextjs/app/components/LandingPage/AnimatedText.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import Typed from "typed.js";
|
||||
|
||||
const AnimatedText: React.FC = () => {
|
||||
const el = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const options = {
|
||||
strings: [
|
||||
'<span class="text-blue-500">with interactive storytelling</span>',
|
||||
'<span class="text-purple-500">for language learning</span>',
|
||||
'<span class="text-green-500">for bedtime stories</span>',
|
||||
'<span class="text-orange-500">with educational adventures</span>',
|
||||
'<span class="text-pink-500">for vocabulary building</span>',
|
||||
'<span class="text-teal-500">as a reading companion</span>',
|
||||
],
|
||||
typeSpeed: 70, // Slightly faster
|
||||
backSpeed: 30, // Add some backspeed for playfulness
|
||||
backDelay: 800, // Shorter delay
|
||||
cursorChar: '🪄', // Child-friendly cursor
|
||||
fadeOut: true,
|
||||
fadeOutDelay: 1,
|
||||
loop: true,
|
||||
};
|
||||
|
||||
const typed = new Typed(el.current, options);
|
||||
|
||||
return () => {
|
||||
typed.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<h1 className="text-3xl font-semibold font-borel h-24 min-h-[6rem] flex items-center justify-center">
|
||||
<span ref={el} className="inline-block" />
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedText;
|
||||
215
frontend-nextjs/app/components/LandingPage/CharacterCarousel.tsx
Normal file
215
frontend-nextjs/app/components/LandingPage/CharacterCarousel.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { getPersonalityImageSrc } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { voiceSampleUrl } from "@/lib/data";
|
||||
|
||||
interface LandingPagePersonality {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const ChosenPersonalities: LandingPagePersonality[] = [
|
||||
{
|
||||
key: "geo_guide",
|
||||
name: "Travel guide",
|
||||
description: "A travel guide who can help you plan your next trip.",
|
||||
},
|
||||
{
|
||||
key: "ironman",
|
||||
name: "Ironman",
|
||||
description: "A superhero who can help you with your problems.",
|
||||
},
|
||||
{
|
||||
key: "math_wiz",
|
||||
name: "Your math tutor",
|
||||
description: "A math tutor who can help you with your math problems.",
|
||||
},
|
||||
{
|
||||
key: "master_chef",
|
||||
name: "Master chef",
|
||||
description: "A master chef who can help you with your cooking.",
|
||||
},
|
||||
{
|
||||
key: "art_guru",
|
||||
name: "Art teacher",
|
||||
description: "An art guru who can help you with your art.",
|
||||
},
|
||||
{
|
||||
key: "female_lover",
|
||||
name: "Romance healer",
|
||||
description:
|
||||
"A muse of connection who can help you with your love life.",
|
||||
},
|
||||
{
|
||||
key: "male_lover",
|
||||
name: "Poet of love",
|
||||
description: "A poet of love who can help you with your love life.",
|
||||
},
|
||||
{
|
||||
key: "sherlock",
|
||||
name: "SherlockGPT",
|
||||
description: "A detective who can help you with your problems.",
|
||||
},
|
||||
{
|
||||
key: "aggie_blood_test_pal",
|
||||
name: "Medical buddy",
|
||||
description:
|
||||
"A medical companion helping kids feel less anxious during procedures.",
|
||||
},
|
||||
];
|
||||
|
||||
interface CharacterCarouselCardProps {
|
||||
personality: LandingPagePersonality;
|
||||
}
|
||||
|
||||
const CharacterCarouselCard = ({ personality }: CharacterCarouselCardProps) => {
|
||||
const [playing, setPlaying] = useState<string | null>(null);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const playAudio = (personality: LandingPagePersonality) => {
|
||||
if (playing === personality.key && audioElement) {
|
||||
// Pause current audio
|
||||
audioElement.pause();
|
||||
setPlaying(null);
|
||||
setAudioElement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any currently playing audio
|
||||
if (audioElement) {
|
||||
audioElement.pause();
|
||||
}
|
||||
|
||||
const audio = new Audio(`${voiceSampleUrl}${personality.key}.wav`);
|
||||
audio.onended = () => {
|
||||
setPlaying(null);
|
||||
setAudioElement(null);
|
||||
};
|
||||
audio.play();
|
||||
setPlaying(personality.key);
|
||||
setAudioElement(audio);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex-shrink-0 w-[270px] border-none bg-gray-50">
|
||||
<CardContent className="flex flex-row items-start gap-2 p-0 ">
|
||||
{/* Circular Avatar */}
|
||||
<div className="w-[100px] h-[100px] relative rounded-lg rounded-tr-none rounded-br-none overflow-hidden flex-shrink-0">
|
||||
<Image
|
||||
src={getPersonalityImageSrc(personality.key)}
|
||||
alt={personality.key}
|
||||
width={100}
|
||||
height={100}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
className="w-fit absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||
onClick={() => playAudio(personality)}
|
||||
>
|
||||
{playing === personality.key ? (
|
||||
<Pause
|
||||
size={24}
|
||||
fill="white"
|
||||
className="text-white"
|
||||
/>
|
||||
) : (
|
||||
<Play
|
||||
size={24}
|
||||
fill="white"
|
||||
className="text-white"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Text and Play Button */}
|
||||
<div className="flex flex-col gap-1 p-2 opacity rounded-lg">
|
||||
<div className="flex flex-row items-center">
|
||||
<h3 className="font-semibold text-sm text-left truncate">
|
||||
{personality.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 text-xs text-left line-clamp-3">
|
||||
{personality.description}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const CharacterCarousel = () => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftShadow, setShowLeftShadow] = useState(false);
|
||||
const [showRightShadow, setShowRightShadow] = useState(true);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (scrollRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeftShadow(scrollLeft > 0);
|
||||
setShowRightShadow(scrollLeft < scrollWidth - clientWidth - 1);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (scrollElement) {
|
||||
scrollElement.addEventListener("scroll", handleScroll);
|
||||
handleScroll(); // Initial check
|
||||
return () =>
|
||||
scrollElement.removeEventListener("scroll", handleScroll);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-screen-md ml-4 sm:mx-auto">
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-white to-transparent z-10 pointer-events-none transition-opacity duration-300 ${
|
||||
showLeftShadow ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex overflow-x-auto scrollbar-hide gap-4"
|
||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
||||
>
|
||||
{ChosenPersonalities.map((personality, index) => (
|
||||
<CharacterCarouselCard
|
||||
personality={personality}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-white to-transparent z-10 pointer-events-none transition-opacity duration-300 ${
|
||||
showRightShadow ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
// <div className="w-full sm:max-w-screen-md mx-auto">
|
||||
// <div className="overflow-x-auto scrollbar-hide">
|
||||
// <div className="flex gap-4 p-4">
|
||||
// {ChosenPersonalities.map((personality, index) => (
|
||||
// <CharacterCarouselCard
|
||||
// personality={personality}
|
||||
// key={index}
|
||||
// />
|
||||
// ))}
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharacterCarousel;
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import Product1 from "@/public/images/decomposation_view.gif";
|
||||
import Product2 from "@/public/images/front_view.png";
|
||||
|
||||
// Import Swiper
|
||||
import Swiper from "swiper";
|
||||
import { Pagination, EffectFade } from "swiper/modules";
|
||||
// import Swiper and modules styles
|
||||
import "swiper/css";
|
||||
import "swiper/css/navigation";
|
||||
import "swiper/css/pagination";
|
||||
import "swiper/css/effect-fade";
|
||||
import Personalities from "./Personalities";
|
||||
Swiper.use([Pagination, EffectFade]);
|
||||
|
||||
interface CharacterPickerProps {
|
||||
allPersonalities: IPersonality[];
|
||||
}
|
||||
|
||||
export default function CharacterPicker({
|
||||
allPersonalities,
|
||||
}: CharacterPickerProps) {
|
||||
useEffect(() => {
|
||||
const character = new Swiper(".character-carousel", {
|
||||
slidesPerView: 1,
|
||||
watchSlidesProgress: true,
|
||||
effect: "fade",
|
||||
fadeEffect: {
|
||||
crossFade: true,
|
||||
},
|
||||
pagination: {
|
||||
el: ".character-carousel-pagination",
|
||||
clickable: true,
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="relative max-w-7xl gap-8 mx-auto text-center flex flex-col items-center justify-center">
|
||||
{/* Carousel */}
|
||||
|
||||
<div
|
||||
className="w-full md:w-3/5 md:mr-8 mb-8 md:mb-0 flex-shrink-0 h-[450px] shadow-custom"
|
||||
data-aos="fade-up"
|
||||
data-aos-anchor="[data-aos-id-6]"
|
||||
>
|
||||
<div className="character-carousel swiper-container max-w-sm mx-auto sm:max-w-none h-[450px] rounded-[30px]">
|
||||
<div className="swiper-wrapper">
|
||||
{/* corp */}
|
||||
{/* Card #1 */}
|
||||
<div className="swiper-slide w-full h-full flex-shrink-0 relative">
|
||||
<Image
|
||||
src={Product2}
|
||||
alt="Products all colors"
|
||||
sizes="100vw"
|
||||
fill
|
||||
style={{
|
||||
objectPosition: "center",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="swiper-slide w-full h-full flex-shrink-0 relative">
|
||||
<div className="rounded-[30px] overflow-hidden w-full h-full">
|
||||
<Image
|
||||
src={Product1}
|
||||
alt="Product decomposition view"
|
||||
sizes="100vw"
|
||||
fill
|
||||
style={{
|
||||
objectPosition: "center",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* corp */}
|
||||
{/* no Card #2 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bullets */}
|
||||
<div className="">
|
||||
<div className="character-carousel-pagination text-center" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { motion } from 'framer-motion';
|
||||
import SheetWrapper from '../Playground/SheetWrapper';
|
||||
|
||||
// Example character data - replace with your actual data
|
||||
const characters = [
|
||||
{ id: 1, name: 'Assistant', description: 'Helpful AI assistant', image: '/characters/assistant.png' },
|
||||
{ id: 2, name: 'Storyteller', description: 'Creative storytelling companion', image: '/characters/storyteller.png' },
|
||||
{ id: 3, name: 'Tutor', description: 'Patient educational guide', image: '/characters/tutor.png' },
|
||||
{ id: 4, name: 'Comedian', description: 'Witty joke-telling friend', image: '/characters/comedian.png' },
|
||||
{ id: 5, name: 'Chef', description: 'Culinary expert and recipe advisor', image: '/characters/chef.png' },
|
||||
{ id: 6, name: 'Fitness Coach', description: 'Motivational exercise companion', image: '/characters/fitness.png' },
|
||||
// Add more characters as needed
|
||||
];
|
||||
|
||||
interface CharacterShowcaseProps {
|
||||
allPersonalities: IPersonality[];
|
||||
}
|
||||
|
||||
export const CharacterShowcase = ({ allPersonalities }: CharacterShowcaseProps) => {
|
||||
const [hoveredCharacter, setHoveredCharacter] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="container mx-auto px-4 max-w-screen-lg">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-12">
|
||||
{/* Character List - On left for desktop, bottom for mobile */}
|
||||
<div className="order-2 lg:order-1 w-full lg:w-3/5 sm:max-w-[400px] mx-auto">
|
||||
<div className="relative">
|
||||
{/* Top scroll indicator/halo */}
|
||||
<div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-gray-50 to-transparent z-10 pointer-events-none rounded-t-lg"></div>
|
||||
|
||||
<div className="h-[500px] mx-auto overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 rounded-lg">
|
||||
<div className="grid grid-cols-2 gap-4 md:gap-6 p-4">
|
||||
{allPersonalities.map((personality, index) => (
|
||||
<SheetWrapper
|
||||
languageState={'en-US'}
|
||||
key={index + personality.personality_id!}
|
||||
personality={personality}
|
||||
personalityIdState={''}
|
||||
onPersonalityPicked={() => {}}
|
||||
disableButtons={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom scroll indicator/halo */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-gray-50 to-transparent z-10 pointer-events-none rounded-b-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Content - On right for desktop, top for mobile */}
|
||||
<div className="order-1 lg:order-2 w-full lg:w-2/5">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6 text-gray-800">
|
||||
Meet Our AI Characters
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 mb-6">
|
||||
Each character comes with specialized knowledge, voice and personality to make
|
||||
your interactions more engaging.
|
||||
</p>
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-100">
|
||||
<h3 className="font-semibold text-blue-800 mb-2">Personalized Experience</h3>
|
||||
<p className="text-blue-700">
|
||||
Choose the character that best fits your needs or mood. You can switch between
|
||||
characters anytime during your conversation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { VoiceSettings } from "./VoiceSettings"
|
||||
|
||||
export const CreateCharacterShowcase = () => {
|
||||
return (
|
||||
<section className="py-16 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="container mx-auto px-4 max-w-screen-lg">
|
||||
<div className="flex flex-col lg:flex-row-reverse items-center gap-12">
|
||||
{/* Text Content - On right for desktop, top for mobile */}
|
||||
<div className="order-1 lg:order-2 w-full lg:w-2/5">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6 text-gray-800">
|
||||
Create Your Own
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 mb-6">
|
||||
Create a character that is unique and personalized to your needs.
|
||||
</p>
|
||||
</div>
|
||||
{/* Character List - On left for desktop, bottom for mobile */}
|
||||
<div className="order-2 lg:order-1 w-full lg:w-3/5 sm:max-w-[400px] mx-auto">
|
||||
<div className="mx-auto px-2 rounded-lg">
|
||||
<VoiceSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
31
frontend-nextjs/app/components/LandingPage/DeviceImage.tsx
Normal file
31
frontend-nextjs/app/components/LandingPage/DeviceImage.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion"; // Add this import at the top
|
||||
import Image from "next/image";
|
||||
|
||||
export const DeviceImage = () => {
|
||||
return (
|
||||
<div className="relative h-[260px] w-full items-center -mt-8">
|
||||
<motion.div
|
||||
animate={{
|
||||
y: [-5, 5, -5],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
ease: "easeInOut",
|
||||
repeat: Infinity,
|
||||
}}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<Image
|
||||
src="/products/box43.png"
|
||||
alt="Elato Toy"
|
||||
fill
|
||||
className="object-contain object-center mr-6 rounded-3xl"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceImage;
|
||||
16
frontend-nextjs/app/components/LandingPage/Emoji.tsx
Normal file
16
frontend-nextjs/app/components/LandingPage/Emoji.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client"
|
||||
|
||||
import Twemoji from "react-twemoji";
|
||||
|
||||
export const Emoji = ({ emoji }: { emoji: string }) => {
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-center w-10 h-10">
|
||||
<Twemoji options={{
|
||||
className: "twemoji flex-shrink-0",
|
||||
style: { fontSize: `${10}px` }
|
||||
}}>
|
||||
{emoji}
|
||||
</Twemoji>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
frontend-nextjs/app/components/LandingPage/EndingSection.tsx
Normal file
71
frontend-nextjs/app/components/LandingPage/EndingSection.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import Illustration from "@/public/hero_section.svg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CalendarCheck, Star } from "lucide-react";
|
||||
import { FaDiscord } from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
businessDemoLink,
|
||||
discordInviteLink,
|
||||
githubPublicLink,
|
||||
} from "@/lib/data";
|
||||
import PreorderButton from "../PreorderButton";
|
||||
|
||||
export default function EndingSection() {
|
||||
return (
|
||||
<section className="py-8 md:py-24">
|
||||
<div className="max-w-4xl text-center mx-8 md:mx-auto gap-10 flex flex-col">
|
||||
<h1
|
||||
className="font-normal text-xl text-gray-600"
|
||||
>
|
||||
Bringing creative, personalized stories to toys, plushies and a whole lot
|
||||
more.
|
||||
</h1>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl mt-8 text-light">
|
||||
Get your <span className="font-silkscreen font-bold mt-1 px-2">Elato</span> today!
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 flex flex-col items-center justify-center gap-8">
|
||||
<div className="flex items-center justify-center gap-8 flex-wrap">
|
||||
<PreorderButton
|
||||
size="lg"
|
||||
buttonText="Buy Now"
|
||||
className="h-10"
|
||||
/>
|
||||
<Link href={businessDemoLink} passHref>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex flex-row bg-white items-center gap-2 font-medium text-base text-stone-800 leading-8 rounded-full border-2 border-stone-900"
|
||||
>
|
||||
<CalendarCheck size={20} />
|
||||
<span>Book a Demo</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-8 flex-wrap">
|
||||
<Link href={githubPublicLink} passHref>
|
||||
<Button
|
||||
variant="link"
|
||||
className="flex flex-row items-center gap-2 font-medium text-base text-stone-800 leading-8 rounded-full bg-transparent"
|
||||
>
|
||||
<Star size={20} className="text-2xl" />
|
||||
<span>Star us on GitHub</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={discordInviteLink} passHref>
|
||||
<Button
|
||||
variant="link"
|
||||
className="flex flex-row items-center gap-2 font-medium text-base text-stone-800 leading-8 rounded-full bg-transparent"
|
||||
>
|
||||
<FaDiscord className="text-2xl" />
|
||||
<span>Join our Discord</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
125
frontend-nextjs/app/components/LandingPage/FeaturesSection.tsx
Normal file
125
frontend-nextjs/app/components/LandingPage/FeaturesSection.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
AudioWaveform,
|
||||
FileSearch2,
|
||||
Sparkle,
|
||||
Heart,
|
||||
MessageSquareHeart,
|
||||
ChartScatter,
|
||||
Ratio,
|
||||
} from "lucide-react";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <MessageSquareHeart size={32} strokeWidth={1.7} />,
|
||||
progress: "Released",
|
||||
name: "Custom AI characters",
|
||||
description: "Choose from a variety of AI characters to interact with.",
|
||||
source: "images/feature2.png",
|
||||
},
|
||||
{
|
||||
icon: <ChartScatter size={32} strokeWidth={1.7} />,
|
||||
progress: "Released",
|
||||
name: "Emotion Intelligence",
|
||||
description:
|
||||
"Understand emotional trends and insights according to real time analytics.",
|
||||
source: "images/feature1.png",
|
||||
},
|
||||
|
||||
{
|
||||
icon: <Ratio size={32} strokeWidth={1.7} />,
|
||||
progress: "Released",
|
||||
name: "Tiny Size",
|
||||
description:
|
||||
"As small as the Apple Watch, you can stack the device on any surface.",
|
||||
source: "images/feature3.png",
|
||||
},
|
||||
{
|
||||
icon: <AudioWaveform size={32} strokeWidth={1.7} />,
|
||||
progress: "In development",
|
||||
name: "Custom Voice Clone",
|
||||
description: "Customize your AI's voice to match your preference.",
|
||||
source: "images/feature4.png",
|
||||
},
|
||||
{
|
||||
icon: <FileSearch2 size={32} strokeWidth={1.7} />,
|
||||
progress: "In development",
|
||||
name: "Talk to your documents",
|
||||
description:
|
||||
"Our RAG implementation allows you to talk to your images, videos and documents.",
|
||||
source: "images/feature5.png",
|
||||
},
|
||||
{
|
||||
icon: <Sparkle size={32} strokeWidth={1.7} />,
|
||||
progress: "In development",
|
||||
name: "Agentic AI",
|
||||
description:
|
||||
"Our agentic AI can help you with your daily tasks and reminders.",
|
||||
source: "images/feature6.png",
|
||||
},
|
||||
// More features...
|
||||
];
|
||||
|
||||
export default function FeaturesSection() {
|
||||
return (
|
||||
<section className="">
|
||||
<div className="max-w-6xl mx-auto py-8 px-4 md:py-12">
|
||||
<div className="max-w-3xl mx-auto text-center pb-12 md:pb-20">
|
||||
<h2 className="text-3xl font-medium tracking-tighter sm:text-5xl text-center ">
|
||||
Packed with features
|
||||
</h2>
|
||||
<p className="font-light mt-12 text-lg sm:text-xl leading-8 text-stone-800">
|
||||
We designed our AI platform to be a suitable companion
|
||||
that you can engage with anytime. Here are some of the
|
||||
features that we have implemented and are actively
|
||||
developing.
|
||||
</p>
|
||||
</div>
|
||||
<ul
|
||||
role="list"
|
||||
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{features.map((feature) => (
|
||||
<li key={feature.source} className="relative">
|
||||
<div className="bg-white p-[10px] rounded-[30px] shadow-custom_unfocus">
|
||||
<div className="cursor-pointer overflow-hidden rounded-[25px] relative group">
|
||||
<img
|
||||
alt=""
|
||||
src={feature.source}
|
||||
className="h-[200px] sm:h-full w-full object-cover transition-all duration-300 ease-in-out group-hover:scale-110 opacity-80"
|
||||
/>
|
||||
<div className="absolute top-0 left-0 text-stone-800 flex flex-col items-start">
|
||||
<div className="inline-block mx-[10px] mt-3 mb-4">
|
||||
{feature.icon}
|
||||
</div>
|
||||
<p className="text-normal md:text-xl font-bold px-3 py-2">
|
||||
{feature.name}
|
||||
</p>
|
||||
<p className="text-sm pl-3 pr-6 py-1">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 text-stone-800 flex flex-col items-start">
|
||||
<p className="text-xs font-light truncate ml-3 mb-4 px-2 py-[2px] border-[1px] border-stone-700 rounded-xl">
|
||||
{feature.progress}
|
||||
</p>
|
||||
</div>
|
||||
{/* <button
|
||||
type="button"
|
||||
className="absolute inset-0 focus:outline-none"
|
||||
>
|
||||
<span className="sr-only">
|
||||
View details for {feature.progress}
|
||||
</span>
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
159
frontend-nextjs/app/components/LandingPage/FrontPage.tsx
Normal file
159
frontend-nextjs/app/components/LandingPage/FrontPage.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { User } from "@supabase/supabase-js";
|
||||
import AnimatedText from "./AnimatedText";
|
||||
import { Emoji } from "./Emoji";
|
||||
/**
|
||||
* Headlines for the landing page
|
||||
*
|
||||
* "No more boring AI conversations with a chatbot",
|
||||
* "No more boring AI conversations on a screen",
|
||||
* "No more boring AI chatbots",
|
||||
* "Experience AI conversations that feel real",
|
||||
* "No more boring AI chatbot convos",
|
||||
* "A new home for your AI characters",
|
||||
* "Hear a joke whenever you need a pick-me-up",
|
||||
* "Your AI, better at debates than Congress",
|
||||
* "Think TMZ, but with punchlines.",
|
||||
* "Say goodbye to boring AI chatbot conversations",
|
||||
* "The AI Device with the best dad jokes",
|
||||
* "The AI wearable for toys"
|
||||
*
|
||||
*/
|
||||
|
||||
const HeaderText = "Elato";
|
||||
interface FrontPageProps {
|
||||
user?: User;
|
||||
allPersonalities: IPersonality[];
|
||||
}
|
||||
|
||||
// const getRandomPersonalities = (
|
||||
// personalities: IPersonality[],
|
||||
// count: number
|
||||
// ) => {
|
||||
// return [...personalities].sort(() => Math.random() - 0.5).slice(0, count);
|
||||
// };
|
||||
|
||||
// const getBlobShape = (index: number) => {
|
||||
// const shapes = [
|
||||
// "40% 60% 70% 30% / 40% 50% 60% 50%",
|
||||
// "60% 40% 30% 70% / 60% 30% 50% 40%",
|
||||
// "50% 60% 40% 50% / 40% 60% 50% 60%",
|
||||
// "30% 70% 60% 40% / 50% 60% 40% 50%",
|
||||
// "45% 55% 65% 35% / 55% 45% 55% 45%",
|
||||
// "40% 60% 70% 30% / 40% 50% 60% 50%",
|
||||
// "60% 40% 30% 70% / 60% 30% 50% 40%",
|
||||
// "50% 60% 40% 50% / 40% 60% 50% 60%",
|
||||
// "30% 70% 60% 40% / 50% 60% 40% 50%",
|
||||
// "45% 55% 65% 35% / 55% 45% 55% 45%",
|
||||
// "40% 60% 70% 30% / 40% 50% 60% 50%",
|
||||
// "60% 40% 30% 70% / 60% 30% 50% 40%",
|
||||
// "50% 60% 40% 50% / 40% 60% 50% 60%",
|
||||
// "30% 70% 60% 40% / 50% 60% 40% 50%",
|
||||
// "45% 55% 65% 35% / 55% 45% 55% 45%",
|
||||
// "40% 60% 70% 30% / 40% 50% 60% 50%",
|
||||
// "60% 40% 30% 70% / 60% 30% 50% 40%",
|
||||
// "50% 60% 40% 50% / 40% 60% 50% 60%",
|
||||
// "30% 70% 60% 40% / 50% 60% 40% 50%",
|
||||
// "45% 55% 65% 35% / 55% 45% 55% 45%",
|
||||
// ];
|
||||
// return shapes[index];
|
||||
// };
|
||||
|
||||
const FrontPage = ({ user }: FrontPageProps) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center max-w-screen-md px-4 md:px-6 mx-auto justify-center gap-8 mt-10 sm:mt-24">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* <Badge
|
||||
className="w-fit flex flex-row gap-2 shadow-md items-center text-sm"
|
||||
variant="secondary"
|
||||
>
|
||||
<Sparkle fill="currentColor" size={12} /> Now Available
|
||||
for Preorder
|
||||
</Badge> */}
|
||||
<h1 className="font-semibold tracking-tight text-5xl/tight sm:text-6xl/tight font-silkscreen">
|
||||
{HeaderText}
|
||||
</h1>
|
||||
{/* <h1 className="mb-4 text-5xl/tight sm:text-6xl/tight font-semibold leading-none tracking-tight dark:text-white">
|
||||
Real time{" "}
|
||||
<span className="underline underline-offset-3 decoration-8 decoration-black">
|
||||
AI conversations
|
||||
</span>{" "}
|
||||
in one compact, open-source device
|
||||
</h1> */}
|
||||
<h1 className="text-3xl md:text-5xl font-medium max-w-2xl mx-auto relative z-10 mb-6 leading-[1.2] md:leading-[1.3]">
|
||||
<span className="bg-clip-text font-normal text-transparent bg-gradient-to-r from-amber-500 to-pink-600 drop-shadow-sm">
|
||||
The <span className="font-extrabold"></span>screen-free storytelling toy <span className="font-extrabold">starring your little one</span></span>
|
||||
</h1>
|
||||
{/* <div className="relative">
|
||||
<h1 className="text-2xl md:text-3xl font-medium text-gray-900 leading-snug max-w-2xl mx-auto px-6 py-4 border-l-4 border-r-4 border-amber-400">
|
||||
{SubHeaderText}
|
||||
</h1>
|
||||
</div> */}
|
||||
{/* <div className="max-w-4xl text-center mx-8 md:mx-auto flex flex-col gap-4 -mt-4">
|
||||
<AnimatedText />
|
||||
</div> */}
|
||||
</div>
|
||||
{/* <div className="flex flex-col gap-4">
|
||||
<DeviceImage />
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrontPage;
|
||||
|
||||
{
|
||||
/* <TbArrowWaveRightUp
|
||||
size={64}
|
||||
strokeWidth={1.5}
|
||||
className="absolute bottom-10 left-16 transform -translate-x-1/2 -rotate-30 text-gray-600"
|
||||
/>
|
||||
{getRandomPersonalities(allPersonalities, NUM_AVATARS).map(
|
||||
(personality, index) => {
|
||||
// Calculate position on a circle
|
||||
const angle = (index * (2 * Math.PI)) / NUM_AVATARS; // Divide circle by number of avatars
|
||||
const radius = 80; // Radius of the circular arrangement
|
||||
const centerX = 20; // Center point X (from right)
|
||||
const centerY = -10; // Center point Y (from top)
|
||||
|
||||
// Calculate position using trigonometry
|
||||
const x = Math.cos(angle) * radius;
|
||||
const y = Math.sin(angle) * radius;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={personality.key}
|
||||
className="absolute"
|
||||
style={{
|
||||
right: `${centerX + x}px`,
|
||||
top: `${centerY + y}px`,
|
||||
// transform: `rotate(${-10 + index * 15}deg)`,
|
||||
zIndex: 10 - index,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
overflow: "hidden",
|
||||
width: "80px",
|
||||
height: "80px",
|
||||
// border: "2px solid white",
|
||||
// boxShadow:
|
||||
// "0 4px 6px -1px rgb(0 0 0 / 0.1)",
|
||||
position: "relative",
|
||||
}}
|
||||
className="shadow-md"
|
||||
>
|
||||
<Image
|
||||
src={getPersonalityImageSrc(
|
||||
personality.key
|
||||
)}
|
||||
alt={personality.key}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)} */
|
||||
}
|
||||
204
frontend-nextjs/app/components/LandingPage/InsightsDemo.tsx
Normal file
204
frontend-nextjs/app/components/LandingPage/InsightsDemo.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"use client";
|
||||
|
||||
import Usecase from "./Usecase";
|
||||
import TopCard from "../Insights/TopCard";
|
||||
import { MyResponsiveLine } from "../Insights/LineChart";
|
||||
import { MyResponsivePie } from "../Insights/PieChart";
|
||||
import { MyResponsiveBar } from "../Insights/BarChart";
|
||||
|
||||
const suggestions =
|
||||
"Based on the recent data, maintaining a neutral emotional state is predominant. Although there is a slight emergence of negative emotions like disgust, sadness, and anger, they are balanced by an equal presence of joy and surprise. Encourage positive interactions and activities to enhance the joy and neutral emotions further.";
|
||||
|
||||
const cardData = {
|
||||
main_emotion_1: { title: "Joy", value: 28.8, change: 28 },
|
||||
main_emotion_2: { title: "Suprise", value: 19.2, change: -12 },
|
||||
change_1: { title: "Anger", value: 12.1, change: 69 },
|
||||
change_2: { title: "Fear", value: 5.9, change: -52 },
|
||||
};
|
||||
|
||||
const barData = [
|
||||
{ emotion: "Surprise", Today: 0.28, Yesterday: 0.25 },
|
||||
{ emotion: "Joy", Today: 0.13, Yesterday: 0.22 },
|
||||
{ emotion: "Sadness", Today: 0.12, Yesterday: 0.18 },
|
||||
{ emotion: "Anger", Today: 0.12, Yesterday: 0.12 },
|
||||
{ emotion: "Neutral", Today: 0.12, Yesterday: 0.1 },
|
||||
{ emotion: "Fear", Today: 0.12, Yesterday: 0.1 },
|
||||
{ emotion: "Disgust", Today: 0.12, Yesterday: 0.09 },
|
||||
];
|
||||
|
||||
const lineData = [
|
||||
{
|
||||
id: "Negative",
|
||||
name: "Negative",
|
||||
data: [
|
||||
{ x: "2024-09-4", y: 0.23122093090355336 },
|
||||
{ x: "2024-09-5", y: 0.21122093090355345 },
|
||||
{ x: "2024-09-6", y: 0.13122093090355345 },
|
||||
{ x: "2024-09-7", y: 0.18122093090355345 },
|
||||
{ x: "2024-09-8", y: 0.43122093090355345 },
|
||||
{ x: "2024-09-9", y: 0.23122093090355345 },
|
||||
{ x: "2024-09-10", y: 0.13122093090355345 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "Neutral",
|
||||
name: "Neutral",
|
||||
data: [
|
||||
{ x: "2024-09-4", y: 0.2433541552767576 },
|
||||
{ x: "2024-09-5", y: 0.1433541552767577 },
|
||||
{ x: "2024-09-6", y: 0.11122093090355345 },
|
||||
{ x: "2024-09-7", y: 0.20122093090355345 },
|
||||
{ x: "2024-09-8", y: 0.23122093090355345 },
|
||||
{ x: "2024-09-9", y: 0.20122093090355345 },
|
||||
{ x: "2024-09-10", y: 0.23122093090355345 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "Positive",
|
||||
name: "Positive",
|
||||
data: [
|
||||
{ x: "2024-09-4", y: 0.3233541552767576 },
|
||||
{ x: "2024-09-5", y: 0.2433541552767577 },
|
||||
{ x: "2024-09-6", y: 0.23122093090355345 },
|
||||
{ x: "2024-09-7", y: 0.23122093090355345 },
|
||||
{ x: "2024-09-8", y: 0.13122093090355345 },
|
||||
{ x: "2024-09-9", y: 0.28122093090355345 },
|
||||
{ x: "2024-09-10", y: 0.33122093090355345 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const pieData = [
|
||||
{ id: "Positive", label: "Positive", value: 0.43 },
|
||||
{ id: "Neutral", label: "Neutral", value: 0.34 },
|
||||
{ id: "Negative", label: "Negative", value: 0.23 },
|
||||
];
|
||||
|
||||
export default function InsightsDemo() {
|
||||
const isEmpty = (data: any) => {
|
||||
return !data || data.length === 0;
|
||||
};
|
||||
|
||||
const placeholder = (
|
||||
<div className="my-4 bg-gray-50 text-center w-full h-full rounded-lg flex items-center justify-center">
|
||||
<p className="text-lg font-medium text-gray-500">
|
||||
Talk to a character to view your trends
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// console.log(cardData?.["change_1"]);
|
||||
return (
|
||||
<div className="">
|
||||
{/* <div className="text-3xl font-medium mb-8 text-gray-800">Insights</div>
|
||||
<div className="mt-2 mb-4 text-gray-800">{suggestions}</div> */}
|
||||
<div className="flex flex-col md:flex-row md:space-x-3">
|
||||
<div className="w-full">
|
||||
<h2 className="mb-4 text-lg font-bold text-gray-700">
|
||||
Daily emotion highlights
|
||||
</h2>
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex-grow">
|
||||
<TopCard
|
||||
title={
|
||||
cardData?.["main_emotion_1"]?.title ??
|
||||
"Emotion"
|
||||
}
|
||||
value={`${cardData?.["main_emotion_1"]?.value ?? "0"}%`}
|
||||
delta={
|
||||
cardData?.["main_emotion_1"]?.change ?? 0
|
||||
}
|
||||
filter={"days"}
|
||||
type="top"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<TopCard
|
||||
title={
|
||||
cardData?.["main_emotion_2"]?.title ??
|
||||
"Emotion"
|
||||
}
|
||||
value={`${cardData?.["main_emotion_2"]?.value ?? "0"}%`}
|
||||
delta={
|
||||
cardData?.["main_emotion_2"]?.change ?? 0
|
||||
}
|
||||
filter={"days"}
|
||||
type="top"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-2 md:mt-0">
|
||||
<h2 className="mb-4 text-lg font-bold text-gray-700">
|
||||
Significant emotion shifts
|
||||
</h2>
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex-grow">
|
||||
<TopCard
|
||||
title={
|
||||
cardData?.["change_1"]?.title ?? "Emotion"
|
||||
}
|
||||
value={`${cardData?.["change_1"]?.value ?? "0"}%`}
|
||||
delta={cardData?.["change_1"]?.change ?? 0}
|
||||
filter={"days"}
|
||||
type="shift"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<TopCard
|
||||
title={
|
||||
cardData?.["change_2"]?.title ?? "Emotion"
|
||||
}
|
||||
value={`${cardData?.["change_2"]?.value ?? "0"}%`}
|
||||
delta={cardData?.["change_2"]?.change ?? 0}
|
||||
filter={"days"}
|
||||
type="shift"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row md:space-x-8 mx-6-">
|
||||
<div className="w-full order-2 md:order-1 md:flex-grow">
|
||||
<h2 className="mt-6 text-lg font-bold text-gray-700">
|
||||
{/* Sentiment Over Time and Forecast */}
|
||||
Sentiment over time
|
||||
</h2>
|
||||
<div className="h-[300px] lg:h-96">
|
||||
{isEmpty(lineData) ? (
|
||||
placeholder
|
||||
) : (
|
||||
<MyResponsiveLine data={lineData} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full order-1 md:order-2 md:w-72 md:flex-shrink-0">
|
||||
<h2 className="mt-6 text-lg font-bold text-gray-700">
|
||||
Daily sentiment proportions
|
||||
</h2>
|
||||
<div className="h-[300px] lg:h-96">
|
||||
{isEmpty(pieData) ? (
|
||||
placeholder
|
||||
) : (
|
||||
<MyResponsivePie data={pieData} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="w-full">
|
||||
<h2 className="mt-6 text-lg font-bold text-gray-700">
|
||||
Emotions Breakdown
|
||||
</h2>
|
||||
<div className="h-[350px] lg:h-[450px]">
|
||||
{isEmpty(barData) ? (
|
||||
placeholder
|
||||
) : (
|
||||
<MyResponsiveBar data={barData} filter={"days"} />
|
||||
)}
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import IllustrationInsights from "@/public/insights_section.svg";
|
||||
|
||||
import InsightsDemo from "./InsightsDemo";
|
||||
|
||||
export default function InsightsDemoSection() {
|
||||
return (
|
||||
<section className="">
|
||||
<div className="relative w-full max-w-[1440px] mx-auto">
|
||||
<div
|
||||
className="absolute -top-[85px] pointer-events-none -z-10 opacity-90 w-full h-[650px] bg-cover bg-center bg-no-repeat blur-xl"
|
||||
style={{
|
||||
backgroundImage: `url(${IllustrationInsights.src})`,
|
||||
transform: "scaleX(-1)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="py-8 px-4 md:py-12">
|
||||
<div className="max-w-3xl mx-auto text-center pb-12 md:pb-20">
|
||||
<h2 className="text-3xl font-medium tracking-tighter sm:text-5xl text-center ">
|
||||
Get trends and insights
|
||||
</h2>
|
||||
<p className="font-light mt-12 text-lg sm:text-xl leading-8 text-stone-800">
|
||||
Our AI platform can analyse human-speech and emotion,
|
||||
and respond with empathy, offering supportive
|
||||
conversations and personalized learning assistance.
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-[1120px] mx-auto px-6 sm:px-20 py-6 sm:py-12 bg-white shadow-custom_focus rounded-[40px] md:border-[22px] border-[12px] border-zinc-800">
|
||||
<InsightsDemo />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import { getPersonalityImageSrc } from "@/lib/utils";
|
||||
|
||||
const LandingPagePersonalityCard = ({
|
||||
personality,
|
||||
}: {
|
||||
personality: IPersonality;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"p-0 rounded-full cursor-pointer sm:min-w-[180px] min-w-[120px] border-none shadow-none bg-transparent transition-all hover:scale-103 flex flex-col"
|
||||
)}
|
||||
// onClick={() => onPersonalityPicked(personality)}
|
||||
>
|
||||
<CardContent className="flex-shrink-0 p-0 sm:h-[180px] h-[120px]">
|
||||
<Image
|
||||
src={getPersonalityImageSrc(personality.key)}
|
||||
alt={personality.key}
|
||||
width={180}
|
||||
height={140}
|
||||
className="rounded-full w-full h-full object-cover"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPagePersonalityCard;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import Image from "next/image";
|
||||
|
||||
interface LandingPageSectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
imageSrc: string;
|
||||
isImageRight?: boolean;
|
||||
}
|
||||
|
||||
const IMAGE_SIZE = 600;
|
||||
|
||||
const LandingPageSection: React.FC<LandingPageSectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
imageSrc,
|
||||
isImageRight,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col max-w-screen-xl ${
|
||||
isImageRight ? "md:flex-row" : "md:flex-row-reverse"
|
||||
} gap-10 justify-between mx-auto mb-28 items-center`}
|
||||
>
|
||||
<Image
|
||||
src={imageSrc}
|
||||
className="rounded-2xl"
|
||||
alt="toy"
|
||||
width={IMAGE_SIZE}
|
||||
height={IMAGE_SIZE}
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
<div className={`flex flex-col gap-4`}>
|
||||
<h2 className="text-4xl font-normal">{title}</h2>
|
||||
<p className="text-xl font-normal text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPageSection;
|
||||
58
frontend-nextjs/app/components/LandingPage/Partners.tsx
Normal file
58
frontend-nextjs/app/components/LandingPage/Partners.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
const IMAGE_SIZE = 150;
|
||||
const Partners = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 w-full">
|
||||
<div className="flex flex-row w-full gap-10 items-center justify-center opacity-30">
|
||||
<Link
|
||||
href="https://antler.co"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerPolicy="origin"
|
||||
>
|
||||
<Image
|
||||
src={"/antler.png"}
|
||||
width={IMAGE_SIZE}
|
||||
height={IMAGE_SIZE}
|
||||
alt="antler"
|
||||
style={{
|
||||
WebkitFilter:
|
||||
"grayscale(100%)" /* Safari 6.0 - 9.0 */,
|
||||
filter: "grayscale(100%)",
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* <Separator
|
||||
className="border-2 text-gray-500 flex-grow"
|
||||
orientation="vertical"
|
||||
/> */}
|
||||
<Link
|
||||
href="https://microsoft.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerPolicy="origin"
|
||||
>
|
||||
<Image
|
||||
src={"/microsoft.png"}
|
||||
width={IMAGE_SIZE}
|
||||
height={IMAGE_SIZE}
|
||||
alt={"microsoft"}
|
||||
style={{
|
||||
WebkitFilter:
|
||||
"grayscale(100%)" /* Safari 6.0 - 9.0 */,
|
||||
filter: "grayscale(100%)",
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-xs hidden sm:block text-gray-500">
|
||||
Supported by our proud partners
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Partners;
|
||||
54
frontend-nextjs/app/components/LandingPage/Personalities.tsx
Normal file
54
frontend-nextjs/app/components/LandingPage/Personalities.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import LandingPagePersonalityCard from "./LandingPagePersonalityCard";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const Personalities = ({
|
||||
allPersonalities,
|
||||
}: {
|
||||
allPersonalities: IPersonality[];
|
||||
}) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
let scrollAmount = 0;
|
||||
const scrollStep = 1; // Adjust this value for speed
|
||||
const scrollInterval = 20; // Adjust this value for smoothness
|
||||
|
||||
const scroll = () => {
|
||||
if (
|
||||
scrollContainer.scrollWidth - scrollContainer.clientWidth ===
|
||||
scrollAmount
|
||||
) {
|
||||
scrollAmount = 0; // Reset scroll
|
||||
} else {
|
||||
scrollAmount += scrollStep;
|
||||
}
|
||||
scrollContainer.scrollLeft = scrollAmount;
|
||||
};
|
||||
|
||||
const intervalId = setInterval(scroll, scrollInterval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div ref={scrollRef} className="overflow-x-auto scrollbar-hide">
|
||||
<div className="flex flex-row items-center sm:gap-x-8 gap-x-4 justify-between whitespace-nowrap px-4 py-8">
|
||||
{allPersonalities.map((personality) => (
|
||||
<LandingPagePersonalityCard
|
||||
key={personality.personality_id}
|
||||
personality={personality}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Personalities;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export default function TikTokEmbed() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
// Clear any existing content first
|
||||
containerRef.current.innerHTML = '';
|
||||
|
||||
// Create a new blockquote element
|
||||
const blockquote = document.createElement('blockquote');
|
||||
blockquote.className = 'tiktok-embed';
|
||||
blockquote.setAttribute('cite', 'https://www.tiktok.com/@elatoai/video/7487016680925744406');
|
||||
blockquote.setAttribute('data-video-id', '7487016680925744406');
|
||||
blockquote.style.maxWidth = '605px';
|
||||
blockquote.style.minWidth = '325px';
|
||||
blockquote.style.margin = '0 auto';
|
||||
|
||||
// Create section element
|
||||
const section = document.createElement('section');
|
||||
blockquote.appendChild(section);
|
||||
|
||||
// Add the blockquote to the container
|
||||
containerRef.current.appendChild(blockquote);
|
||||
|
||||
// Check if script already exists and remove it
|
||||
const existingScript = document.querySelector('script[src="https://www.tiktok.com/embed.js"]');
|
||||
if (existingScript) {
|
||||
existingScript.remove();
|
||||
}
|
||||
|
||||
// Load the TikTok embed script
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.tiktok.com/embed.js';
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = '';
|
||||
}
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center" ref={containerRef}>
|
||||
{/* TikTok embed will be inserted here by useEffect */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
frontend-nextjs/app/components/LandingPage/ToyPicker.tsx
Normal file
147
frontend-nextjs/app/components/LandingPage/ToyPicker.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { voiceSampleUrl } from "@/lib/data";
|
||||
import { ArrowRight, PauseIcon, PlayIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ToyPickerProps {
|
||||
imageSize: number;
|
||||
buttonText: string;
|
||||
showHelpText: boolean;
|
||||
}
|
||||
|
||||
interface IDoctorPersonality {
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const getDoctorImageSrc = (key: string) => {
|
||||
return "/personality/" + key + ".jpeg";
|
||||
};
|
||||
|
||||
const DoctorPersonalities: IDoctorPersonality[] = [
|
||||
{
|
||||
key: "aggie_blood_test_pal",
|
||||
name: "Aggie Blood Test Pal",
|
||||
},
|
||||
{
|
||||
key: "santa_claus",
|
||||
name: "Santa Claus",
|
||||
},
|
||||
{
|
||||
key: "luna_epilepsy_pal",
|
||||
name: "Luna Epilepsy Pal",
|
||||
},
|
||||
];
|
||||
|
||||
const ToyPicker: React.FC<ToyPickerProps> = ({ imageSize, buttonText }) => {
|
||||
const [selectedPersonality, setSelectedPersonality] =
|
||||
useState<IDoctorPersonality>(DoctorPersonalities[0]);
|
||||
|
||||
const [playing, setPlaying] = useState<string | null>(null);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const playAudio = (personality: IDoctorPersonality) => {
|
||||
if (playing === personality.key && audioElement) {
|
||||
// Pause current audio
|
||||
audioElement.pause();
|
||||
setPlaying(null);
|
||||
setAudioElement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any currently playing audio
|
||||
if (audioElement) {
|
||||
audioElement.pause();
|
||||
}
|
||||
|
||||
const audio = new Audio(`${voiceSampleUrl}${personality.key}.wav`);
|
||||
audio.onended = () => {
|
||||
setPlaying(null);
|
||||
setAudioElement(null);
|
||||
};
|
||||
audio.play();
|
||||
setPlaying(personality.key);
|
||||
setAudioElement(audio);
|
||||
};
|
||||
|
||||
const onClickSelectedPersonality = (personality: IDoctorPersonality) => {
|
||||
setSelectedPersonality(personality);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col-reverse gap-8 pb-6">
|
||||
<div className="flex md:mt-7- md:flex-row flex-col gap-8 items-center justify-center">
|
||||
{DoctorPersonalities.map((personality) => {
|
||||
const chosen = selectedPersonality?.key === personality.key;
|
||||
return (
|
||||
<div
|
||||
key={personality.key}
|
||||
className="flex flex-col gap-2 bg-gray-50- rounded-2xl"
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-center max-w-[300px] max-h-[300px] rounded-br-2xl gap-2 mb-4 rounded-xl overflow-hidden cursor-pointer transition-colors duration-200 ease-in-out`}
|
||||
onClick={() =>
|
||||
onClickSelectedPersonality(personality)
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={getDoctorImageSrc(personality.key)}
|
||||
width={imageSize}
|
||||
height={imageSize}
|
||||
alt={personality.name}
|
||||
className="rounded-2xl transition-transform duration-300 ease-in-out scale-90 transform hover:scale-100 hover:-rotate-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 items-center text-center">
|
||||
<div className={`text-xl font-medium`}>
|
||||
{personality.name}
|
||||
</div>
|
||||
{chosen && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// play sound
|
||||
playAudio(personality);
|
||||
}}
|
||||
variant="outline"
|
||||
className="font-medium text-lg flex flex-row gap-2 items-center rounded-full border-stone-800 border-2"
|
||||
>
|
||||
<span>
|
||||
{playing
|
||||
? "Pause voice"
|
||||
: buttonText}
|
||||
</span>
|
||||
{playing ? (
|
||||
<PauseIcon
|
||||
size={18}
|
||||
fill="currentColor"
|
||||
/>
|
||||
) : (
|
||||
<PlayIcon
|
||||
size={18}
|
||||
fill="currentColor"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* {showHelpText && (
|
||||
<p className="flex self-center text-sm text-gray-600">
|
||||
(pick your favorite AI character to get started!)
|
||||
</p>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToyPicker;
|
||||
21
frontend-nextjs/app/components/LandingPage/Usecase.tsx
Normal file
21
frontend-nextjs/app/components/LandingPage/Usecase.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import Image, { StaticImageData } from "next/image";
|
||||
|
||||
interface usecaseProps {
|
||||
usecase: {
|
||||
image: StaticImageData;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Usecase({ usecase }: usecaseProps) {
|
||||
return (
|
||||
<div className="rounded w-[22rem] border border-transparent">
|
||||
<div className="flex items-center mb-4 ">
|
||||
<Image
|
||||
className="shrink-0 rounded-[30px]"
|
||||
src={usecase.image}
|
||||
alt="Usecase Image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend-nextjs/app/components/LandingPage/Usecases.tsx
Normal file
90
frontend-nextjs/app/components/LandingPage/Usecases.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import { r2Url } from "@/lib/data";
|
||||
|
||||
export default function Usecases() {
|
||||
const usecases = [
|
||||
{
|
||||
title: "For Interactive Storytelling",
|
||||
description: "Transform ordinary toys into storytelling companions that respond to your child's imagination.",
|
||||
features: [
|
||||
"Voice-activated responses",
|
||||
"Customizable personalities",
|
||||
"Age-appropriate content",
|
||||
"Multiple story modes"
|
||||
],
|
||||
videoSrc: `${r2Url}/peterrabbit.mp4`,
|
||||
poster: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/peterrabbit.png`
|
||||
},
|
||||
{
|
||||
title: "For Educational Learning",
|
||||
description: "Turn everyday objects into educational tools that make learning fun and interactive.",
|
||||
features: [],
|
||||
videoSrc: `${r2Url}/paddington.mp4`,
|
||||
poster: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/paddington.png`
|
||||
},
|
||||
{
|
||||
title: "For Creative Play",
|
||||
description: "Enhance playtime with responsive objects that encourage creativity and imaginative play.",
|
||||
features: [
|
||||
"Character role-playing",
|
||||
"Collaborative play options",
|
||||
"Parent monitoring features"
|
||||
],
|
||||
videoSrc: `${r2Url}/plant.mp4`,
|
||||
poster: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/plant.png`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-stone-50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="space-y-16 md:space-y-24">
|
||||
{usecases.map((usecase, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex flex-col ${index % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'} gap-16 items-center`}
|
||||
>
|
||||
{/* Video Column */}
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="relative aspect-video rounded-2xl overflow-hidden shadow-lg">
|
||||
<video
|
||||
className="w-full h-full object-cover"
|
||||
controls
|
||||
poster={usecase.poster}
|
||||
>
|
||||
<source src={usecase.videoSrc} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Column */}
|
||||
<div className="w-full md:w-1/2">
|
||||
<h3 className="text-3xl md:text-4xl font-semibold text-stone-800 mb-4 font-silkscreen">
|
||||
{usecase.title}
|
||||
</h3>
|
||||
<p className="text-xl text-gray-600 mb-6">
|
||||
{usecase.description}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{/* <h4 className="text-lg font-medium text-stone-800">Key Features:</h4> */}
|
||||
<ul className="space-y-2">
|
||||
{usecase.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-start">
|
||||
<svg className="w-5 h-5 text-emerald-500 mr-2 mt-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-400 text-xl">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
96
frontend-nextjs/app/components/LandingPage/VideoPlayer.tsx
Normal file
96
frontend-nextjs/app/components/LandingPage/VideoPlayer.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { IoIosShareAlt } from 'react-icons/io';
|
||||
import { Wand, Heart, MessageCircle, Bookmark } from 'lucide-react';
|
||||
interface VideoPlayerProps {
|
||||
sources: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 28;
|
||||
|
||||
export default function VideoPlayer({ sources, className = "" }: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
if (!videoElement) return;
|
||||
|
||||
// Handle video ended event to switch to the next video
|
||||
const handleVideoEnded = () => {
|
||||
// Fade out
|
||||
if (videoElement) {
|
||||
videoElement.classList.add('opacity-0');
|
||||
|
||||
// Wait for fade out, then change source and fade in
|
||||
setTimeout(() => {
|
||||
setCurrentSourceIndex((prevIndex) => (prevIndex + 1) % sources.length);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
videoElement.addEventListener('ended', handleVideoEnded);
|
||||
|
||||
// Clean up event listener
|
||||
return () => {
|
||||
videoElement.removeEventListener('ended', handleVideoEnded);
|
||||
};
|
||||
}, [sources.length]);
|
||||
|
||||
// When the current source index changes, load and play the new video
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
if (!videoElement) return;
|
||||
|
||||
videoElement.load();
|
||||
videoElement.play().catch(error => {
|
||||
console.error("Error playing video:", error);
|
||||
});
|
||||
|
||||
// Fade in after source change
|
||||
setTimeout(() => {
|
||||
videoElement.classList.remove('opacity-0');
|
||||
}, 50);
|
||||
|
||||
}, [currentSourceIndex]);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-[9/16] max-h-[85vh] w-full">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={`w-full h-full object-cover rounded-xl shadow-lg transition-opacity duration-300 ${className}`}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
>
|
||||
<source src={sources[currentSourceIndex]} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{/* TikTok-style icons */}
|
||||
<div className="absolute top-2 left-2 flex flex-col gap-4">
|
||||
<button className="bg-transparent bg-opacity-50 rounded-full p-2 transition-transform hover:scale-110">
|
||||
<Wand size={ICON_SIZE+4} color="white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2 flex flex-col gap-4">
|
||||
<button className="bg-transparent bg-opacity-50 rounded-full p-2 transition-transform hover:scale-110">
|
||||
<Heart size={ICON_SIZE} color="rgb(239, 68, 68" fill="rgb(239, 68, 68)" />
|
||||
</button>
|
||||
|
||||
<button className="bg-transparent bg-opacity-50 rounded-full p-2 transition-transform hover:scale-110">
|
||||
<MessageCircle size={ICON_SIZE} color="white" />
|
||||
</button>
|
||||
|
||||
<button className="bg-transparent bg-opacity-50 rounded-full p-2 transition-transform hover:scale-110">
|
||||
<Bookmark size={ICON_SIZE} color="rgb(250, 204, 21)" fill="rgb(250, 204, 21)" />
|
||||
</button>
|
||||
|
||||
<button className="bg-transparent bg-opacity-50 rounded-full p-2 transition-transform hover:scale-110">
|
||||
<IoIosShareAlt size={ICON_SIZE} color="white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
frontend-nextjs/app/components/LandingPage/VoiceSettings.tsx
Normal file
119
frontend-nextjs/app/components/LandingPage/VoiceSettings.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
|
||||
import { Volume2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { emotionOptions, r2UrlAudio, voices } from "@/lib/data";
|
||||
import EmojiComponent from "../CreateCharacter/EmojiComponent";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export const VoiceSettings = () => {
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
|
||||
const [previewingVoice, setPreviewingVoice] = useState<string | null>(null);
|
||||
|
||||
const previewVoice = (voiceId: string) => {
|
||||
// If the same voice is clicked again while playing, pause it
|
||||
if (previewingVoice === voiceId && audioElement) {
|
||||
audioElement.pause();
|
||||
audioElement.currentTime = 0;
|
||||
setPreviewingVoice(null);
|
||||
return;
|
||||
}
|
||||
// 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
|
||||
};
|
||||
|
||||
return (<div className="flex flex-col gap-4 w-full">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="voice">Pick a voice</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{voices.map((voice) => (
|
||||
<div
|
||||
key={voice.id}
|
||||
className={`
|
||||
rounded-lg border p-3 transition-all relative
|
||||
${"" === voice.id
|
||||
? 'border-2 border-blue-500 shadow-sm ' + voice.color
|
||||
: 'border-gray-200 hover:border-gray-300 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
onClick={() => {
|
||||
previewVoice(voice.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-3">
|
||||
<div className="text-2xl mt-0.5">
|
||||
<EmojiComponent emoji={voice.emoji} />
|
||||
</div>
|
||||
<div className="flex flex-col text-center sm:text-left">
|
||||
<span className="font-medium">{voice.name}</span>
|
||||
<span className="text-xs text-gray-600">{voice.description}</span>
|
||||
</div>
|
||||
{previewingVoice === voice.id && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="animate-pulse text-blue-500">
|
||||
<Volume2 size={20} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label className="block mb-2">Emotional Tone</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{emotionOptions.map((emotion) => (
|
||||
<div
|
||||
key={emotion.value}
|
||||
className={`
|
||||
rounded-lg border p-3 cursor-pointer transition-all
|
||||
${"" === emotion.value
|
||||
? 'border-2 border-blue-500 shadow-sm ' + emotion.color
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<EmojiComponent emoji={emotion.icon} />
|
||||
<span className="text-sm font-medium">{emotion.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
127
frontend-nextjs/app/components/Nav/LeftNavbarButtons.tsx
Normal file
127
frontend-nextjs/app/components/Nav/LeftNavbarButtons.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Hospital, Sparkle, ChevronDown, Dog, Bird, Hop, Wand, Plus, Blocks, Gamepad2 } from "lucide-react";
|
||||
import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const ICON_SIZE = 22;
|
||||
|
||||
interface LeftNavbarButtonsProps {
|
||||
user: IUser | null;
|
||||
}
|
||||
|
||||
export default function LeftNavbarButtons({ user }: LeftNavbarButtonsProps) {
|
||||
const isDoctor = user?.user_info.user_type === "doctor";
|
||||
const pathname = usePathname();
|
||||
|
||||
let firstWordOfHospital = '';
|
||||
if (isDoctor) {
|
||||
const hospitalName = (user?.user_info.user_metadata as IDoctorMetadata).hospital_name;
|
||||
firstWordOfHospital = hospitalName ? hospitalName.split(' ')[0] : '';
|
||||
}
|
||||
|
||||
const isRoot = pathname === "/";
|
||||
const isHome = pathname.includes("/home");
|
||||
|
||||
const shouldShowHospital = isDoctor && firstWordOfHospital.length && isHome;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-4 sm:gap-10 items-center">
|
||||
<a className="flex flex-row gap-3 items-center" href="/">
|
||||
<Wand size={ICON_SIZE} />
|
||||
<p
|
||||
className={`flex items-center font-silkscreen text-xl text-stone-800 dark:text-stone-100 relative`}
|
||||
>
|
||||
{shouldShowHospital ? (
|
||||
<>
|
||||
<span>Elato | <span className="text-cyan-700">{firstWordOfHospital}</span></span>
|
||||
<span className="absolute -top-3 -right-3 text-cyan-700"><Plus size={12} strokeWidth={3} /></span>
|
||||
</>
|
||||
) : (
|
||||
<span>Elato</span>
|
||||
)}
|
||||
</p>
|
||||
</a>
|
||||
{/* {!isHome && (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Remove focus from any active element when dropdown closes
|
||||
document.activeElement instanceof HTMLElement &&
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex flex-row gap-2 items-center rounded-full hover:bg-stone-100 dark:hover:bg-stone-800"
|
||||
>
|
||||
<span className="font-medium text-md hidden sm:flex">
|
||||
Use cases
|
||||
</span>
|
||||
<ChevronDown size={18} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-56 p-2 sm:mt-2 rounded-lg"
|
||||
side="bottom"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>Use Cases</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href="/"
|
||||
className={`flex flex-row gap-2 w-full items-center justify-between ${
|
||||
isRoot
|
||||
? "bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Gamepad2 size={ICON_SIZE - 6} />
|
||||
<span>Elato for Hobbyists</span>
|
||||
</div>
|
||||
{isRoot && (
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
)}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href="/kids"
|
||||
className={`flex flex-row gap-2 w-full items-center justify-between ${
|
||||
!isRoot
|
||||
? "bg-amber-50 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Blocks
|
||||
size={ICON_SIZE - 6}
|
||||
// fill="currentColor"
|
||||
/>
|
||||
<span>Elato for Kids</span>
|
||||
</div>
|
||||
{!isRoot && (
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
)}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend-nextjs/app/components/Nav/MobileNav.tsx
Normal file
57
frontend-nextjs/app/components/Nav/MobileNav.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export function MobileNav({
|
||||
items,
|
||||
}: {
|
||||
items: SidebarNavItem[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const primaryItem = (item: SidebarNavItem) => {
|
||||
return <a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="relative flex flex-col items-center justify-center p-2 text-sm bg-yellow-500 hover:bg-yellow-400 rounded-full -mt-4 shadow-xl hover:shadow-2xl w-16 h-16"
|
||||
>
|
||||
{pathname === item.href && (
|
||||
<div className="absolute inset-0 rounded-full bg-white opacity-20 -z-[1]" />
|
||||
)}
|
||||
<div
|
||||
className={`text-white text-xl`}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-background border-t md:hidden">
|
||||
<div className="flex justify-around items-center h-14">
|
||||
{items.map((item) => (
|
||||
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="relative flex flex-col items-center p-2 text-sm"
|
||||
>
|
||||
{pathname === item.href && (
|
||||
<div className="absolute top-5 left-1/2 w-[35px] h-[45px] -translate-x-1/2 -translate-y-1/2 -rotate-[70deg] rounded-[100%_80%_100%_80%] bg-yellow-100 -z-[1]" />
|
||||
)}
|
||||
<div
|
||||
className={`${pathname === item.href ? "text-yellow-600" : "text-gray-500"}`}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span
|
||||
className={`mt-1 text-xs ${pathname === item.href ? "text-yellow-600 font-medium" : "text-gray-500"}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
58
frontend-nextjs/app/components/Nav/Navbar.tsx
Normal file
58
frontend-nextjs/app/components/Nav/Navbar.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import NavbarButtons from "./NavbarButtons";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import { usePathname } from "next/navigation";
|
||||
import LeftNavbarButtons from "./LeftNavbarButtons";
|
||||
|
||||
export function Navbar({
|
||||
user,
|
||||
stars,
|
||||
}: {
|
||||
user: IUser | null;
|
||||
stars: number | null;
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [lastScrollY, setLastScrollY] = useState(0);
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const isHome = usePathname().includes("/home");
|
||||
const isProduct = usePathname().includes("/products");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && isMobile) {
|
||||
const handleScroll = () => {
|
||||
const currentScrollY = window.scrollY;
|
||||
setIsVisible(
|
||||
currentScrollY <= 0 || currentScrollY < lastScrollY
|
||||
);
|
||||
setLastScrollY(currentScrollY);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}
|
||||
}, [lastScrollY, isMobile]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`backdrop-blur-[3px] flex-none flex items-center sticky top-0 z-50 transition-transform duration-300 h-[80px] ${
|
||||
isVisible ? "translate-y-0" : "-translate-y-full"
|
||||
} ${!isHome ? "h-[80px]" : "h-[60px]"}`}
|
||||
>
|
||||
{!isHome && (
|
||||
<div className="fixed h-8 top-0 flex items-center justify-center w-full bg-yellow-100 dark:bg-yellow-900/30 px-4 py-2 text-center font-medium text-yellow-800 dark:text-yellow-200 z-40 gap-2 text-sm">
|
||||
⭐️ Starmoon has a new home!
|
||||
</div>
|
||||
)}
|
||||
<nav
|
||||
className={`mx-auto w-full max-w-[1440px] px-4 flex items-center justify-between ${
|
||||
!isHome ? "pt-8" : ""
|
||||
}`}
|
||||
>
|
||||
<LeftNavbarButtons user={user} />
|
||||
<NavbarButtons user={user} stars={stars} isHome={isHome} />
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend-nextjs/app/components/Nav/NavbarButtons.tsx
Normal file
91
frontend-nextjs/app/components/Nav/NavbarButtons.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { User } from "@supabase/supabase-js";
|
||||
import Link from "next/link";
|
||||
import { businessDemoLink, githubPublicLink } from "@/lib/data";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import PreorderButton from "../PreorderButton";
|
||||
import { NavbarDropdownMenu } from "./NavbarDropdownMenu";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import PremiumBadge from "../PremiumBadge";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import { usePathname } from "next/navigation";
|
||||
import GetInTouchButton from "../GetInTouch";
|
||||
import { CalendarCheck } from "lucide-react";
|
||||
|
||||
interface NavbarButtonsProps {
|
||||
user: IUser | null;
|
||||
stars: number | null;
|
||||
isHome: boolean;
|
||||
}
|
||||
|
||||
const NavbarButtons: React.FC<NavbarButtonsProps> = ({
|
||||
user,
|
||||
stars,
|
||||
isHome,
|
||||
}) => {
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const isHealthcare = usePathname().includes("/healthcare");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-row sm:gap-2 ${
|
||||
isHome ? "gap-2" : ""
|
||||
} items-center font-bold text-sm `}
|
||||
>
|
||||
{isHome && user && (
|
||||
<div className="mr-2">
|
||||
<PremiumBadge currentUserId={user.user_id} />
|
||||
</div>
|
||||
)}
|
||||
{/* {isHealthcare ? (
|
||||
<GetInTouchButton size="sm" iconOnMobile={isMobile} />
|
||||
) : (
|
||||
<PreorderButton size="sm" buttonText="Buy" className="font-normal" iconOnMobile />
|
||||
)} */}
|
||||
|
||||
{!isHome && (
|
||||
<Link
|
||||
href={githubPublicLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Visit our GitHub"
|
||||
className="ml-4"
|
||||
// className="bg-nav-bar rounded-full px-3"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isMobile ? "ghost" : "outline"}
|
||||
className="flex bg-nav-bar border-0 sm:mr-2 sm:border flex-row gap-2 items-center rounded-full"
|
||||
>
|
||||
<FaGithub className="text-xl" />
|
||||
<p className="hidden sm:flex font-normal">GitHub</p>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="hidden sm:flex"
|
||||
/>
|
||||
<p className="hidden sm:flex text-xs font-normal">
|
||||
{stars?.toLocaleString() ?? 498}
|
||||
</p>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{!isHome && !isHealthcare && !isMobile && (
|
||||
<Link href={businessDemoLink} passHref tabIndex={-1}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="flex flex-row gap-2 items-center rounded-full bg-nav-bar focus:shadow-none focus-visible:shadow-none"
|
||||
>
|
||||
<CalendarCheck size={20} />
|
||||
<span className="hidden sm:flex font-normal">
|
||||
Business demo
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<NavbarDropdownMenu user={user} stars={stars} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavbarButtons;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue