init commit

Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
This commit is contained in:
2025-08-20 18:56:07 +09:00
commit 2383894664
46 changed files with 7834 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/.idea
sdkconfig.old
/managed_components

6
CMakeLists.txt Normal file
View File

@@ -0,0 +1,6 @@
# The following five lines of boilerplate have to be in your project's
# CMakeLists in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(odroid-remote-http)

85
README.md Normal file
View File

@@ -0,0 +1,85 @@
# ODROID Remote HTTP Power Manager (OWPM)
A web-based remote power and monitoring tool for ODROID Single Board Computers (SBCs), powered by an ESP32. This project provides a comprehensive web interface to control power, monitor real-time metrics, and access the serial console of your ODROID from any web browser on your local network.
## Features
- **Web-Based UI**: Modern, responsive interface built with Bootstrap and vanilla JavaScript.
- **Power Control**: Independently toggle Main (e.g., 12V) and USB (5V) power supplies.
- **Power Actions**: Remotely trigger Reset and Power On/Off actions.
- **Real-time Metrics**: Monitor voltage, current, and power consumption with a live-updating graph.
- **Interactive Serial Terminal**: Access your ODROID's serial console directly from the web UI via WebSockets.
- **Wi-Fi Management**:
- Scan and connect to Wi-Fi networks (STA mode).
- Enable Access Point mode (AP+STA) to connect directly to the device.
- Configure static IP settings.
- **System Info**: View device uptime and connection status.
- **Customizable Theme**: Switch between light and dark modes, with the preference saved locally.
## Prerequisites
Before you begin, ensure you have the following installed and configured on your system:
- **[ESP-IDF (Espressif IoT Development Framework)](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/index.html)**: This project is developed and tested with ESP-IDF v5.x.
- **[Node.js and npm](https://nodejs.org/)**: Required to build the web application. Node.js LTS version (e.g., 18.x or later) is recommended.
## How to Build and Flash
1. **Clone the repository:**
```bash
git clone <your-repository-url>
cd odroid-remote-http
```
2. **Build the Web Application:**
The web interface needs to be compiled before building the main firmware.
```bash
cd page
npm install
npm run build
cd ..
```
3. **Set up the ESP-IDF environment:**
Open a terminal and source the ESP-IDF export script. The path may vary depending on your installation location.
```bash
. $HOME/esp/esp-idf/export.sh
```
4. **Set the target chip:**
Specify your ESP32 variant (e.g., `esp32`, `esp32s3`).
```bash
idf.py set-target esp32
```
5. **(Optional) Configure the project:**
You can configure project-specific settings, such as default Wi-Fi credentials, by running `menuconfig`.
```bash
idf.py menuconfig
```
6. **Build the project:**
This command compiles the application, bootloader, partition table, and embeds the compiled web page data.
```bash
idf.py build
```
7. **Flash the firmware:**
Connect your ESP32 board to your computer and replace `/dev/ttyUSB0` with your device's serial port.
```bash
idf.py -p /dev/ttyUSB0 flash
```
8. **Monitor the output:**
To view the serial logs from the device, use the `monitor` command. This is useful for finding the device's IP address after it connects to your Wi-Fi.
```bash
idf.py -p /dev/ttyUSB0 monitor
```
To exit the monitor, press `Ctrl+]`.
## Usage
1. After flashing, the ESP32 will either connect to the pre-configured Wi-Fi network or start an Access Point (AP).
2. Check the serial monitor logs to find the IP address assigned to the device in STA mode, or the default AP address (usually `192.168.4.1`).
3. Open a web browser and navigate to the device's IP address.
4. You should now see the ODROID Remote control panel.

59
dependencies.lock Normal file
View File

@@ -0,0 +1,59 @@
dependencies:
espressif/cmake_utilities:
component_hash: 05165f30922b422b4b90c08845e6d449329b97370fbd06309803d8cb539d79e3
dependencies:
- name: idf
require: private
version: '>=4.1'
source:
registry_url: https://components.espressif.com
type: service
version: 1.1.1
espressif/led_indicator:
component_hash: 5b2531835a989825c0dc94465e3481086473e086dca109b99bea5605d8e70396
dependencies:
- name: espressif/cmake_utilities
registry_url: https://components.espressif.com
require: private
version: '*'
- name: idf
require: private
version: '>=4.0'
- name: espressif/led_strip
registry_url: https://components.espressif.com
require: public
version: 2.5.5
source:
registry_url: https://components.espressif.com/
type: service
version: 1.1.1
espressif/led_strip:
component_hash: 28c6509a727ef74925b372ed404772aeedf11cce10b78c3f69b3c66799095e2d
dependencies:
- name: idf
require: private
version: '>=4.4'
source:
registry_url: https://components.espressif.com
type: service
version: 2.5.5
idf:
source:
type: idf
version: 5.4.0
joltwallet/littlefs:
component_hash: 8e12955f47e27e6070b76715a96d6c75fc2b44f069e8c33679332d9bdd3120c4
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.20.1
direct_dependencies:
- espressif/led_indicator
- joltwallet/littlefs
manifest_hash: 445ef18c991ae952f2f16ffe06a905b6d1414a42286212d7b2459fa32945a09c
target: esp32c3
version: 2.0.0

209
docs/API.md Normal file
View File

@@ -0,0 +1,209 @@
# ODROID Remote API Documentation
This document outlines the HTTP REST and WebSocket APIs for communication between the web interface and the ESP32 device.
---
## WebSocket API
The WebSocket API provides a full-duplex communication channel for real-time data, such as sensor metrics and the interactive serial console.
**Endpoint**: `/ws`
### Server-to-Client Messages
The server pushes messages to the client, which can be either JSON objects or raw binary data. JSON messages always contain a `type` field to identify the payload.
#### JSON Messages
| Type | Description | Payload Example |
|---------------|-----------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------|
| `sensor_data` | Pushed periodically (e.g., every second) with the latest power metrics. | `{"type":"sensor_data", "voltage":12.01, "current":1.52, "power":18.25, "uptime_sec":3600, "timestamp": 1672531200}` |
| `wifi_status` | Pushed periodically or on change to update the current Wi-Fi connection status. | `{"type":"wifi_status", "connected":true, "ssid":"MyHome_WiFi", "rssi":-65}` |
**Field Descriptions:**
- `sensor_data`:
- `voltage` (float): The measured voltage in Volts (V).
- `current` (float): The measured current in Amperes (A).
- `power` (float): The calculated power in Watts (W).
- `uptime_sec` (integer): The system uptime in seconds.
- `timestamp` (integer): A Unix timestamp (seconds) of when the measurement was taken.
- `wifi_status`:
- `connected` (boolean): `true` if connected to a Wi-Fi network, `false` otherwise.
- `ssid` (string): The SSID of the connected network. Null if not connected.
- `rssi` (integer): The Received Signal Strength Indicator in dBm. Null if not connected.
#### Raw Binary Data
- **Description**: Raw binary data from the ODROID's serial (UART) port is forwarded directly to the client. This is used to display the terminal output.
- **Payload**: `(binary data)`
### Client-to-Server Messages
The client primarily sends raw binary data, which is interpreted as terminal input.
- **Description**: Raw binary data representing user input from the web terminal. This data is forwarded directly to the ODROID's serial (UART) port.
- **Payload**: `(binary data)`
---
## HTTP REST API
The REST API is used for configuration and to trigger specific actions. All request and response bodies are in `application/json` format.
### Endpoint: `/api/control`
Manages power relays and system actions.
#### `GET /api/control`
Retrieves the current status of the power relays.
- **Success Response (200 OK)**
```json
{
"load_12v_on": true,
"load_5v_on": false
}
```
- `load_12v_on` (boolean): The state of the main 12V power relay.
- `load_5v_on` (boolean): The state of the 5V USB power relay.
#### `POST /api/control`
Sets the state of power relays or triggers a power action. You can send one or more commands in a single request.
- **Request Body Examples**:
- To turn the main power on:
```json
{ "load_12v_on": true }
```
- To trigger a system reset:
```json
{ "reset_trigger": true }
```
- To toggle the power button:
```json
{ "power_trigger": true }
```
- **Request Fields**:
- `load_12v_on` (boolean, optional): Set the state of the 12V relay.
- `load_5v_on` (boolean, optional): Set the state of the 5V relay.
- `reset_trigger` (boolean, optional): If `true`, momentarily triggers the reset button.
- `power_trigger` (boolean, optional): If `true`, momentarily triggers the power button.
- **Success Response**: `204 No Content`
- **Error Response**: `400 Bad Request` if the request body is invalid.
---
### Endpoint: `/api/wifi`
Manages all Wi-Fi and network-related configurations.
#### `GET /api/wifi`
Retrieves the complete current network configuration.
- **Success Response (200 OK)**
```json
{
"connected": true,
"ssid": "MyHome_WiFi",
"mode": "apsta",
"net_type": "static",
"ip": {
"ip": "192.168.1.100",
"gateway": "192.168.1.1",
"subnet": "255.255.255.0",
"dns1": "8.8.8.8",
"dns2": "8.8.4.4"
}
}
```
- **Response Fields**:
- `connected` (boolean): Current Wi-Fi connection state.
- `ssid` (string): The SSID of the connected network.
- `mode` (string): The current Wi-Fi mode (`"sta"` or `"apsta"`).
- `net_type` (string): The network type (`"dhcp"` or `"static"`).
- `ip` (object): Contains IP details. Present even if using DHCP (may show last-leased IP).
#### `POST /api/wifi`
This is a multi-purpose endpoint. The server determines the action based on the fields provided in the request body.
- **Action: Connect to a Wi-Fi Network**
- **Request Body**:
```json
{
"ssid": "MyHome_WiFi",
"password": "my_secret_password"
}
```
- **Success Response (200 OK)**:
```json
{ "status": "connection_initiated" }
```
- **Action: Configure Network Type (DHCP/Static)**
- **Request Body (for DHCP)**:
```json
{ "net_type": "dhcp" }
```
- **Request Body (for Static IP)**:
```json
{
"net_type": "static",
"ip": "192.168.1.100",
"gateway": "192.168.1.1",
"subnet": "255.255.255.0",
"dns1": "8.8.8.8",
"dns2": "8.8.4.4"
}
```
- **Success Response**: `204 No Content`
- **Action: Configure Wi-Fi Mode (STA/APSTA)**
- **Request Body (for STA mode)**:
```json
{ "mode": "sta" }
```
- **Request Body (for AP+STA mode)**:
```json
{
"mode": "apsta",
"ap_ssid": "ODROID-Remote-AP",
"ap_password": "hardkernel"
}
```
*Note: `ap_password` is optional. If omitted, the AP will be open.*
- **Success Response**: `204 No Content`
---
### Endpoint: `/api/wifi/scan`
Scans for available Wi-Fi networks.
#### `GET /api/wifi/scan`
- **Success Response (200 OK)**: Returns a JSON array of found networks.
```json
[
{
"ssid": "MyHome_WiFi",
"rssi": -55,
"authmode": "WPA2_PSK"
},
{
"ssid": "GuestNetwork",
"rssi": -78,
"authmode": "OPEN"
}
]
```
- **Response Fields**:
- `ssid` (string): The network's Service Set Identifier.
- `rssi` (integer): Signal strength in dBm.
- `authmode` (string): The authentication mode (e.g., `"OPEN"`, `"WPA_PSK"`, `"WPA2_PSK"`).

31
main/CMakeLists.txt Normal file
View File

@@ -0,0 +1,31 @@
# Define the web application source directory and the final output file
set(WEB_APP_SOURCE_DIR ${CMAKE_SOURCE_DIR}/page)
set(GZ_OUTPUT_FILE ${WEB_APP_SOURCE_DIR}/dist/index.html.gz)
# Register the component. Now, CMake knows how GZ_OUTPUT_FILE is generated
# and can correctly handle the dependency for embedding.
idf_component_register(SRC_DIRS "app" "nconfig" "wifi" "indicator" "system" "service" "ina226"
INCLUDE_DIRS "include"
EMBED_FILES ${GZ_OUTPUT_FILE}
)
# Define a custom command to build the web app.
# This command explicitly tells CMake that it produces the GZ_OUTPUT_FILE.
add_custom_command(
OUTPUT ${GZ_OUTPUT_FILE}
COMMAND npm install
COMMAND npm run build
WORKING_DIRECTORY ${WEB_APP_SOURCE_DIR}
# Re-run the build if package.json or vite.config.js changes
DEPENDS ${WEB_APP_SOURCE_DIR}/index.html ${WEB_APP_SOURCE_DIR}/src/main.js ${WEB_APP_SOURCE_DIR}/src/style.css
COMMENT "Building Node.js project to produce ${GZ_OUTPUT_FILE}"
VERBATIM
)
# Create a target that depends on the output file. When this target is built,
# it ensures the custom command above is executed first.
add_custom_target(build_web_app ALL
DEPENDS ${GZ_OUTPUT_FILE}
)

82
main/Kconfig Normal file
View File

@@ -0,0 +1,82 @@
menu "ODROID-MONITOR"
menu "GPIO"
orsource "$IDF_PATH/examples/common_components/env_caps/$IDF_TARGET/Kconfig.env_caps"
config GPIO_INA226_SCL
int "INA226 SCL GPIO Num"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 0
help
GPIO number for I2C Master data line.
config GPIO_INA226_SDA
int "INA226 SDA GPIO Num"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 1
help
GPIO number for I2C Master data line.
config GPIO_INA226_INT
int "INA226 ALERT GPIO Num"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 10
help
GPIO number for I2C Master data line.
config GPIO_UART_TX
int "UART TX GPIO Num"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 6
help
GPIO number for UART data line.
config GPIO_UART_RX
int "UART RX GPIO Num"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 7
help
GPIO number for UART data line.
config GPIO_LED_STATUS
int "Status LED GPIO Num"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 8
help
GPIO number for LED.
config GPIO_LED_WIFI
int "Wi-Fi LED GPIO Num"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 9
help
GPIO number for LED.
config GPIO_SW_12V
int "12v Load Switch GPIO Num"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 4
help
GPIO number for Load switch.
config GPIO_SW_5V
int "5v Load Switch GPIO Num"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 5
help
GPIO number for Load switch.
config GPIO_TRIGGER_POWER
int "Trigger power GPIO Num"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 2
help
GPIO number for Trigger.
config GPIO_TRIGGER_RESET
int "Trigger reset GPIO Num"
range ENV_GPIO_RANGE_MIN ENV_GPIO_OUT_RANGE_MAX
default 3
help
GPIO number for Trigger.
endmenu
endmenu

View File

@@ -0,0 +1,50 @@
#include <stdio.h>
#include <string.h>
#include <sys/param.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_system.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_netif.h"
#include "driver/uart.h"
#include "esp_http_server.h"
#include "indicator.h"
#include "nconfig.h"
#include "system.h"
#include "wifi.h"
#include "lwip/err.h"
#include "lwip/sockets.h"
#include "lwip/sys.h"
static const char *TAG = "odroid-remote";
void app_main(void) {
init_led();
led_set(LED_BLU, BLINK_TRIPLE);
led_off(LED_BLU);
// NVS 초기화
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
// 네트워크 초기화
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
ESP_ERROR_CHECK(init_nconfig());
// WiFi 연결
wifi_connect();
sync_time();
start_webserver();
}

3
main/idf_component.yml Normal file
View File

@@ -0,0 +1,3 @@
dependencies:
espressif/led_indicator: ^1.1.1
joltwallet/littlefs: ==1.20.1

118
main/ina226/ina226.c Normal file
View File

@@ -0,0 +1,118 @@
#include "ina226.h"
#include <stdint.h>
#include <string.h>
#include <math.h>
#include <esp_err.h>
#define INA226_REG_CONFIG (0x00)
#define INA226_REG_SHUNT_VOLTAGE (0x01)
#define INA226_REG_BUS_VOLTAGE (0x02)
#define INA226_REG_POWER (0x03)
#define INA226_REG_CURRENT (0x04)
#define INA226_REG_CALIBRATION (0x05)
#define INA226_REG_ALERT_MASK (0x06)
#define INA226_REG_ALERT_LIMIT (0x07)
#define INA226_REG_MANUFACTURER_ID (0xFE)
#define INA226_REG_DIE_ID (0xFF)
#define INA226_CFG_AVERAGING_OFFSET 9
#define INA226_CFG_BUS_VOLTAGE_OFFSET 6
#define INA226_CFG_SHUNT_VOLTAGE_OFFSET 3
static esp_err_t ina226_read_reg(ina226_t *handle, uint8_t reg_addr, uint16_t *data, size_t len)
{
return i2c_master_transmit_receive(handle->dev_handle, &reg_addr, 1, (uint8_t *)data, len, handle->timeout_ms);
}
static esp_err_t ina226_write_reg(ina226_t *handle, uint8_t reg_addr, uint16_t value)
{
uint8_t write_buf[3] = {reg_addr, value >> 8, value & 0xFF};
return i2c_master_transmit(handle->dev_handle, write_buf, sizeof(write_buf), handle->timeout_ms);
}
esp_err_t ina226_get_manufacturer_id(ina226_t *device, uint16_t *manufacturer_id)
{
return ina226_read_reg(device, INA226_REG_MANUFACTURER_ID, manufacturer_id, 2);
}
esp_err_t ina226_get_die_id(ina226_t *device, uint16_t *die_id)
{
return ina226_read_reg(device, INA226_REG_DIE_ID, die_id, 2);
}
esp_err_t ina226_get_shunt_voltage(ina226_t *device, float *voltage)
{
uint8_t data[2];
esp_err_t err = ina226_read_reg(device, INA226_REG_SHUNT_VOLTAGE, (uint16_t*)data, 2);
*voltage = (float) (data[0] << 8 | data[1]) * 2.5e-6f; /* fixed to 2.5 uV */
return err;
}
esp_err_t ina226_get_bus_voltage(ina226_t *device, float *voltage)
{
uint8_t data[2];
esp_err_t err = ina226_read_reg(device, INA226_REG_BUS_VOLTAGE, (uint16_t*)data, 2);
*voltage = (float) (data[0] << 8 | data[1]) * 0.00125f;
return err;
}
esp_err_t ina226_get_current(ina226_t *device, float *current)
{
uint8_t data[2];
esp_err_t err = ina226_read_reg(device, INA226_REG_CURRENT, (uint16_t*)data, 2);
*current = ((float) (data[0] << 8 | data[1])) * device->current_lsb;
return err;
}
esp_err_t ina226_get_power(ina226_t *device, float *power)
{
uint8_t data[2];
esp_err_t err = ina226_read_reg(device, INA226_REG_POWER, (uint16_t*)data, 2);
*power = (float) (data[0] << 8 | data[1]) * device->power_lsb;
return err;
}
esp_err_t ina226_init(ina226_t *device, i2c_master_dev_handle_t dev_handle, const ina226_config_t *config)
{
esp_err_t err;
device->timeout_ms = config->timeout_ms;
device->dev_handle = dev_handle;
uint16_t bitmask = 0;
bitmask |= (config->averages << INA226_CFG_AVERAGING_OFFSET);
bitmask |= (config->bus_conv_time << INA226_CFG_BUS_VOLTAGE_OFFSET);
bitmask |= (config->shunt_conv_time << INA226_CFG_SHUNT_VOLTAGE_OFFSET);
bitmask |= config->mode;
err = ina226_write_reg(device, INA226_REG_CONFIG, bitmask);
if(err != ESP_OK) return err;
/* write calibration*/
float minimum_lsb = config->max_current / 32767;
float current_lsb = (uint16_t)(minimum_lsb * 100000000);
current_lsb /= 100000000;
current_lsb /= 0.0001;
current_lsb = ceil(current_lsb);
current_lsb *= 0.0001;
device->current_lsb = current_lsb;
device->power_lsb = current_lsb * 25;
uint16_t calibration_value = (uint16_t)((0.00512) / (current_lsb * config->r_shunt));
err = ina226_write_reg(device, INA226_REG_CALIBRATION, calibration_value);
if(err != ESP_OK) return err;
return ESP_OK;
}
esp_err_t ina226_get_alert_mask(ina226_t *device, ina226_alert_t *alert_mask)
{
return ina226_read_reg(device, INA226_REG_ALERT_MASK, (uint16_t *)alert_mask, 2);
}
esp_err_t ina226_set_alert_mask(ina226_t *device, ina226_alert_t alert_mask)
{
return ina226_write_reg(device, INA226_REG_ALERT_MASK, (uint16_t)alert_mask);
}
esp_err_t ina226_set_alert_limit(ina226_t *device, float voltage)
{
return ina226_write_reg(device, INA226_REG_ALERT_LIMIT, (uint16_t)(voltage));
}

105
main/include/ina226.h Normal file
View File

@@ -0,0 +1,105 @@
#ifndef _INA226_H_
#define _INA226_H_
#include <stdint.h>
#include "esp_err.h"
#include "driver/i2c_master.h"
typedef enum
{
INA226_AVERAGES_1 = 0b000,
INA226_AVERAGES_4 = 0b001,
INA226_AVERAGES_16 = 0b010,
INA226_AVERAGES_64 = 0b011,
INA226_AVERAGES_128 = 0b100,
INA226_AVERAGES_256 = 0b101,
INA226_AVERAGES_512 = 0b110,
INA226_AVERAGES_1024 = 0b111
} ina226_averages_t;
typedef enum
{
INA226_BUS_CONV_TIME_140_US = 0b000,
INA226_BUS_CONV_TIME_204_US = 0b001,
INA226_BUS_CONV_TIME_332_US = 0b010,
INA226_BUS_CONV_TIME_588_US = 0b011,
INA226_BUS_CONV_TIME_1100_US = 0b100,
INA226_BUS_CONV_TIME_2116_US = 0b101,
INA226_BUS_CONV_TIME_4156_US = 0b110,
INA226_BUS_CONV_TIME_8244_US = 0b111
} ina226_bus_conv_time_t;
typedef enum
{
INA226_SHUNT_CONV_TIME_140_US = 0b000,
INA226_SHUNT_CONV_TIME_204_US = 0b001,
INA226_SHUNT_CONV_TIME_332_US = 0b010,
INA226_SHUNT_CONV_TIME_588_US = 0b011,
INA226_SHUNT_CONV_TIME_1100_US = 0b100,
INA226_SHUNT_CONV_TIME_2116_US = 0b101,
INA226_SHUNT_CONV_TIME_4156_US = 0b110,
INA226_SHUNT_CONV_TIME_8244_US = 0b111
} ina226_shunt_conv_time_t;
typedef enum
{
INA226_MODE_POWER_DOWN = 0b000,
INA226_MODE_SHUNT_TRIG = 0b001,
INA226_MODE_BUS_TRIG = 0b010,
INA226_MODE_SHUNT_BUS_TRIG = 0b011,
INA226_MODE_ADC_OFF = 0b100,
INA226_MODE_SHUNT_CONT = 0b101,
INA226_MODE_BUS_CONT = 0b110,
INA226_MODE_SHUNT_BUS_CONT = 0b111,
} ina226_mode_t;
typedef enum
{
INA226_ALERT_SHUNT_OVER_VOLTAGE = 0xf,
INA226_ALERT_SHUNT_UNDER_VOLTAGE = 0xe,
INA226_ALERT_BUS_OVER_VOLTAGE = 0xd,
INA226_ALERT_BUS_UNDER_VOLTAGE = 0xc,
INA226_ALERT_POWER_OVER_LIMIT = 0xb,
INA226_ALERT_CONVERSION_READY = 0xa,
INA226_ALERT_FUNCTION_FLAG = 0x4,
INA226_ALERT_CONVERSION_READY_FLAG = 0x3,
INA226_ALERT_MATH_OVERFLOW_FLAG = 0x2,
INA226_ALERT_POLARITY = 0x1,
INA226_ALERT_LATCH_ENABLE = 0x0
} ina226_alert_t;
typedef struct
{
i2c_port_t i2c_port;
int i2c_addr;
int timeout_ms;
ina226_averages_t averages;
ina226_bus_conv_time_t bus_conv_time;
ina226_shunt_conv_time_t shunt_conv_time;
ina226_mode_t mode;
float r_shunt; /* ohm */
float max_current; /* amps */
} ina226_config_t;
typedef struct
{
i2c_master_dev_handle_t dev_handle;
int timeout_ms;
float current_lsb;
float power_lsb;
} ina226_t;
esp_err_t ina226_get_manufacturer_id(ina226_t *device, uint16_t *manufacturer_id);
esp_err_t ina226_get_die_id(ina226_t *device, uint16_t *die_id);
esp_err_t ina226_get_shunt_voltage(ina226_t *device, float *voltage);
esp_err_t ina226_get_bus_voltage(ina226_t *device, float *voltage);
esp_err_t ina226_get_current(ina226_t *device, float *current);
esp_err_t ina226_get_power(ina226_t *device, float *power);
esp_err_t ina226_get_alert_mask(ina226_t *device, ina226_alert_t *alert_mask);
esp_err_t ina226_set_alert_mask(ina226_t *device, ina226_alert_t alert_mask);
esp_err_t ina226_set_alert_limit(ina226_t *device, float voltage);
esp_err_t ina226_init(ina226_t *device, i2c_master_dev_handle_t dev_handle, const ina226_config_t *config);
#endif

29
main/include/indicator.h Normal file
View File

@@ -0,0 +1,29 @@
//
// Created by shinys on 25. 7. 29.
//
#ifndef LED_H
#define LED_H
enum blink_type
{
BLINK_SLOW = 0,
BLINK_FAST,
BLINK_DOUBLE,
BLINK_TRIPLE,
BLINK_SOLID,
BLINK_MAX,
};
enum blink_led
{
LED_RED = 0,
LED_BLU = 1,
LED_MAX,
};
void init_led(void);
void led_set(enum blink_led led, enum blink_type type);
void led_off(enum blink_led led);
#endif //LED_H

46
main/include/nconfig.h Normal file
View File

@@ -0,0 +1,46 @@
//
// Created by shinys on 25. 7. 10.
//
#ifndef NCONFIG_H
#define NCONFIG_H
#include "nvs.h"
#include "esp_err.h"
#define NCONFIG_NVS_NAMESPACE "er"
#define NCONFIG_NOT_FOUND ESP_ERR_NVS_NOT_FOUND
esp_err_t init_nconfig();
enum nconfig_type
{
WIFI_SSID,
WIFI_PASSWORD,
WIFI_MODE,
AP_SSID,
AP_PASSWORD,
NETIF_HOSTNAME,
NETIF_IP,
NETIF_GATEWAY,
NETIF_SUBNET,
NETIF_DNS1,
NETIF_DNS2,
NETIF_TYPE,
UART_BAUD_RATE,
NCONFIG_TYPE_MAX,
};
// Write config
esp_err_t nconfig_write(enum nconfig_type type, const char* data);
// Check config is set and get config value length
esp_err_t nconfig_get_str_len(enum nconfig_type type, size_t *len);
// Read config
esp_err_t nconfig_read(enum nconfig_type type, char* data, size_t len);
// Remove key
esp_err_t nconfig_delete(enum nconfig_type type);
#endif //NCONFIG_H

12
main/include/system.h Normal file
View File

@@ -0,0 +1,12 @@
//
// Created by shinys on 25. 8. 5.
//
#ifndef SYSTEM_H
#define SYSTEM_H
void start_reboot_timer(int sec);
void stop_reboot_timer();
void start_webserver();
#endif //SYSTEM_H

23
main/include/wifi.h Normal file
View File

@@ -0,0 +1,23 @@
//
// Created by shinys on 25. 7. 10.
//
#ifndef WIFI_H
#define WIFI_H
#include "esp_err.h"
#include "esp_netif_types.h"
#include "esp_wifi_types_generic.h"
const char* auth_mode_str(wifi_auth_mode_t mode);
esp_err_t wifi_connect(void);
esp_err_t wifi_disconnect(void);
void wifi_scan_aps(wifi_ap_record_t **ap_records, uint16_t* count);
esp_err_t wifi_get_current_ap_info(wifi_ap_record_t *ap_info);
esp_err_t wifi_get_current_ip_info(esp_netif_ip_info_t *ip_info);
esp_err_t wifi_get_dns_info(esp_netif_dns_type_t type, esp_netif_dns_info_t *dns_info);
esp_err_t wifi_use_dhcp(void);
esp_err_t wifi_use_static(const char *ip, const char *gw, const char *netmask, const char *dns1, const char *dns2);
esp_err_t wifi_switch_mode(const char* mode);
void sync_time();
#endif //WIFI_H

109
main/indicator/indicator.c Normal file
View File

@@ -0,0 +1,109 @@
//
// Created by shinys on 25. 7. 29.
//
#include "indicator.h"
#include <led_indicator.h>
#define LED_STATUS_GPIO CONFIG_GPIO_LED_STATUS
#define LED_WIFI_GPIO CONFIG_GPIO_LED_WIFI
static const blink_step_t slow_blink[] = {
{LED_BLINK_HOLD, LED_STATE_ON, 500},
{LED_BLINK_HOLD, LED_STATE_OFF, 500},
{LED_BLINK_HOLD, LED_STATE_ON, 500},
{LED_BLINK_HOLD, LED_STATE_OFF, 500},
{LED_BLINK_LOOP, 0, 0},
};
static const blink_step_t fast_blink[] = {
{LED_BLINK_HOLD, LED_STATE_ON, 100},
{LED_BLINK_HOLD, LED_STATE_OFF, 100},
{LED_BLINK_HOLD, LED_STATE_ON, 100},
{LED_BLINK_HOLD, LED_STATE_OFF, 100},
{LED_BLINK_LOOP, 0, 0},
};
static const blink_step_t double_blink[] = {
{LED_BLINK_HOLD, LED_STATE_ON, 100},
{LED_BLINK_HOLD, LED_STATE_OFF, 100},
{LED_BLINK_HOLD, LED_STATE_ON, 100},
{LED_BLINK_HOLD, LED_STATE_OFF, 500},
{LED_BLINK_LOOP, 0, 0},
};
static const blink_step_t triple_blink[] = {
{LED_BLINK_HOLD, LED_STATE_ON, 100},
{LED_BLINK_HOLD, LED_STATE_OFF, 100},
{LED_BLINK_HOLD, LED_STATE_ON, 100},
{LED_BLINK_HOLD, LED_STATE_OFF, 100},
{LED_BLINK_HOLD, LED_STATE_ON, 100},
{LED_BLINK_HOLD, LED_STATE_OFF, 500},
{LED_BLINK_LOOP, 0, 0},
};
static const blink_step_t solid_blink[] = {
{LED_BLINK_HOLD, LED_STATE_ON, 100},
{LED_BLINK_LOOP, 0, 0},
};
blink_step_t const *led_mode[] = {
[BLINK_SLOW] = slow_blink,
[BLINK_FAST] = fast_blink,
[BLINK_DOUBLE] = double_blink,
[BLINK_TRIPLE] = triple_blink,
[BLINK_SOLID] = solid_blink,
[BLINK_MAX] = NULL,
};
led_indicator_handle_t led_handle[LED_MAX] = {0};
int recent_type[LED_MAX] = {-1, -1};
void init_led(void)
{
led_indicator_ledc_config_t ledc_config = {0};
led_indicator_config_t config = {0};
ledc_config.is_active_level_high = true;
ledc_config.timer_inited = false;
ledc_config.timer_num = LEDC_TIMER_0;
ledc_config.gpio_num = LED_STATUS_GPIO;
ledc_config.channel = LEDC_CHANNEL_0;
config.mode = LED_LEDC_MODE;
config.led_indicator_ledc_config = &ledc_config;
config.blink_lists = led_mode;
config.blink_list_num = BLINK_MAX;
led_handle[LED_RED] = led_indicator_create(&config);
ledc_config.is_active_level_high = true;
ledc_config.timer_inited = false;
ledc_config.timer_num = LEDC_TIMER_0;
ledc_config.gpio_num = LED_WIFI_GPIO;
ledc_config.channel = LEDC_CHANNEL_1;
config.mode = LED_LEDC_MODE;
config.led_indicator_ledc_config = &ledc_config;
config.blink_lists = led_mode;
config.blink_list_num = BLINK_MAX;
led_handle[LED_BLU] = led_indicator_create(&config);
}
void led_set(enum blink_led led, enum blink_type type)
{
if (recent_type[led] != -1)
led_indicator_stop(led_handle[led], recent_type[led]);
recent_type[led] = type;
led_indicator_start(led_handle[led], type);
}
void led_off(enum blink_led led)
{
if (recent_type[led] != -1)
led_indicator_stop(led_handle[led], recent_type[led]);
recent_type[led] = -1;
}

85
main/nconfig/nconfig.c Normal file
View File

@@ -0,0 +1,85 @@
//
// Created by shinys on 25. 7. 10.
//
#include "nconfig.h"
#include "nvs_flash.h"
#include "esp_err.h"
static nvs_handle_t handle;
const static char *keys[NCONFIG_TYPE_MAX] = {
[WIFI_SSID] = "wifi_ssid",
[WIFI_PASSWORD] = "wifi_pw",
[WIFI_MODE] = "wifi_mode",
[AP_SSID] = "ap_ssid",
[AP_PASSWORD] = "ap_pw",
[NETIF_HOSTNAME] = "hostname",
[NETIF_IP] = "ip",
[NETIF_GATEWAY] = "gw",
[NETIF_SUBNET] = "sn",
[NETIF_DNS1] = "dns1",
[NETIF_DNS2] = "dns2",
[NETIF_TYPE] = "dhcp",
[UART_BAUD_RATE] = "baudrate",
};
struct default_value {
enum nconfig_type type;
const char *value;
};
struct default_value const default_values[] = {
{WIFI_SSID, "HK_BOB_24G"},
{WIFI_PASSWORD, ""},
{NETIF_TYPE, "dhcp"},
{NETIF_HOSTNAME, "odroid"},
{UART_BAUD_RATE, "1500000"},
{NETIF_DNS1, "8.8.8.8"},
{NETIF_DNS2, "8.8.4.4"},
};
esp_err_t init_nconfig()
{
esp_err_t ret = nvs_open(NCONFIG_NVS_NAMESPACE, NVS_READWRITE, &handle);
if (ret != ESP_OK) return ret;
for (int i = 0; i < sizeof(default_values) / sizeof(default_values[0]); ++i) {
// check key is not exist or value is null
size_t len = 0;
nconfig_get_str_len(default_values[i].type, &len);
if (len <= 1) // nconfig_get_str_len return err or value is '\0'
{
if (nconfig_write(default_values[i].type, default_values[i].value) != ESP_OK) // if nconfig write fail, system panic
return ESP_FAIL;
}
}
return ESP_OK;
}
esp_err_t nconfig_write(enum nconfig_type type, const char* data)
{
return nvs_set_str(handle, keys[type], data);
}
esp_err_t nconfig_delete(enum nconfig_type type)
{
return nvs_erase_key(handle, keys[type]);
}
esp_err_t nconfig_get_str_len(enum nconfig_type type, size_t *len)
{
return nvs_get_str(handle, keys[type], NULL, len);
}
esp_err_t nconfig_read(enum nconfig_type type, char* data, size_t len)
{
return nvs_get_str(handle, keys[type], data, &len);
}
esp_err_t nconfig_read_bool(enum nconfig_type type, char* data, size_t len)
{
return nvs_get_str(handle, keys[type], data, &len);
}

192
main/service/control.c Normal file
View File

@@ -0,0 +1,192 @@
#include "webserver.h"
#include "driver/gpio.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "cJSON.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_timer.h"
static const char *TAG = "CONTROL";
// --- GPIO 핀 정의 ---
#define GPIO_12V_SWITCH CONFIG_GPIO_SW_12V
#define GPIO_5V_SWITCH CONFIG_GPIO_SW_5V
#define GPIO_POWER_TRIGGER CONFIG_GPIO_TRIGGER_POWER
#define GPIO_RESET_TRIGGER CONFIG_GPIO_TRIGGER_RESET
// --- 상태 변수, 뮤텍스 및 타이머 핸들 ---
static bool status_12v_on = false;
static bool status_5v_on = false;
static SemaphoreHandle_t state_mutex;
static esp_timer_handle_t power_trigger_timer;
static esp_timer_handle_t reset_trigger_timer;
/**
* @brief 타이머 만료 시 GPIO를 다시 HIGH로 설정하는 콜백 함수
*/
static void trigger_off_callback(void* arg)
{
gpio_num_t gpio_pin = (int) arg;
gpio_set_level(gpio_pin, 1); // 핀을 다시 HIGH로 복구
ESP_LOGI(TAG, "GPIO %d trigger finished.", gpio_pin);
}
static void update_gpio_switches()
{
gpio_set_level(GPIO_12V_SWITCH, status_12v_on);
gpio_set_level(GPIO_5V_SWITCH, status_5v_on);
ESP_LOGI(TAG, "Switches updated: 12V=%s, 5V=%s", status_12v_on ? "ON" : "OFF", status_5v_on ? "ON" : "OFF");
}
static esp_err_t control_get_handler(httpd_req_t *req)
{
cJSON *root = cJSON_CreateObject();
xSemaphoreTake(state_mutex, portMAX_DELAY);
cJSON_AddBoolToObject(root, "load_12v_on", status_12v_on);
cJSON_AddBoolToObject(root, "load_5v_on", status_5v_on);
xSemaphoreGive(state_mutex);
char *json_string = cJSON_Print(root);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, json_string, strlen(json_string));
free(json_string);
cJSON_Delete(root);
return ESP_OK;
}
static esp_err_t control_post_handler(httpd_req_t *req)
{
char buf[128];
int ret, remaining = req->content_len;
if (remaining >= sizeof(buf)) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Request content too long");
return ESP_FAIL;
}
ret = httpd_req_recv(req, buf, remaining);
if (ret <= 0) {
if (ret == HTTPD_SOCK_ERR_TIMEOUT) {
httpd_resp_send_408(req);
}
return ESP_FAIL;
}
buf[ret] = '\0';
ESP_LOGI(TAG, "Received JSON: %s", buf);
cJSON *root = cJSON_Parse(buf);
if (root == NULL) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON format");
return ESP_FAIL;
}
bool state_changed = false;
xSemaphoreTake(state_mutex, portMAX_DELAY);
cJSON *item_12v = cJSON_GetObjectItem(root, "load_12v_on");
if (cJSON_IsBool(item_12v)) {
status_12v_on = cJSON_IsTrue(item_12v);
state_changed = true;
}
cJSON *item_5v = cJSON_GetObjectItem(root, "load_5v_on");
if (cJSON_IsBool(item_5v)) {
status_5v_on = cJSON_IsTrue(item_5v);
state_changed = true;
}
if (state_changed) {
update_gpio_switches();
}
xSemaphoreGive(state_mutex);
cJSON *power_trigger = cJSON_GetObjectItem(root, "power_trigger");
if (cJSON_IsTrue(power_trigger)) {
ESP_LOGI(TAG, "Triggering GPIO %d LOW for 3 seconds...", GPIO_POWER_TRIGGER);
gpio_set_level(GPIO_POWER_TRIGGER, 0);
esp_timer_stop(power_trigger_timer); // Stop timer if it's already running
ESP_ERROR_CHECK(esp_timer_start_once(power_trigger_timer, 3000000)); // 3초
}
cJSON *reset_trigger = cJSON_GetObjectItem(root, "reset_trigger");
if (cJSON_IsTrue(reset_trigger)) {
ESP_LOGI(TAG, "Triggering GPIO %d LOW for 3 seconds...", GPIO_RESET_TRIGGER);
gpio_set_level(GPIO_RESET_TRIGGER, 0);
esp_timer_stop(reset_trigger_timer); // Stop timer if it's already running
ESP_ERROR_CHECK(esp_timer_start_once(reset_trigger_timer, 3000000)); // 3초
}
cJSON_Delete(root);
httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return ESP_OK;
}
static void control_module_init(void)
{
state_mutex = xSemaphoreCreateMutex();
gpio_config_t switch_conf = {
.pin_bit_mask = (1ULL << GPIO_12V_SWITCH) | (1ULL << GPIO_5V_SWITCH),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&switch_conf);
update_gpio_switches();
gpio_config_t trigger_conf = {
.pin_bit_mask = (1ULL << GPIO_POWER_TRIGGER) | (1ULL << GPIO_RESET_TRIGGER),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&trigger_conf);
gpio_set_level(GPIO_POWER_TRIGGER, 1);
gpio_set_level(GPIO_RESET_TRIGGER, 1);
const esp_timer_create_args_t power_timer_args = {
.callback = &trigger_off_callback,
.arg = (void*) GPIO_POWER_TRIGGER,
.name = "power_trigger_off"
};
ESP_ERROR_CHECK(esp_timer_create(&power_timer_args, &power_trigger_timer));
const esp_timer_create_args_t reset_timer_args = {
.callback = &trigger_off_callback,
.arg = (void*) GPIO_RESET_TRIGGER,
.name = "reset_trigger_off"
};
ESP_ERROR_CHECK(esp_timer_create(&reset_timer_args, &reset_trigger_timer));
ESP_LOGI(TAG, "Control module initialized");
}
void register_control_endpoint(httpd_handle_t server)
{
control_module_init();
httpd_uri_t get_uri = {
.uri = "/api/control",
.method = HTTP_GET,
.handler = control_get_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &get_uri);
httpd_uri_t post_uri = {
.uri = "/api/control",
.method = HTTP_POST,
.handler = control_post_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &post_uri);
ESP_LOGI(TAG, "Registered /api/control endpoints (GET, POST)");
}

175
main/service/datalog.c Normal file
View File

@@ -0,0 +1,175 @@
#include "datalog.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/stat.h>
#include "esp_littlefs.h"
#include "esp_log.h"
static const char* TAG = "DATALOG";
static const char* LOG_FILE_PATH = "/littlefs/datalog.csv";
static const char* TEMP_LOG_FILE_PATH = "/littlefs/datalog.tmp";
#define MAX_LOG_SIZE (1024 * 1024)
void datalog_init(void)
{
ESP_LOGI(TAG, "Initializing DataLog with LittleFS");
esp_vfs_littlefs_conf_t conf = {
.base_path = "/littlefs",
.partition_label = "littlefs",
.format_if_mount_failed = true,
.dont_mount = false,
};
esp_err_t ret = esp_vfs_littlefs_register(&conf);
if (ret != ESP_OK)
{
if (ret == ESP_FAIL)
{
ESP_LOGE(TAG, "Failed to mount or format filesystem");
}
else if (ret == ESP_ERR_NOT_FOUND)
{
ESP_LOGE(TAG, "Failed to find LittleFS partition");
}
else
{
ESP_LOGE(TAG, "Failed to initialize LittleFS (%s)", esp_err_to_name(ret));
}
return;
}
size_t total = 0, used = 0;
ret = esp_littlefs_info(NULL, &total, &used);
if (ret != ESP_OK)
{
ESP_LOGE(TAG, "Failed to get LittleFS partition information (%s)", esp_err_to_name(ret));
}
else
{
ESP_LOGI(TAG, "Partition size: total: %d, used: %d", total, used);
}
// Check if file exists
FILE* f = fopen(LOG_FILE_PATH, "r");
if (f == NULL)
{
ESP_LOGI(TAG, "Log file not found, creating new one.");
FILE* f_write = fopen(LOG_FILE_PATH, "w");
if (f_write == NULL)
{
ESP_LOGE(TAG, "Failed to create log file.");
}
else
{
// Add header
fprintf(f_write, "timestamp,voltage,current,power\n");
fclose(f_write);
}
}
else
{
ESP_LOGI(TAG, "Log file found.");
fclose(f);
}
}
void datalog_add(uint32_t timestamp, float voltage, float current, float power)
{
char new_line[100];
int new_line_len = snprintf(new_line, sizeof(new_line), "%lu,%.3f,%.3f,%.3f\n", timestamp, voltage, current, power);
struct stat st;
long size = 0;
if (stat(LOG_FILE_PATH, &st) == 0)
{
size = st.st_size;
}
if (size + new_line_len <= MAX_LOG_SIZE)
{
FILE* f = fopen(LOG_FILE_PATH, "a");
if (f == NULL)
{
ESP_LOGE(TAG, "Failed to open log file for appending.");
return;
}
fputs(new_line, f);
fclose(f);
}
else
{
ESP_LOGI(TAG, "Log file is full. Rotating log file.");
FILE* f_read = fopen(LOG_FILE_PATH, "r");
if (f_read == NULL)
{
ESP_LOGE(TAG, "Could not open log for reading");
return;
}
FILE* f_write = fopen(TEMP_LOG_FILE_PATH, "w");
if (f_write == NULL)
{
ESP_LOGE(TAG, "Could not open temp file for writing");
fclose(f_read);
return;
}
long size_to_remove = (size + new_line_len) - MAX_LOG_SIZE;
char line[256];
// Keep header
if (fgets(line, sizeof(line), f_read) != NULL)
{
fputs(line, f_write);
}
else
{
ESP_LOGE(TAG, "Could not read header");
fclose(f_read);
fclose(f_write);
return;
}
long bytes_skipped = 0;
while (fgets(line, sizeof(line), f_read) != NULL)
{
bytes_skipped += strlen(line);
if (bytes_skipped >= size_to_remove)
{
fputs(line, f_write);
break;
}
}
while (fgets(line, sizeof(line), f_read) != NULL)
{
fputs(line, f_write);
}
fputs(new_line, f_write);
fclose(f_read);
fclose(f_write);
if (remove(LOG_FILE_PATH) != 0)
{
ESP_LOGE(TAG, "Failed to remove old log file");
}
else if (rename(TEMP_LOG_FILE_PATH, LOG_FILE_PATH) != 0)
{
ESP_LOGE(TAG, "Failed to rename temp file");
}
else
{
ESP_LOGI(TAG, "Log file rotated successfully.");
}
}
}
const char* datalog_get_path(void)
{
return LOG_FILE_PATH;
}

10
main/service/datalog.h Normal file
View File

@@ -0,0 +1,10 @@
#ifndef MAIN_SERVICE_DATALOG_H_
#define MAIN_SERVICE_DATALOG_H_
#include <stdint.h>
void datalog_init(void);
void datalog_add(uint32_t timestamp, float voltage, float current, float power);
const char* datalog_get_path(void);
#endif /* MAIN_SERVICE_DATALOG_H_ */

135
main/service/monitor.c Normal file
View File

@@ -0,0 +1,135 @@
//
// Created by shinys on 25. 8. 18..
//
#include "monitor.h"
#include <time.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "cJSON.h"
#include "esp_netif.h"
#include "esp_wifi_types_generic.h"
#include "ina226.h"
#include "webserver.h"
#include "wifi.h"
#include "datalog.h"
#define INA226_SDA CONFIG_GPIO_INA226_SDA
#define INA226_SCL CONFIG_GPIO_INA226_SCL
ina226_t ina;
i2c_master_bus_handle_t bus_handle;
i2c_master_dev_handle_t dev_handle;
// Timer callback function to read sensor data
static void sensor_timer_callback(void *arg)
{
// Generate random sensor data
float voltage = 0;
float current = 0;
float power = 0;
ina226_get_bus_voltage(&ina, &voltage);
ina226_get_power(&ina, &power);
ina226_get_current(&ina, &current);
// Get system uptime
int64_t uptime_us = esp_timer_get_time();
uint32_t uptime_sec = (uint32_t)(uptime_us / 1000000);
uint32_t timestamp = (uint32_t)time(NULL);
datalog_add(timestamp, voltage, current, power);
// Create JSON object with sensor data
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "type", "sensor_data");
cJSON_AddNumberToObject(root, "voltage", voltage);
cJSON_AddNumberToObject(root, "current", current);
cJSON_AddNumberToObject(root, "power", power);
cJSON_AddNumberToObject(root, "timestamp", timestamp);
cJSON_AddNumberToObject(root, "uptime_sec", uptime_sec);
// Push data to WebSocket clients
push_data_to_ws(root);
}
static void status_wifi_callback(void *arg)
{
wifi_ap_record_t ap_info;
cJSON *root = cJSON_CreateObject();
if (wifi_get_current_ap_info(&ap_info) == ESP_OK) {
cJSON_AddStringToObject(root, "type", "wifi_status");
cJSON_AddBoolToObject(root, "connected", true);
cJSON_AddStringToObject(root, "ssid", (const char *)ap_info.ssid);
cJSON_AddNumberToObject(root, "rssi", ap_info.rssi);
} else {
cJSON_AddBoolToObject(root, "connected", false);
}
push_data_to_ws(root);
}
ina226_config_t ina_config = {
.i2c_port = I2C_NUM_0,
.i2c_addr = 0x40,
.timeout_ms = 100,
.averages = INA226_AVERAGES_16,
.bus_conv_time = INA226_BUS_CONV_TIME_1100_US,
.shunt_conv_time = INA226_SHUNT_CONV_TIME_1100_US,
.mode = INA226_MODE_SHUNT_BUS_CONT,
.r_shunt = 0.01f,
.max_current = 8
};
static void init_ina226()
{
i2c_master_bus_config_t bus_config = {
.i2c_port = I2C_NUM_0,
.sda_io_num = (gpio_num_t) INA226_SDA,
.scl_io_num = (gpio_num_t) INA226_SCL,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = false,
};
ESP_ERROR_CHECK(i2c_new_master_bus(&bus_config, &bus_handle));
i2c_device_config_t dev_config = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = 0x40,
.scl_speed_hz = 400000,
};
ESP_ERROR_CHECK(i2c_master_bus_add_device(bus_handle, &dev_config, &dev_handle));
ESP_ERROR_CHECK(ina226_init(&ina, dev_handle, &ina_config));
}
static esp_timer_handle_t sensor_timer;
static esp_timer_handle_t wifi_status_timer;
void init_status_monitor()
{
init_ina226();
datalog_init();
// Timer configuration
const esp_timer_create_args_t sensor_timer_args = {
.callback = &sensor_timer_callback,
.name = "sensor_reading_timer" // Optional name for debugging
};
const esp_timer_create_args_t wifi_timer_args = {
.callback = &status_wifi_callback,
.name = "wifi_status_timer" // Optional name for debugging
};
ESP_ERROR_CHECK(esp_timer_create(&sensor_timer_args, &sensor_timer));
ESP_ERROR_CHECK(esp_timer_create(&wifi_timer_args, &wifi_status_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(sensor_timer, 1000000)); // 1sec
ESP_ERROR_CHECK(esp_timer_start_periodic(wifi_status_timer, 1000000 * 5)); // 5s in microseconds
}

25
main/service/monitor.h Normal file
View File

@@ -0,0 +1,25 @@
//
// Created by shinys on 25. 8. 18..
//
#ifndef ODROID_REMOTE_HTTP_MONITOR_H
#define ODROID_REMOTE_HTTP_MONITOR_H
#include <stdint.h>
#include "esp_http_server.h"
// 버퍼에 저장할 데이터의 개수
#define SENSOR_BUFFER_SIZE 100
// 단일 센서 데이터를 저장하기 위한 구조체
typedef struct {
float voltage;
float current;
float power;
uint32_t timestamp; // 데이터를 읽은 시간 (부팅 후 ms)
} sensor_data_t;
void init_status_monitor();
#endif //ODROID_REMOTE_HTTP_MONITOR_H

249
main/service/setting.c Normal file
View File

@@ -0,0 +1,249 @@
#include "webserver.h"
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "nconfig.h"
#include "wifi.h"
#include "system.h"
#include "esp_netif.h"
#include "freertos/task.h"
static const char *TAG = "webserver";
static esp_err_t setting_get_handler(httpd_req_t *req)
{
wifi_ap_record_t ap_info;
cJSON *root = cJSON_CreateObject();
char mode_buf[16];
if (nconfig_read(WIFI_MODE, mode_buf, sizeof(mode_buf)) == ESP_OK) {
cJSON_AddStringToObject(root, "mode", mode_buf);
} else {
cJSON_AddStringToObject(root, "mode", "sta"); // Default to sta
}
char net_type_buf[16];
if (nconfig_read(NETIF_TYPE, net_type_buf, sizeof(net_type_buf)) == ESP_OK) {
cJSON_AddStringToObject(root, "net_type", net_type_buf);
} else {
cJSON_AddStringToObject(root, "net_type", "dhcp"); // Default to dhcp
}
// Add baudrate to the response
char baud_buf[16];
if (nconfig_read(UART_BAUD_RATE, baud_buf, sizeof(baud_buf)) == ESP_OK) {
cJSON_AddStringToObject(root, "baudrate", baud_buf);
}
if (wifi_get_current_ap_info(&ap_info) == ESP_OK) {
cJSON_AddBoolToObject(root, "connected", true);
cJSON_AddStringToObject(root, "ssid", (const char *)ap_info.ssid);
cJSON_AddNumberToObject(root, "rssi", ap_info.rssi);
esp_netif_ip_info_t ip_info;
cJSON* ip_obj = cJSON_CreateObject();
if (wifi_get_current_ip_info(&ip_info) == ESP_OK) {
char ip_str[16];
esp_ip4addr_ntoa(&ip_info.ip, ip_str, sizeof(ip_str));
cJSON_AddStringToObject(ip_obj, "ip", ip_str);
esp_ip4addr_ntoa(&ip_info.gw, ip_str, sizeof(ip_str));
cJSON_AddStringToObject(ip_obj, "gateway", ip_str);
esp_ip4addr_ntoa(&ip_info.netmask, ip_str, sizeof(ip_str));
cJSON_AddStringToObject(ip_obj, "subnet", ip_str);
}
esp_netif_dns_info_t dns_info;
char dns_str[16];
if (wifi_get_dns_info(ESP_NETIF_DNS_MAIN, &dns_info) == ESP_OK) {
esp_ip4addr_ntoa(&dns_info.ip.u_addr.ip4, dns_str, sizeof(dns_str));
cJSON_AddStringToObject(ip_obj, "dns1", dns_str);
}
if (wifi_get_dns_info(ESP_NETIF_DNS_BACKUP, &dns_info) == ESP_OK) {
esp_ip4addr_ntoa(&dns_info.ip.u_addr.ip4, dns_str, sizeof(dns_str));
cJSON_AddStringToObject(ip_obj, "dns2", dns_str);
}
cJSON_AddItemToObject(root, "ip", ip_obj);
} else {
cJSON_AddBoolToObject(root, "connected", false);
}
const char *json_string = cJSON_Print(root);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, json_string, HTTPD_RESP_USE_STRLEN);
cJSON_Delete(root);
free((void*)json_string);
return ESP_OK;
}
static esp_err_t wifi_scan(httpd_req_t *req)
{
wifi_ap_record_t *ap_records;
uint16_t count;
wifi_scan_aps(&ap_records, &count);
cJSON *root = cJSON_CreateArray();
for (int i = 0; i < count; i++)
{
cJSON *ap_obj = cJSON_CreateObject();
cJSON_AddStringToObject(ap_obj, "ssid", (const char *)ap_records[i].ssid);
cJSON_AddNumberToObject(ap_obj, "rssi", ap_records[i].rssi);
cJSON_AddStringToObject(ap_obj, "authmode", auth_mode_str(ap_records[i].authmode));
cJSON_AddItemToArray(root, ap_obj);
}
if (count > 0)
free(ap_records);
const char *json_string = cJSON_Print(root);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, json_string, HTTPD_RESP_USE_STRLEN);
cJSON_Delete(root);
free((void*)json_string);
return ESP_OK;
}
static esp_err_t setting_post_handler(httpd_req_t *req)
{
char buf[512];
int received = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (received <= 0) {
if (received == HTTPD_SOCK_ERR_TIMEOUT) httpd_resp_send_408(req);
return ESP_FAIL;
}
buf[received] = '\0';
cJSON *root = cJSON_Parse(buf);
if (root == NULL) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
return ESP_FAIL;
}
cJSON *mode_item = cJSON_GetObjectItem(root, "mode");
cJSON *net_type_item = cJSON_GetObjectItem(root, "net_type");
cJSON *ssid_item = cJSON_GetObjectItem(root, "ssid");
cJSON *baud_item = cJSON_GetObjectItem(root, "baudrate");
if (mode_item && cJSON_IsString(mode_item)) {
const char* mode = mode_item->valuestring;
ESP_LOGI(TAG, "Received mode switch request: %s", mode);
if (strcmp(mode, "sta") == 0 || strcmp(mode, "apsta") == 0) {
if (strcmp(mode, "apsta") == 0) {
cJSON *ap_ssid_item = cJSON_GetObjectItem(root, "ap_ssid");
cJSON *ap_pass_item = cJSON_GetObjectItem(root, "ap_password");
if (ap_ssid_item && cJSON_IsString(ap_ssid_item)) {
nconfig_write(AP_SSID, ap_ssid_item->valuestring);
} else {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "AP SSID required for APSTA mode");
cJSON_Delete(root);
return ESP_FAIL;
}
if (ap_pass_item && cJSON_IsString(ap_pass_item)) {
nconfig_write(AP_PASSWORD, ap_pass_item->valuestring);
} else {
nconfig_delete(AP_PASSWORD); // Open network
}
}
wifi_switch_mode(mode);
httpd_resp_sendstr(req, "{\"status\":\"mode_switch_initiated\"}");
} else {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid mode");
}
} else if (net_type_item && cJSON_IsString(net_type_item)) {
const char* type = net_type_item->valuestring;
ESP_LOGI(TAG, "Received network config: %s", type);
if (strcmp(type, "static") == 0) {
cJSON *ip_item = cJSON_GetObjectItem(root, "ip");
cJSON *gw_item = cJSON_GetObjectItem(root, "gateway");
cJSON *sn_item = cJSON_GetObjectItem(root, "subnet");
cJSON *d1_item = cJSON_GetObjectItem(root, "dns1");
cJSON *d2_item = cJSON_GetObjectItem(root, "dns2");
const char* ip = cJSON_IsString(ip_item) ? ip_item->valuestring : NULL;
const char* gw = cJSON_IsString(gw_item) ? gw_item->valuestring : NULL;
const char* sn = cJSON_IsString(sn_item) ? sn_item->valuestring : NULL;
const char* d1 = cJSON_IsString(d1_item) ? d1_item->valuestring : NULL;
const char* d2 = cJSON_IsString(d2_item) ? d2_item->valuestring : NULL;
if (ip && gw && sn && d1) {
nconfig_write(NETIF_TYPE, "static");
nconfig_write(NETIF_IP, ip);
nconfig_write(NETIF_GATEWAY, gw);
nconfig_write(NETIF_SUBNET, sn);
nconfig_write(NETIF_DNS1, d1);
if (d2) nconfig_write(NETIF_DNS2, d2); else nconfig_delete(NETIF_DNS2);
wifi_use_static(ip, gw, sn, d1, d2);
httpd_resp_sendstr(req, "{\"status\":\"static_config_applied\"}");
} else {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing static IP fields");
}
} else if (strcmp(type, "dhcp") == 0) {
nconfig_write(NETIF_TYPE, "dhcp");
wifi_use_dhcp();
httpd_resp_sendstr(req, "{\"status\":\"dhcp_config_applied\"}");
}
} else if (ssid_item && cJSON_IsString(ssid_item)) {
cJSON *pass_item = cJSON_GetObjectItem(root, "password");
if (cJSON_IsString(pass_item)) {
nconfig_write(WIFI_SSID, ssid_item->valuestring);
nconfig_write(WIFI_PASSWORD, pass_item->valuestring);
nconfig_write(NETIF_TYPE, "dhcp"); // Default to DHCP on new connection
httpd_resp_sendstr(req, "{\"status\":\"connection_initiated\"}");
wifi_disconnect();
vTaskDelay(pdMS_TO_TICKS(500));
wifi_connect();
} else {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Password required");
}
} else if (baud_item && cJSON_IsString(baud_item)) {
const char* baudrate = baud_item->valuestring;
ESP_LOGI(TAG, "Received baudrate set request: %s", baudrate);
nconfig_write(UART_BAUD_RATE, baudrate);
change_baud_rate(strtol(baudrate, NULL, 10));
httpd_resp_sendstr(req, "{\"status\":\"baudrate_updated\"}");
} else {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid payload");
}
cJSON_Delete(root);
return ESP_OK;
}
void register_wifi_endpoint(httpd_handle_t server)
{
httpd_uri_t status = {
.uri = "/api/setting",
.method = HTTP_GET,
.handler = setting_get_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &status);
httpd_uri_t set = {
.uri = "/api/setting",
.method = HTTP_POST,
.handler = setting_post_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &set);
httpd_uri_t scan = {
.uri = "/api/wifi/scan",
.method = HTTP_GET,
.handler = wifi_scan,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &scan);
}

102
main/service/webserver.c Normal file
View File

@@ -0,0 +1,102 @@
#include "webserver.h"
#include <stdio.h>
#include <string.h>
#include <sys/param.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_system.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_netif.h"
#include "driver/uart.h"
#include "esp_http_server.h"
#include "indicator.h"
#include "nconfig.h"
#include "monitor.h"
#include "wifi.h"
#include "datalog.h"
#include "lwip/err.h"
#include "lwip/sockets.h"
#include "lwip/sys.h"
static const char *TAG = "WEBSERVER";
static esp_err_t index_handler(httpd_req_t *req) {
extern const unsigned char index_html_start[] asm("_binary_index_html_gz_start");
extern const unsigned char index_html_end[] asm("_binary_index_html_gz_end");
const size_t index_html_size = (index_html_end - index_html_start);
httpd_resp_set_hdr(req, "Content-Encoding", "gzip");
httpd_resp_set_type(req, "text/html");
httpd_resp_send(req, (const char *)index_html_start, index_html_size);
return ESP_OK;
}
static esp_err_t datalog_download_handler(httpd_req_t *req)
{
const char *filepath = datalog_get_path();
FILE *f = fopen(filepath, "r");
if (f == NULL) {
ESP_LOGE(TAG, "Failed to open datalog file for reading");
httpd_resp_send_404(req);
return ESP_FAIL;
}
httpd_resp_set_type(req, "text/csv");
httpd_resp_set_hdr(req, "Content-Disposition", "attachment; filename=\"datalog.csv\"");
char buffer[1024];
size_t bytes_read;
while ((bytes_read = fread(buffer, 1, sizeof(buffer), f)) > 0) {
if (httpd_resp_send_chunk(req, buffer, bytes_read) != ESP_OK) {
ESP_LOGE(TAG, "File sending failed!");
fclose(f);
httpd_resp_send_chunk(req, NULL, 0);
httpd_resp_send_500(req);
return ESP_FAIL;
}
}
fclose(f);
httpd_resp_send_chunk(req, NULL, 0);
return ESP_OK;
}
// HTTP 서버 시작
void start_webserver(void) {
httpd_handle_t server = NULL;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 1024 * 8;
config.max_uri_handlers = 10;
if (httpd_start(&server, &config) != ESP_OK) {
return ;
}
// Index page
httpd_uri_t index = {
.uri = "/",
.method = HTTP_GET,
.handler = index_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &index);
httpd_uri_t datalog_uri = {
.uri = "/datalog.csv",
.method = HTTP_GET,
.handler = datalog_download_handler,
.user_ctx = NULL
};
httpd_register_uri_handler(server, &datalog_uri);
register_wifi_endpoint(server);
register_ws_endpoint(server);
register_control_endpoint(server);
init_status_monitor();
}

17
main/service/webserver.h Normal file
View File

@@ -0,0 +1,17 @@
//
// Created by shinys on 25. 8. 18..
//
#ifndef ODROID_REMOTE_HTTP_WEBSERVER_H
#define ODROID_REMOTE_HTTP_WEBSERVER_H
#include "cJSON.h"
#include "esp_http_server.h"
#include "system.h"
void register_wifi_endpoint(httpd_handle_t server);
void register_ws_endpoint(httpd_handle_t server);
void register_control_endpoint(httpd_handle_t server);
void push_data_to_ws(cJSON *data);
esp_err_t change_baud_rate(int baud_rate);
#endif //ODROID_REMOTE_HTTP_WEBSERVER_H

178
main/service/ws.c Normal file
View File

@@ -0,0 +1,178 @@
//
// Created by shinys on 25. 8. 18..
//
#include "cJSON.h"
#include "webserver.h"
#include "esp_err.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "nconfig.h"
#include "driver/uart.h"
#define UART_NUM UART_NUM_1
#define BUF_SIZE (4096)
#define RD_BUF_SIZE (BUF_SIZE)
#define UART_TX_PIN CONFIG_GPIO_UART_TX
#define UART_RX_PIN CONFIG_GPIO_UART_RX
static const char *TAG = "ws-uart";
static int client_fd = -1;
struct status_message
{
cJSON *data;
};
QueueHandle_t status_queue;
// Status task
static void status_task(void *arg)
{
httpd_handle_t server = (httpd_handle_t)arg;
struct status_message msg;
while (1) {
if (xQueueReceive(status_queue, &msg, portMAX_DELAY)) {
if (client_fd <= 0) continue;
char *json_string = cJSON_Print(msg.data);
httpd_ws_frame_t ws_pkt;
memset(&ws_pkt, 0, sizeof(httpd_ws_frame_t));
ws_pkt.payload = (uint8_t *)json_string;
ws_pkt.len = strlen(json_string);
ws_pkt.type = HTTPD_WS_TYPE_TEXT;
esp_err_t err = httpd_ws_send_frame_async(server, client_fd, &ws_pkt);
free(json_string);
cJSON_Delete(msg.data);
if (err != ESP_OK)
{
// try close...
httpd_ws_frame_t close_frame = {
.final = true,
.fragmented = false,
.type = HTTPD_WS_TYPE_CLOSE,
.payload = NULL,
.len = 0
};
httpd_ws_send_frame_async(server, client_fd, &close_frame);
client_fd = -1;
}
}
vTaskDelay(1);
}
}
// UART task
static void uart_read_task(void *arg) {
httpd_handle_t server = (httpd_handle_t)arg;
uint8_t data[RD_BUF_SIZE];
while (1) {
int len = uart_read_bytes(UART_NUM, data, RD_BUF_SIZE, 10 / portTICK_PERIOD_MS);
if (len > 0 && client_fd != -1) {
httpd_ws_frame_t ws_pkt;
memset(&ws_pkt, 0, sizeof(httpd_ws_frame_t));
ws_pkt.payload = data;
ws_pkt.len = len;
ws_pkt.type = HTTPD_WS_TYPE_BINARY;
esp_err_t err = httpd_ws_send_frame_async(server, client_fd, &ws_pkt);
if (err != ESP_OK)
{
// try close...
httpd_ws_frame_t close_frame = {
.final = true,
.fragmented = false,
.type = HTTPD_WS_TYPE_CLOSE,
.payload = NULL,
.len = 0
};
httpd_ws_send_frame_async(server, client_fd, &close_frame);
client_fd = -1;
}
}
vTaskDelay(1);
}
}
// 웹소켓 처리 핸들러
static esp_err_t ws_handler(httpd_req_t *req) {
if (req->method == HTTP_GET) {
ESP_LOGI(TAG, "Accept websocket connection");
client_fd = httpd_req_to_sockfd(req);
xQueueReset(status_queue);
return ESP_OK;
}
httpd_ws_frame_t ws_pkt;
uint8_t buf[BUF_SIZE];
memset(&ws_pkt, 0, sizeof(httpd_ws_frame_t));
ws_pkt.payload = buf;
ws_pkt.type = HTTPD_WS_TYPE_BINARY;
esp_err_t ret = httpd_ws_recv_frame(req, &ws_pkt, BUF_SIZE);
if (ret != ESP_OK) {
ESP_LOGI(TAG, "웹소켓 프레임 수신 실패");
return ret;
}
uart_write_bytes(UART_NUM, (const char *)ws_pkt.payload, ws_pkt.len);
return ESP_OK;
}
void register_ws_endpoint(httpd_handle_t server)
{
size_t baud_rate_len;
nconfig_get_str_len(UART_BAUD_RATE, &baud_rate_len);
char buf[baud_rate_len];
nconfig_read(UART_BAUD_RATE, buf, baud_rate_len);
uart_config_t uart_config = {
.baud_rate = strtol(buf, NULL, 10),
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
// .source_clk = UART_SCLK_APB,
};
ESP_ERROR_CHECK(uart_param_config(UART_NUM, &uart_config));
ESP_ERROR_CHECK(uart_set_pin(UART_NUM, UART_TX_PIN, UART_RX_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));
ESP_ERROR_CHECK(uart_driver_install(UART_NUM, BUF_SIZE * 2, BUF_SIZE * 2, 0, NULL, ESP_INTR_FLAG_IRAM));
httpd_uri_t ws = {
.uri = "/ws",
.method = HTTP_GET,
.handler = ws_handler,
.user_ctx = NULL,
.is_websocket = true
};
httpd_register_uri_handler(server, &ws);
status_queue = xQueueCreate(10, sizeof(struct status_message));
xTaskCreate(uart_read_task, "uart_read_task", 1024*6, server, 8, NULL);
xTaskCreate(status_task, "status_task", 4096, server, 7, NULL);
}
void push_data_to_ws(cJSON *data)
{
struct status_message msg;
msg.data = data;
if (xQueueSend(status_queue, &msg, 10) != pdPASS)
{
ESP_LOGW(TAG, "Queue full");
}
}
esp_err_t change_baud_rate(int baud_rate)
{
return uart_set_baudrate(UART_NUM, baud_rate);
}

46
main/system/system.c Normal file
View File

@@ -0,0 +1,46 @@
//
// Created by shinys on 25. 8. 5.
//
#include <system.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <esp_log.h>
static const char *TAG = "odroid";
int t = 0;
TaskHandle_t reboot_handle = NULL;
static void reboot_task(void *arg)
{
while (t > 0)
{
ESP_LOGW(TAG, "ESP will reboot in [%d] sec..., If you want stop reboot, use command \"reboot -s\"", t);
vTaskDelay(1000 / portTICK_PERIOD_MS);
--t;
}
esp_restart();
}
void start_reboot_timer(int sec)
{
if (reboot_handle != NULL)
{
ESP_LOGW(TAG, "The reboot timer is already running.");
return;
}
t = sec;
xTaskCreate(reboot_task, "reboot_task", 2048, NULL, 8, &reboot_handle);
}
void stop_reboot_timer()
{
if (reboot_handle == NULL)
{
return;
}
vTaskDelete(reboot_handle);
}

550
main/wifi/wifi.c Normal file
View File

@@ -0,0 +1,550 @@
//
// Created by shinys on 25. 7. 10.
//
#include "wifi.h"
#include "nconfig.h"
#include "indicator.h"
#include <string.h>
#include <system.h>
#include <lwip/sockets.h>
#include <time.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_event.h"
#include "esp_wifi.h"
#include "esp_wifi_default.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_netif_sntp.h"
#include "rom/ets_sys.h"
static const char *TAG = "odroid";
#define MAX_RETRY 10
#define MAX_SCAN 20
const char* auth_mode_str(wifi_auth_mode_t mode)
{
switch (mode)
{
case WIFI_AUTH_OPEN:
return "OPEN";
case WIFI_AUTH_WEP:
return "WEP";
case WIFI_AUTH_WPA_PSK:
return "WPA_PSK";
case WIFI_AUTH_WPA2_PSK:
return "WPA2_PSK";
case WIFI_AUTH_WPA_WPA2_PSK:
return "WPA_WPA2_PSK";
case WIFI_AUTH_ENTERPRISE:
return "ENTERPRISE";
case WIFI_AUTH_WPA3_PSK:
return "WPA3_PSK";
case WIFI_AUTH_WPA2_WPA3_PSK:
return "WPA2_WPA3_PSK";
case WIFI_AUTH_WAPI_PSK:
return "WAPI_PSK";
case WIFI_AUTH_OWE:
return "OWE";
case WIFI_AUTH_WPA3_ENT_192:
return "WPA3_ENT_192";
case WIFI_AUTH_WPA3_EXT_PSK:
return "WPA3_EXT_PSK";
case WIFI_AUTH_WPA3_EXT_PSK_MIXED_MODE:
return "WPA3_EXT_PSK_MIXED_MODE";
case WIFI_AUTH_DPP:
return "DPP";
case WIFI_AUTH_WPA3_ENTERPRISE:
return "WPA3_ENTERPRISE";
case WIFI_AUTH_WPA2_WPA3_ENTERPRISE:
return "WPA2_WPA3_ENTERPRISE";
default:
return "UNKNOWN";
}
}
static const char* wifi_reason_str(wifi_err_reason_t reason) {
switch (reason) {
case WIFI_REASON_UNSPECIFIED: return "UNSPECIFIED";
case WIFI_REASON_AUTH_EXPIRE: return "AUTH_EXPIRE";
case WIFI_REASON_AUTH_LEAVE: return "AUTH_LEAVE";
case WIFI_REASON_ASSOC_EXPIRE: return "ASSOC_EXPIRE";
case WIFI_REASON_ASSOC_TOOMANY: return "ASSOC_TOOMANY";
case WIFI_REASON_NOT_AUTHED: return "NOT_AUTHED";
case WIFI_REASON_NOT_ASSOCED: return "NOT_ASSOCED";
case WIFI_REASON_ASSOC_LEAVE: return "ASSOC_LEAVE";
case WIFI_REASON_ASSOC_NOT_AUTHED: return "ASSOC_NOT_AUTHED";
case WIFI_REASON_DISASSOC_PWRCAP_BAD: return "DISASSOC_PWRCAP_BAD";
case WIFI_REASON_DISASSOC_SUPCHAN_BAD: return "DISASSOC_SUPCHAN_BAD";
case WIFI_REASON_IE_INVALID: return "IE_INVALID";
case WIFI_REASON_MIC_FAILURE: return "MIC_FAILURE";
case WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT: return "4WAY_HANDSHAKE_TIMEOUT";
case WIFI_REASON_GROUP_KEY_UPDATE_TIMEOUT: return "GROUP_KEY_UPDATE_TIMEOUT";
case WIFI_REASON_IE_IN_4WAY_DIFFERS: return "IE_IN_4WAY_DIFFERS";
case WIFI_REASON_GROUP_CIPHER_INVALID: return "GROUP_CIPHER_INVALID";
case WIFI_REASON_PAIRWISE_CIPHER_INVALID: return "PAIRWISE_CIPHER_INVALID";
case WIFI_REASON_AKMP_INVALID: return "AKMP_INVALID";
case WIFI_REASON_UNSUPP_RSN_IE_VERSION: return "UNSUPP_RSN_IE_VERSION";
case WIFI_REASON_INVALID_RSN_IE_CAP: return "INVALID_RSN_IE_CAP";
case WIFI_REASON_802_1X_AUTH_FAILED: return "802_1X_AUTH_FAILED";
case WIFI_REASON_CIPHER_SUITE_REJECTED: return "CIPHER_SUITE_REJECTED";
case WIFI_REASON_INVALID_PMKID: return "INVALID_PMKID";
case WIFI_REASON_BEACON_TIMEOUT: return "BEACON_TIMEOUT";
case WIFI_REASON_NO_AP_FOUND: return "NO_AP_FOUND";
case WIFI_REASON_AUTH_FAIL: return "AUTH_FAIL";
case WIFI_REASON_ASSOC_FAIL: return "ASSOC_FAIL";
case WIFI_REASON_HANDSHAKE_TIMEOUT: return "HANDSHAKE_TIMEOUT";
case WIFI_REASON_CONNECTION_FAIL: return "CONNECTION_FAIL";
case WIFI_REASON_AP_TSF_RESET: return "AP_TSF_RESET";
case WIFI_REASON_ROAMING: return "ROAMING";
default: return "UNKNOWN";
}
}
static esp_netif_t *wifi_sta_netif = NULL;
static esp_netif_t *wifi_ap_netif = NULL;
static int s_retry_num = 0;
static esp_err_t wifi_sta_do_disconnect(void);
static void sntp_sync_time_cb(struct timeval *tv)
{
time_t now = 0;
struct tm timeinfo = { 0 };
time(&now);
localtime_r(&now, &timeinfo);
char strftime_buf[64];
strftime(strftime_buf, sizeof(strftime_buf), "%c", &timeinfo);
ESP_LOGI(TAG, "Time synchronized: %s", strftime_buf);
}
static void handler_on_wifi_disconnect(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
{
s_retry_num++;
if (s_retry_num > MAX_RETRY) {
ESP_LOGW(TAG, "WiFi Connect failed %d times, stop reconnect.", s_retry_num);
/* let example_wifi_sta_do_connect() return */
wifi_sta_do_disconnect();
start_reboot_timer(60);
return;
}
wifi_event_sta_disconnected_t *disconn = event_data;
if (disconn->reason == WIFI_REASON_ROAMING) {
ESP_LOGD(TAG, "station roaming, do nothing");
return;
}
ESP_LOGW(TAG, "Wi-Fi disconnected, reason: (%s)", wifi_reason_str(disconn->reason));
ESP_LOGI(TAG, "Trying to reconnect...");
esp_err_t err = esp_wifi_connect();
if (err == ESP_ERR_WIFI_NOT_STARTED) {
return;
}
ESP_ERROR_CHECK(err);
}
static void handler_on_wifi_connect(void *esp_netif, esp_event_base_t event_base,
int32_t event_id, void *event_data)
{
}
static void handler_on_sta_got_ip(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
{
stop_reboot_timer();
s_retry_num = 0;
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
if (strcmp("sta", esp_netif_get_desc(event->esp_netif)) != 0) {
return;
}
ESP_LOGI(TAG, "Got IPv4 event: Interface \"%s\" address: " IPSTR, esp_netif_get_desc(event->esp_netif), IP2STR(&event->ip_info.ip));
ESP_LOGI(TAG, "- IPv4 address: " IPSTR ",", IP2STR(&event->ip_info.ip));
sync_time();
led_set(LED_BLU, BLINK_SOLID);
}
static void wifi_ap_event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data)
{
if (event_id == WIFI_EVENT_AP_STACONNECTED) {
wifi_event_ap_staconnected_t* event = (wifi_event_ap_staconnected_t*) event_data;
ESP_LOGI(TAG, "station "MACSTR" join, AID=%d",
MAC2STR(event->mac), event->aid);
} else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) {
wifi_event_ap_stadisconnected_t* event = (wifi_event_ap_stadisconnected_t*) event_data;
ESP_LOGI(TAG, "station "MACSTR" leave, AID=%d",
MAC2STR(event->mac), event->aid);
}
}
static esp_err_t set_hostname(esp_netif_t* esp_netif, const char *hostname)
{
if (esp_netif_set_hostname(esp_netif, hostname) != ESP_OK) return ESP_FAIL;
return ESP_OK;
}
static void wifi_start(wifi_mode_t mode)
{
size_t hostname_len;
char type_buf[16];
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
if (mode == WIFI_MODE_STA || mode == WIFI_MODE_APSTA) {
esp_netif_inherent_config_t esp_netif_config = ESP_NETIF_INHERENT_DEFAULT_WIFI_STA();
wifi_sta_netif = esp_netif_create_wifi(WIFI_IF_STA, &esp_netif_config);
if (nconfig_read(NETIF_TYPE, type_buf, sizeof(type_buf)) == ESP_OK && strcmp(type_buf, "static") == 0) {
ESP_LOGI(TAG, "Using static IP configuration");
char ip_buf[16], gw_buf[16], mask_buf[16], dns1_buf[16], dns2_buf[16];
nconfig_read(NETIF_IP, ip_buf, sizeof(ip_buf));
nconfig_read(NETIF_GATEWAY, gw_buf, sizeof(gw_buf));
nconfig_read(NETIF_SUBNET, mask_buf, sizeof(mask_buf));
const char* dns1 = (nconfig_read(NETIF_DNS1, dns1_buf, sizeof(dns1_buf)) == ESP_OK) ? dns1_buf : NULL;
const char* dns2 = (nconfig_read(NETIF_DNS2, dns2_buf, sizeof(dns2_buf)) == ESP_OK) ? dns2_buf : NULL;
if (dns1 == NULL)
wifi_use_static(ip_buf, gw_buf, mask_buf, "8.8.8.8", "8.8.4.4");
else
wifi_use_static(ip_buf, gw_buf, mask_buf, dns1, dns2);
} else {
ESP_LOGI(TAG, "Using DHCP configuration");
wifi_use_dhcp();
}
nconfig_get_str_len(NETIF_HOSTNAME, &hostname_len);
char buf[hostname_len];
nconfig_read(NETIF_HOSTNAME, buf, sizeof(buf));
set_hostname(wifi_sta_netif, buf);
}
if (mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA) {
esp_netif_inherent_config_t esp_netif_config_ap = ESP_NETIF_INHERENT_DEFAULT_WIFI_AP();
wifi_ap_netif = esp_netif_create_wifi(WIFI_IF_AP, &esp_netif_config_ap);
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_AP_STACONNECTED, &wifi_ap_event_handler, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_AP_STADISCONNECTED, &wifi_ap_event_handler, NULL));
}
esp_wifi_set_default_wifi_sta_handlers();
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));
ESP_ERROR_CHECK(esp_wifi_set_mode(mode));
ESP_ERROR_CHECK(esp_wifi_start());
}
static void wifi_stop(void)
{
esp_err_t err = esp_wifi_stop();
if (err == ESP_ERR_WIFI_NOT_INIT) {
return;
}
ESP_ERROR_CHECK(err);
if (wifi_ap_netif) {
esp_event_handler_unregister(WIFI_EVENT, WIFI_EVENT_AP_STACONNECTED, &wifi_ap_event_handler);
esp_event_handler_unregister(WIFI_EVENT, WIFI_EVENT_AP_STADISCONNECTED, &wifi_ap_event_handler);
}
ESP_ERROR_CHECK(esp_wifi_deinit());
if (wifi_sta_netif) {
esp_netif_destroy(wifi_sta_netif);
wifi_sta_netif = NULL;
}
if (wifi_ap_netif) {
esp_netif_destroy(wifi_ap_netif);
wifi_ap_netif = NULL;
}
}
static esp_err_t wifi_sta_do_connect(wifi_config_t wifi_config)
{
stop_reboot_timer();
s_retry_num = 0;
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED, handler_on_wifi_disconnect, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, handler_on_sta_got_ip, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_STA_CONNECTED, handler_on_wifi_connect, wifi_sta_netif));
ESP_LOGI(TAG, "Connecting to %s...", (char*)wifi_config.sta.ssid);
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
esp_err_t ret = esp_wifi_connect();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "WiFi connect failed! ret:%x", ret);
return ret;
}
return ESP_OK;
}
static esp_err_t wifi_sta_do_disconnect(void)
{
ESP_ERROR_CHECK(esp_event_handler_unregister(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED, &handler_on_wifi_disconnect));
ESP_ERROR_CHECK(esp_event_handler_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, &handler_on_sta_got_ip));
ESP_ERROR_CHECK(esp_event_handler_unregister(WIFI_EVENT, WIFI_EVENT_STA_CONNECTED, &handler_on_wifi_connect));
led_set(LED_BLU, BLINK_DOUBLE);
return esp_wifi_disconnect();
}
static void wifi_shutdown(void)
{
wifi_sta_do_disconnect();
wifi_stop();
}
static esp_err_t do_connect(void)
{
esp_err_t err;
char mode_buf[16] = {0};
wifi_mode_t mode = WIFI_MODE_STA; // Default mode
if (nconfig_read(WIFI_MODE, mode_buf, sizeof(mode_buf)) == ESP_OK) {
if (strcmp(mode_buf, "apsta") == 0) {
mode = WIFI_MODE_APSTA;
ESP_LOGI(TAG, "Starting in APSTA mode");
} else { // "sta" or anything else defaults to STA
mode = WIFI_MODE_STA;
ESP_LOGI(TAG, "Starting in STA mode");
}
} else {
ESP_LOGI(TAG, "WIFI_MODE not set, defaulting to STA mode");
}
wifi_start(mode);
// Configure and connect STA interface if needed
if (mode == WIFI_MODE_STA || mode == WIFI_MODE_APSTA) {
wifi_config_t sta_config = {0};
bool sta_creds_ok = false;
if (nconfig_read(WIFI_SSID, (char*)sta_config.sta.ssid, 32) == ESP_OK && strlen((char*)sta_config.sta.ssid) > 0) {
if (nconfig_read(WIFI_PASSWORD, (char*)sta_config.sta.password, 64) == ESP_OK) {
sta_creds_ok = true;
}
}
if (sta_creds_ok) {
err = wifi_sta_do_connect(sta_config);
if (err != ESP_OK && mode == WIFI_MODE_STA) {
// In STA-only mode, failure to connect is a fatal error
return err;
}
} else if (mode == WIFI_MODE_STA) {
// In STA-only mode, missing credentials is a fatal error
ESP_LOGE(TAG, "Missing STA credentials in STA mode.");
return ESP_FAIL;
} else {
// In APSTA mode, missing credentials is a warning
ESP_LOGW(TAG, "Missing STA credentials in APSTA mode. STA will not connect.");
}
}
// Configure AP interface if needed
if (mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA) {
char ap_ssid[32], ap_pass[64];
wifi_config_t ap_config = {
.ap = {
.channel = 1,
.max_connection = 4,
.authmode = WIFI_AUTH_WPA2_PSK,
.ssid_hidden = 0,
},
};
if (nconfig_read(AP_SSID, ap_ssid, sizeof(ap_ssid)) == ESP_OK && strlen(ap_ssid) > 0) {
strcpy((char*)ap_config.ap.ssid, ap_ssid);
} else {
strcpy((char*)ap_config.ap.ssid, "ODROID-REMOTE-AP");
}
if (nconfig_read(AP_PASSWORD, ap_pass, sizeof(ap_pass)) == ESP_OK && strlen(ap_pass) >= 8) {
strcpy((char*)ap_config.ap.password, ap_pass);
} else {
ap_config.ap.authmode = WIFI_AUTH_OPEN;
memset(ap_config.ap.password, 0, sizeof(ap_config.ap.password));
}
ap_config.ap.ssid_len = strlen((char*)ap_config.ap.ssid);
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_config));
ESP_LOGI(TAG, "AP configured, SSID: %s", ap_config.ap.ssid);
}
return ESP_OK;
}
esp_err_t wifi_connect(void)
{
led_set(LED_BLU, BLINK_DOUBLE);
static esp_sntp_config_t ntp_cfg = ESP_NETIF_SNTP_DEFAULT_CONFIG_MULTIPLE(3,
ESP_SNTP_SERVER_LIST("time.windows.com", "pool.ntp.org", "216.239.35.0")); // google public ntp
ntp_cfg.start = false;
ntp_cfg.sync_cb = sntp_sync_time_cb;
ntp_cfg.smooth_sync = true; // Sync immediately when started
esp_netif_sntp_init(&ntp_cfg);
if (do_connect() != ESP_OK) {
return ESP_FAIL;
}
ESP_ERROR_CHECK(esp_register_shutdown_handler(&wifi_shutdown));
return ESP_OK;
}
esp_err_t wifi_disconnect(void)
{
wifi_shutdown();
ESP_ERROR_CHECK(esp_unregister_shutdown_handler(&wifi_shutdown));
return ESP_OK;
}
void wifi_scan_aps(wifi_ap_record_t **ap_records, uint16_t* count)
{
ESP_LOGI(TAG, "Starting WiFi scan...");
esp_err_t err = esp_wifi_scan_start(NULL, true);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_wifi_scan_start failed: %s", esp_err_to_name(err));
*count = 0;
*ap_records = NULL;
return;
}
ESP_ERROR_CHECK(esp_wifi_scan_get_ap_num(count));
ESP_LOGI(TAG, "Found %u access points", *count);
if (*count == 0)
*ap_records = NULL;
else
*ap_records = calloc(*count, sizeof(wifi_ap_record_t));
ESP_ERROR_CHECK(esp_wifi_scan_get_ap_records(count, *ap_records));
ESP_LOGI(TAG, "Scan done");
}
esp_err_t wifi_get_current_ap_info(wifi_ap_record_t *ap_info)
{
esp_err_t ret = esp_wifi_sta_get_ap_info(ap_info);
if (ret != ESP_OK) {
// Clear ssid and set invalid rssi on error
memset(ap_info->ssid, 0, sizeof(ap_info->ssid));
ap_info->rssi = -127;
}
return ret;
}
esp_err_t wifi_get_current_ip_info(esp_netif_ip_info_t *ip_info)
{
return esp_netif_get_ip_info(wifi_sta_netif, ip_info);
}
esp_err_t wifi_get_dns_info(esp_netif_dns_type_t type, esp_netif_dns_info_t *dns_info)
{
if (wifi_sta_netif) {
return esp_netif_get_dns_info(wifi_sta_netif, type, dns_info);
}
return ESP_FAIL;
}
esp_err_t wifi_use_static(const char *ip, const char *gw, const char *netmask, const char *dns1, const char *dns2)
{
if (wifi_sta_netif == NULL) {
return ESP_FAIL;
}
esp_err_t err = esp_netif_dhcpc_stop(wifi_sta_netif);
if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_DHCP_NOT_STOPPED) {
ESP_LOGE(TAG, "Failed to stop DHCP client: %s", esp_err_to_name(err));
return err;
}
esp_netif_ip_info_t ip_info;
inet_pton(AF_INET, ip, &ip_info.ip);
inet_pton(AF_INET, gw, &ip_info.gw);
inet_pton(AF_INET, netmask, &ip_info.netmask);
err = esp_netif_set_ip_info(wifi_sta_netif, &ip_info);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to set static IP info: %s", esp_err_to_name(err));
return err;
}
esp_netif_dns_info_t dns_info;
if (dns1 && strlen(dns1) > 0) {
inet_pton(AF_INET, dns1, &dns_info.ip.u_addr.ip4);
esp_netif_set_dns_info(wifi_sta_netif, ESP_NETIF_DNS_MAIN, &dns_info);
} else {
esp_netif_set_dns_info(wifi_sta_netif, ESP_NETIF_DNS_MAIN, NULL);
}
if (dns2 && strlen(dns2) > 0) {
inet_pton(AF_INET, dns2, &dns_info.ip.u_addr.ip4);
esp_netif_set_dns_info(wifi_sta_netif, ESP_NETIF_DNS_BACKUP, &dns_info);
} else {
esp_netif_set_dns_info(wifi_sta_netif, ESP_NETIF_DNS_BACKUP, NULL);
}
return ESP_OK;
}
esp_err_t wifi_use_dhcp(void)
{
if (wifi_sta_netif == NULL) {
return ESP_FAIL;
}
esp_err_t err = esp_netif_dhcpc_start(wifi_sta_netif);
if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) {
ESP_LOGE(TAG, "Failed to start DHCP client: %s", esp_err_to_name(err));
return err;
}
return ESP_OK;
}
esp_err_t wifi_switch_mode(const char* mode)
{
if (strcmp(mode, "sta") != 0 && strcmp(mode, "apsta") != 0) {
ESP_LOGE(TAG, "Invalid mode specified: %s. Use 'sta' or 'apsta'.", mode);
return ESP_ERR_INVALID_ARG;
}
char current_mode_buf[16] = {0};
if (nconfig_read(WIFI_MODE, current_mode_buf, sizeof(current_mode_buf)) == ESP_OK) {
if (strcmp(current_mode_buf, mode) == 0) {
ESP_LOGI(TAG, "Already in %s mode.", mode);
return ESP_OK;
}
}
ESP_LOGI(TAG, "Switching Wi-Fi mode to %s.", mode);
esp_err_t err = nconfig_write(WIFI_MODE, mode);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to save new Wi-Fi mode to NVS");
return err;
}
wifi_disconnect();
err = wifi_connect();
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to connect in new mode %s", mode);
return err;
}
ESP_LOGI(TAG, "Successfully switched to %s mode.", mode);
return ESP_OK;
}
void sync_time()
{
esp_netif_sntp_start();
ESP_LOGI(TAG, "SNTP service started, waiting for time synchronization...");
}

24
page/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

319
page/index.html Normal file
View File

@@ -0,0 +1,319 @@
<!DOCTYPE HTML>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<title>ODROID Remote</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type="module" src="/src/main.js"></script>
</head>
<body>
<main class="container">
<header class="d-flex justify-content-between align-items-center mb-3 main-header">
<div class="order-md-1" style="flex: 1;">
<div class="d-flex align-items-center">
<span id="wifi-status" class="d-flex align-items-center text-muted">
<i id="wifi-icon" class="bi bi-wifi-off me-2"></i>
<span id="wifi-ssid-status">Disconnected</span>
</span>
<span id="websocket-status" class="d-flex align-items-center text-danger ms-3">
<i id="websocket-icon" class="bi bi-x-circle-fill me-2"></i>
<span id="websocket-status-text">Offline</span>
</span>
</div>
<div class="font-monospace mt-1 d-none d-md-inline">
<span id="voltage-display" class="text-primary">--.-- V</span> |
<span id="current-display" class="text-primary">--.-- A</span> |
<span id="power-display" class="text-primary">--.-- W</span>
</div>
</div>
<h1 class="text-primary text-center order-md-2 mx-auto">ODROID Power Mate</h1>
<div class="d-flex align-items-center justify-content-end order-md-3 header-controls" style="flex: 1;">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="theme-toggle">
<label class="form-check-label" for="theme-toggle"><i id="theme-icon" class="bi bi-moon-stars-fill"></i></label>
</div>
<button class="btn btn-outline-secondary ms-3" id="settings-button" data-bs-toggle="modal"
data-bs-target="#settingsModal">
<i class="bi bi-gear"></i>
</button>
</div>
</header>
<ul class="nav nav-tabs" id="main-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="terminal-tab-btn" data-bs-toggle="tab"
data-bs-target="#terminal-tab-pane" type="button" role="tab">Terminal
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="graph-tab-btn" data-bs-toggle="tab" data-bs-target="#graph-tab-pane"
type="button" role="tab">Metrics
</button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="terminal-tab-pane" role="tabpanel">
<div class="card border-top-0 rounded-0 rounded-bottom">
<div class="card-body">
<div id="terminal-wrapper">
<div id="terminal-container" class="border rounded">
<div id="terminal"></div>
</div>
</div>
<div class="d-flex justify-content-end mt-3">
<button id="download-button" class="btn btn-primary me-2"><i class="bi bi-download me-1"></i>Download Log</button>
<button id="clear-button" class="btn btn-secondary">Clear Terminal</button>
</div>
<!-- Moved from Control Tab -->
<div class="mt-4">
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center control-list-item">
Main Power (12V)
<div class="control-wrapper">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="main-power-toggle">
</div>
</div>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center control-list-item">
USB Power (5V)
<div class="control-wrapper">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="usb-power-toggle">
</div>
</div>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center control-list-item">
Power Actions
<div class="control-wrapper">
<button id="reset-button" class="btn btn-secondary">Reset</button>
<button id="power-action-button" class="btn btn-danger ms-2">Power</button>
</div>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
System Uptime
<div class="control-wrapper">
<span id="uptime-display" class="font-monospace text-success">--:--:--</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="graph-tab-pane" role="tabpanel">
<div class="card border-top-0 rounded-0 rounded-bottom">
<div class="card-body">
<div class="d-flex justify-content-end mb-3">
<a href="/datalog.csv" class="btn btn-primary" download="datalog.csv"><i class="bi bi-download me-1"></i> Download CSV</a>
</div>
<h5 class="card-title text-center mb-3">Power Input</h5>
<div class="row">
<div class="col-md-4 mb-3 mb-md-0">
<canvas id="powerChart" class="chart-canvas"></canvas>
</div>
<div class="col-md-4 mb-3 mb-md-0">
<canvas id="voltageChart" class="chart-canvas"></canvas>
</div>
<div class="col-md-4">
<canvas id="currentChart" class="chart-canvas"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="bg-body-tertiary text-center p-3">
<a href="https://www.hardkernel.com/" target="_blank" class="link-secondary">Hardkernel</a> |
<a href="https://wiki.odroid.com/start" target="_blank" class="link-secondary">Wiki</a>
</footer>
<!-- Settings Modal -->
<div class="modal fade" id="settingsModal" tabindex="-1" aria-labelledby="settingsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="settingsModalLabel">Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs" id="settingsTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="wifi-settings-tab" data-bs-toggle="tab"
data-bs-target="#wifi-settings-pane" type="button" role="tab">Wi-Fi
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="network-settings-tab" data-bs-toggle="tab"
data-bs-target="#network-settings-pane" type="button" role="tab">Network
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="ap-mode-settings-tab" data-bs-toggle="tab"
data-bs-target="#ap-mode-settings-pane" type="button" role="tab">AP Mode
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="device-settings-tab" data-bs-toggle="tab"
data-bs-target="#device-settings-pane" type="button" role="tab">Device
</button>
</li>
</ul>
<div class="tab-content pt-3" id="settingsTabContent">
<div class="tab-pane fade show active" id="wifi-settings-pane" role="tabpanel">
<div class="alert alert-danger mt-3" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Warning:</strong> Disconnecting or connecting to a new network will restart the Wi-Fi service. You may need to reconnect to the device.
</div>
<div class="mb-3">
<h6>Current Connection</h6>
<div class="d-flex justify-content-between align-items-center p-2 rounded bg-body-secondary">
<div>
<strong>Connected to: </strong><span id="current-wifi-ssid">MyHome_WiFi</span><br>
<small class="text-muted" id="current-wifi-ip">IP Address: -</small>
</div>
<!-- <button class="btn btn-warning btn-sm" id="wifi-disconnect-button">Disconnect</button>-->
</div>
</div>
<hr>
<h6>Available Wi-Fi Networks</h6>
<div class="table-responsive" style="max-height: 200px;">
<table class="table table-hover table-sm">
<thead>
<tr>
<th scope="col">SSID</th>
<th scope="col">Signal</th>
<th scope="col">Security</th>
</tr>
</thead>
<tbody id="wifi-ap-list">
<tr><td colspan="3" class="text-center text-muted">Click 'Scan' to find networks.</td></tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-end pt-3 border-top mt-3">
<button type="button" class="btn btn-primary me-2" id="scan-wifi-button">Scan</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
<div class="tab-pane fade" id="network-settings-pane" role="tabpanel">
<div class="alert alert-danger mt-3" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Warning:</strong> Applying these settings will restart the Wi-Fi service. You may need to reconnect to the device.
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="static-ip-toggle">
<label class="form-check-label" for="static-ip-toggle">Use Static IP</label>
</div>
<div id="static-ip-config" style="display: none;">
<div class="mb-3">
<label for="ip-address" class="form-label">IP Address</label>
<input type="text" class="form-control" id="ip-address" placeholder="192.168.0.100">
</div>
<div class="mb-3">
<label for="gateway" class="form-label">Gateway</label>
<input type="text" class="form-control" id="gateway" placeholder="192.168.0.1">
</div>
<div class="mb-3">
<label for="netmask" class="form-label">Netmask</label>
<input type="text" class="form-control" id="netmask" placeholder="255.255.255.0">
</div>
<div class="mb-3">
<label for="dns1" class="form-label">DNS Server</label>
<input type="text" class="form-control" id="dns1" placeholder="8.8.8.8">
</div>
<div class="mb-3">
<label for="dns2" class="form-label">Secondary DNS Server</label>
<input type="text" class="form-control" id="dns2" placeholder="8.8.4.4">
</div>
</div>
<div class="d-flex justify-content-end pt-3 border-top mt-3">
<button type="button" class="btn btn-primary me-2" id="network-apply-button">Apply</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
<div class="tab-pane fade" id="ap-mode-settings-pane" role="tabpanel">
<div class="alert alert-danger mt-3" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Warning:</strong> Applying these settings will restart the Wi-Fi service. You may need to reconnect to the device.
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="ap-mode-toggle">
<label class="form-check-label" for="ap-mode-toggle">Enable AP+STA Mode</label>
</div>
<p class="text-muted small">Disable to use Station (STA) mode only. Enable to use Access Point + Station (APSTA) mode, allowing other devices to connect to this one.</p>
<div id="ap-mode-config" style="display: none;">
<div class="mb-3">
<label for="ap-ssid" class="form-label">AP SSID</label>
<input type="text" class="form-control" id="ap-ssid" placeholder="ODROID-Remote-AP">
</div>
<div class="mb-3">
<label for="ap-password" class="form-label">AP Password</label>
<input type="password" class="form-control" id="ap-password" placeholder="Leave blank for open network">
</div>
</div>
<div class="d-flex justify-content-end pt-3 border-top mt-3">
<button type="button" class="btn btn-primary me-2" id="ap-mode-apply-button">Apply</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
<div class="tab-pane fade" id="device-settings-pane" role="tabpanel">
<div class="mb-3">
<label for="baud-rate-select" class="form-label">UART Baud Rate</label>
<select class="form-select" id="baud-rate-select">
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="115200">115200</option>
<option value="230400">230400</option>
<option value="460800">460800</option>
<option value="921600">921600</option>
<option value="1500000" selected>1500000</option>
</select>
</div>
<div class="d-flex justify-content-end pt-3 border-top mt-3">
<button type="button" class="btn btn-primary me-2" id="baud-rate-apply-button">Apply</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Wi-Fi Connection Modal -->
<div class="modal fade" id="wifiModal" tabindex="-1" aria-labelledby="wifiModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="wifiModalLabel">Connect to Wi-Fi</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="wifi-ssid-connect" class="form-label">SSID</label>
<input type="text" class="form-control" id="wifi-ssid-connect" readonly>
</div>
<div class="mb-3">
<label for="wifi-password-connect" class="form-label">Password</label>
<input type="password" class="form-control" id="wifi-password-connect">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="wifi-connect-button">Connect</button>
</div>
</div>
</div>
</div>
</body>
</html>

1283
page/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
page/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "odroid-remote-page",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^7.0.4",
"vite-plugin-singlefile": "^2.0.1",
"vite-plugin-compression": "^0.5.1"
},
"dependencies": {
"@xterm/xterm": "^5.5.0",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"@xterm/addon-fit": "^0.9.0",
"chart.js": "^4.4.3"
}
}

112
page/src/api.js Normal file
View File

@@ -0,0 +1,112 @@
/**
* @file api.js
* @description This module centralizes all API calls to the server's RESTful endpoints.
* It abstracts the fetch logic, error handling, and JSON parsing for network and control operations.
*/
/**
* Fetches the list of available Wi-Fi networks from the server.
* @returns {Promise<Array<Object>>} A promise that resolves to an array of Wi-Fi access point objects.
* @throws {Error} Throws an error if the network request fails.
*/
export async function fetchWifiScan() {
const response = await fetch('/api/wifi/scan');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
}
/**
* Sends a request to connect to a specific Wi-Fi network.
* @param {string} ssid The SSID of the network to connect to.
* @param {string} password The password for the network.
* @returns {Promise<Object>} A promise that resolves to the server's JSON response.
* @throws {Error} Throws an error if the connection request fails.
*/
export async function postWifiConnect(ssid, password) {
const response = await fetch('/api/setting', { // Updated URL
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, password }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Connection failed with status: ${response.status}`);
}
return await response.json();
}
/**
* Posts updated network settings (e.g., static IP, DHCP, AP mode) to the server.
* @param {Object} payload The settings object to be sent.
* @returns {Promise<Response>} A promise that resolves to the raw fetch response.
* @throws {Error} Throws an error if the request fails.
*/
export async function postNetworkSettings(payload) {
const response = await fetch('/api/setting', { // Updated URL
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Failed to apply settings with status: ${response.status}`);
}
return response;
}
/**
* Posts the selected UART baud rate to the server.
* @param {string} baudrate The selected baud rate.
* @returns {Promise<Response>} A promise that resolves to the raw fetch response.
* @throws {Error} Throws an error if the request fails.
*/
export async function postBaudRateSetting(baudrate) {
const response = await fetch('/api/setting', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baudrate }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Failed to apply baudrate with status: ${response.status}`);
}
return response;
}
/**
* Fetches the current network settings and Wi-Fi status from the server.
* @returns {Promise<Object>} A promise that resolves to an object containing the current settings.
* @throws {Error} Throws an error if the network request fails.
*/
export async function fetchSettings() {
const response = await fetch('/api/setting'); // Updated URL
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
}
/**
* Fetches the current status of the power control relays (12V and 5V).
* @returns {Promise<Object>} A promise that resolves to an object with the power status.
* @throws {Error} Throws an error if the network request fails.
*/
export async function fetchControlStatus() {
const response = await fetch('/api/control');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
}
/**
* Sends a command to the server to control power functions (e.g., toggle relays, trigger reset).
* @param {Object} command The control command object.
* @returns {Promise<Response>} A promise that resolves to the raw fetch response.
* @throws {Error} Throws an error if the request fails.
*/
export async function postControlCommand(command) {
const response = await fetch('/api/control', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(command)
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response;
}

211
page/src/chart.js Normal file
View File

@@ -0,0 +1,211 @@
/**
* @file chart.js
* @description This module manages the Chart.js instances for visualizing sensor data.
* It handles initialization, theme updates, data updates, and resizing for the three separate charts.
*/
import { Chart, registerables } from 'chart.js';
import { powerChartCtx, voltageChartCtx, currentChartCtx, htmlEl, graphTabPane } from './dom.js';
// Register all necessary Chart.js components
Chart.register(...registerables);
// Store chart instances in an object
export const charts = {
power: null,
voltage: null,
current: null
};
const CHART_DATA_POINTS = 30; // Number of data points to display on the chart
/**
* Creates an array of empty labels for initial chart rendering.
* @returns {Array<string>} An array of empty strings.
*/
function initialLabels() {
return Array(CHART_DATA_POINTS).fill('');
}
/**
* Creates an array of null data points for initial chart rendering.
* @returns {Array<null>} An array of nulls.
*/
function initialData() {
return Array(CHART_DATA_POINTS).fill(null);
}
/**
* Creates a common configuration object for a single line chart.
* @param {string} title - The title of the chart (e.g., 'Power (W)').
* @returns {Object} A Chart.js options object.
*/
function createChartOptions(title) {
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top' },
title: { display: true, text: title }
},
scales: {
x: { ticks: { autoSkipPadding: 10, maxRotation: 0, minRotation: 0 } },
y: { }
}
};
}
/**
* Initializes all three charts (Power, Voltage, Current).
* If chart instances already exist, they are destroyed and new ones are created.
*/
export function initCharts() {
// Destroy existing charts if they exist
for (const key in charts) {
if (charts[key]) {
charts[key].destroy();
}
}
// Create Power Chart
if (powerChartCtx) {
charts.power = new Chart(powerChartCtx, {
type: 'line',
data: {
labels: initialLabels(),
datasets: [
{ label: 'Power (W)', data: initialData(), borderWidth: 2, fill: false, tension: 0.2, pointRadius: 2 },
{ label: 'Avg Power', data: initialData(), borderWidth: 1.5, borderDash: [10, 5], fill: false, tension: 0, pointRadius: 0 }
]
},
options: createChartOptions('Power')
});
}
// Create Voltage Chart
if (voltageChartCtx) {
charts.voltage = new Chart(voltageChartCtx, {
type: 'line',
data: {
labels: initialLabels(),
datasets: [
{ label: 'Voltage (V)', data: initialData(), borderWidth: 2, fill: false, tension: 0.2, pointRadius: 2 },
{ label: 'Avg Voltage', data: initialData(), borderWidth: 1.5, borderDash: [10, 5], fill: false, tension: 0, pointRadius: 0 }
]
},
options: createChartOptions('Voltage')
});
}
// Create Current Chart
if (currentChartCtx) {
charts.current = new Chart(currentChartCtx, {
type: 'line',
data: {
labels: initialLabels(),
datasets: [
{ label: 'Current (A)', data: initialData(), borderWidth: 2, fill: false, tension: 0.2, pointRadius: 2 },
{ label: 'Avg Current', data: initialData(), borderWidth: 1.5, borderDash: [10, 5], fill: false, tension: 0, pointRadius: 0 }
]
},
options: createChartOptions('Current')
});
}
}
/**
* Applies a new theme (light or dark) to all charts.
* @param {string} themeName - The name of the theme to apply ('light' or 'dark').
*/
export function applyChartsTheme(themeName) {
const isDark = themeName === 'dark';
const gridColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
const labelColor = isDark ? '#dee2e6' : '#212529';
const powerColor = getComputedStyle(htmlEl).getPropertyValue('--chart-power-color');
const voltageColor = getComputedStyle(htmlEl).getPropertyValue('--chart-voltage-color');
const currentColor = getComputedStyle(htmlEl).getPropertyValue('--chart-current-color');
const updateThemeForChart = (chart, color) => {
if (!chart) return;
chart.options.scales.x.grid.color = gridColor;
chart.options.scales.y.grid.color = gridColor;
chart.options.scales.x.ticks.color = labelColor;
chart.options.scales.y.ticks.color = labelColor;
chart.options.plugins.legend.labels.color = labelColor;
chart.options.plugins.title.color = labelColor;
chart.data.datasets[0].borderColor = color;
chart.data.datasets[1].borderColor = color;
chart.data.datasets[1].borderDash = [10, 5];
chart.update('none');
};
updateThemeForChart(charts.power, powerColor);
updateThemeForChart(charts.voltage, voltageColor);
updateThemeForChart(charts.current, currentColor);
}
/**
* Updates all charts with new sensor data.
* @param {Object} data - The new sensor data object from the WebSocket.
*/
export function updateCharts(data) {
const timeLabel = new Date(data.timestamp * 1000).toLocaleTimeString();
const updateSingleChart = (chart, value) => {
if (!chart) return;
// Shift old data
chart.data.labels.shift();
chart.data.datasets.forEach(dataset => dataset.data.shift());
// Push new data
chart.data.labels.push(timeLabel);
chart.data.datasets[0].data.push(value.toFixed(2));
// Calculate average and adjust Y-axis scale
const dataArray = chart.data.datasets[0].data.filter(v => v !== null).map(v => parseFloat(v));
if (dataArray.length > 0) {
const sum = dataArray.reduce((acc, val) => acc + val, 0);
const avg = (sum / dataArray.length).toFixed(2);
chart.data.datasets[1].data.push(avg);
// Adjust Y-axis scale for centering
const minVal = Math.min(...dataArray);
const maxVal = Math.max(...dataArray);
let padding = (maxVal - minVal) * 0.1; // 10% padding of the range
if (padding === 0) {
// If all values are the same, add 10% padding of the value itself, or a small default
padding = maxVal > 0 ? maxVal * 0.1 : 0.1;
}
chart.options.scales.y.min = Math.max(0, minVal - padding);
chart.options.scales.y.max = maxVal + padding;
} else {
chart.data.datasets[1].data.push(null);
}
// Only update the chart if the tab is visible
if (graphTabPane.classList.contains('show')) {
chart.update('none');
}
};
updateSingleChart(charts.power, data.power);
updateSingleChart(charts.voltage, data.voltage);
updateSingleChart(charts.current, data.current);
}
/**
* Resizes all chart canvases. This is typically called on window resize events.
*/
export function resizeCharts() {
for (const key in charts) {
if (charts[key]) {
charts[key].resize();
}
}
}

78
page/src/dom.js Normal file
View File

@@ -0,0 +1,78 @@
/**
* @file dom.js
* @description This module selects and exports all necessary DOM elements for the application.
* This centralizes DOM access, making it easier to manage and modify element selectors.
*/
// --- Theme Elements ---
export const themeToggle = document.getElementById('theme-toggle');
export const themeIcon = document.getElementById('theme-icon');
export const htmlEl = document.documentElement;
// --- Control & Status Elements ---
export const mainPowerToggle = document.getElementById('main-power-toggle');
export const usbPowerToggle = document.getElementById('usb-power-toggle');
export const resetButton = document.getElementById('reset-button');
export const powerActionButton = document.getElementById('power-action-button');
export const voltageDisplay = document.getElementById('voltage-display');
export const currentDisplay = document.getElementById('current-display');
export const powerDisplay = document.getElementById('power-display');
export const uptimeDisplay = document.getElementById('uptime-display');
// --- Terminal Elements ---
export const terminalContainer = document.getElementById('terminal');
export const clearButton = document.getElementById('clear-button');
export const downloadButton = document.getElementById('download-button');
// --- Chart & Graph Elements ---
export const graphTabPane = document.getElementById('graph-tab-pane');
export const powerChartCtx = document.getElementById('powerChart')?.getContext('2d');
export const voltageChartCtx = document.getElementById('voltageChart')?.getContext('2d');
export const currentChartCtx = document.getElementById('currentChart')?.getContext('2d');
// --- WebSocket Status Elements ---
export const websocketStatus = document.getElementById('websocket-status');
export const websocketIcon = document.getElementById('websocket-icon');
export const websocketStatusText = document.getElementById('websocket-status-text');
// --- Header Wi-Fi Status ---
export const wifiStatus = document.getElementById('wifi-status');
export const wifiIcon = document.getElementById('wifi-icon');
export const wifiSsidStatus = document.getElementById('wifi-ssid-status');
// --- Settings Modal & General ---
export const settingsButton = document.getElementById('settings-button');
export const settingsModal = document.getElementById('settingsModal');
// --- Wi-Fi Settings Elements ---
export const wifiApList = document.getElementById('wifi-ap-list');
export const scanWifiButton = document.getElementById('scan-wifi-button');
export const currentWifiSsid = document.getElementById('current-wifi-ssid');
export const currentWifiIp = document.getElementById('current-wifi-ip');
// --- Wi-Fi Connection Modal Elements ---
export const wifiModalEl = document.getElementById('wifiModal');
export const wifiSsidConnectInput = document.getElementById('wifi-ssid-connect');
export const wifiPasswordConnectInput = document.getElementById('wifi-password-connect');
export const wifiConnectButton = document.getElementById('wifi-connect-button');
// --- Static IP Config Elements ---
export const staticIpToggle = document.getElementById('static-ip-toggle');
export const staticIpConfig = document.getElementById('static-ip-config');
export const networkApplyButton = document.getElementById('network-apply-button');
export const staticIpInput = document.getElementById('ip-address');
export const staticGatewayInput = document.getElementById('gateway');
export const staticNetmaskInput = document.getElementById('netmask');
export const dns1Input = document.getElementById('dns1');
export const dns2Input = document.getElementById('dns2');
// --- AP Mode Config Elements ---
export const apModeToggle = document.getElementById('ap-mode-toggle');
export const apModeConfig = document.getElementById('ap-mode-config');
export const apModeApplyButton = document.getElementById('ap-mode-apply-button');
export const apSsidInput = document.getElementById('ap-ssid');
export const apPasswordInput = document.getElementById('ap-password');
// --- Device Settings Elements ---
export const baudRateSelect = document.getElementById('baud-rate-select');
export const baudRateApplyButton = document.getElementById('baud-rate-apply-button');

93
page/src/events.js Normal file
View File

@@ -0,0 +1,93 @@
/**
* @file events.js
* @description This module sets up all the event listeners for the application.
* It connects user interactions (like clicks and toggles) to the corresponding
* functions in other modules (UI, API, etc.).
*/
import * as dom from './dom.js';
import * as api from './api.js';
import * as ui from './ui.js';
import { clearTerminal, fitTerminal, downloadTerminalOutput } from './terminal.js';
import { debounce, isMobile } from './utils.js';
// A flag to track if charts have been initialized
let chartsInitialized = false;
/**
* Sets up all event listeners for the application's interactive elements.
*/
export function setupEventListeners() {
// --- Theme Toggle ---
dom.themeToggle.addEventListener('change', () => {
const newTheme = dom.themeToggle.checked ? 'dark' : 'light';
localStorage.setItem('theme', newTheme);
ui.applyTheme(newTheme);
});
// --- Terminal Controls ---
dom.clearButton.addEventListener('click', clearTerminal);
dom.downloadButton.addEventListener('click', downloadTerminalOutput);
// --- Power Controls ---
dom.mainPowerToggle.addEventListener('change', () => api.postControlCommand({'load_12v_on': dom.mainPowerToggle.checked}).then(ui.updateControlStatus));
dom.usbPowerToggle.addEventListener('change', () => api.postControlCommand({'load_5v_on': dom.usbPowerToggle.checked}).then(ui.updateControlStatus));
dom.resetButton.addEventListener('click', () => api.postControlCommand({'reset_trigger': true}));
dom.powerActionButton.addEventListener('click', () => api.postControlCommand({'power_trigger': true}));
// --- Settings Modal Controls ---
dom.scanWifiButton.addEventListener('click', ui.scanForWifi);
dom.wifiConnectButton.addEventListener('click', ui.connectToWifi);
dom.networkApplyButton.addEventListener('click', ui.applyNetworkSettings);
dom.apModeApplyButton.addEventListener('click', ui.applyApModeSettings);
dom.baudRateApplyButton.addEventListener('click', ui.applyBaudRateSettings);
// --- Settings Modal Toggles (for showing/hiding sections) ---
dom.apModeToggle.addEventListener('change', () => {
dom.apModeConfig.style.display = dom.apModeToggle.checked ? 'block' : 'none';
});
dom.staticIpToggle.addEventListener('change', () => {
dom.staticIpConfig.style.display = dom.staticIpToggle.checked ? 'block' : 'none';
});
// --- General App Listeners ---
dom.settingsButton.addEventListener('click', ui.initializeSettings);
// --- Accessibility: Remove focus from modal elements before hiding ---
const blurActiveElement = () => {
if (document.activeElement && typeof document.activeElement.blur === 'function') {
document.activeElement.blur();
}
};
dom.settingsModal.addEventListener('hide.bs.modal', blurActiveElement);
dom.wifiModalEl.addEventListener('hide.bs.modal', blurActiveElement);
// --- Bootstrap Tab Events ---
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(tabEl => {
tabEl.addEventListener('shown.bs.tab', async (event) => {
const tabId = event.target.getAttribute('data-bs-target');
if (tabId === '#graph-tab-pane') {
// Dynamically import the chart module only when the tab is shown
const chartModule = await import('./chart.js');
if (!chartsInitialized) {
chartModule.initCharts();
chartsInitialized = true;
} else {
chartModule.resizeCharts();
}
} else if (tabId === '#terminal-tab-pane') {
// Fit the terminal when its tab is shown, especially for mobile.
if (isMobile()) {
fitTerminal();
}
}
});
});
// --- Window Resize Event ---
// Debounced to avoid excessive calls during resizing.
window.addEventListener('resize', debounce(ui.handleResize, 150));
}

109
page/src/main.js Normal file
View File

@@ -0,0 +1,109 @@
/**
* @file main.js
* @description The main entry point for the web application.
* This file imports all necessary modules, sets up the application structure,
* initializes components, and establishes the WebSocket connection.
*/
// --- Stylesheets ---
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.css';
import './style.css';
// --- Module Imports ---
import { initWebSocket } from './websocket.js';
import { setupTerminal, term } from './terminal.js';
import {
applyTheme,
initUI,
updateControlStatus,
updateSensorUI,
updateWifiStatusUI,
updateWebsocketStatus
} from './ui.js';
import { setupEventListeners } from './events.js';
// --- WebSocket Event Handlers ---
/**
* Callback function for when the WebSocket connection is successfully opened.
* Updates the UI to show an 'Online' status and fetches the initial control status.
*/
function onWsOpen() {
updateWebsocketStatus(true);
if (term) {
term.write('\x1b[32mConnected to WebSocket Server\x1b[0m\r\n');
}
updateControlStatus();
}
/**
* Callback function for when the WebSocket connection is closed.
* Updates the UI to show an 'Offline' status and attempts to reconnect after a delay.
*/
function onWsClose() {
updateWebsocketStatus(false);
if (term) {
term.write('\r\n\x1b[31mConnection closed. Reconnecting...\x1b[0m\r\n');
}
// Attempt to re-establish the connection after 2 seconds
setTimeout(initialize, 2000);
}
/**
* Callback function for when a message is received from the WebSocket server.
* It handles both JSON messages (for sensor and status updates) and binary data (for the terminal).
* @param {MessageEvent} event - The WebSocket message event.
*/
function onWsMessage(event) {
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data);
if (message.type === 'sensor_data') {
updateSensorUI(message);
} else if (message.type === 'wifi_status') {
updateWifiStatusUI(message);
}
} catch (e) {
// Ignore non-JSON string messages
}
} else if (term && event.data instanceof ArrayBuffer) {
// Write raw UART data to the terminal
const data = new Uint8Array(event.data);
term.write(data);
}
}
// --- Application Initialization ---
/**
* Initializes the entire application.
* This function sets up the UI, theme, terminal, chart, WebSocket connection, and event listeners.
*/
function initialize() {
// Initialize basic UI components
initUI();
// Set up the interactive components first
setupTerminal();
// Apply the saved theme or detect the user's preferred theme.
// This must be done AFTER the chart and terminal are initialized.
const savedTheme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
applyTheme(savedTheme);
// Establish the WebSocket connection with the defined handlers
initWebSocket({
onOpen: onWsOpen,
onClose: onWsClose,
onMessage: onWsMessage
});
// Attach all event listeners to the DOM elements
setupEventListeners();
}
// --- Start Application ---
// Wait for the DOM to be fully loaded before initializing the application.
document.addEventListener('DOMContentLoaded', initialize);

153
page/src/style.css Normal file
View File

@@ -0,0 +1,153 @@
:root {
--bs-body-font-family: 'Courier New', Courier, monospace;
--chart-power-color: #007bff;
--chart-voltage-color: #28a745;
--chart-current-color: #ffc107;
}
[data-bs-theme="dark"] {
--chart-power-color: #569cd6;
--chart-voltage-color: #4ec9b0;
--chart-current-color: #dcdcaa;
}
body, .card, .modal-content, .list-group-item, .nav-tabs .nav-link {
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
html, body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
padding-top: 1rem;
}
main {
flex-grow: 1;
padding-bottom: 2rem;
}
#terminal-tab-pane .card-body {
text-align: center;
}
#terminal-container {
padding: 0.5rem;
background-color: var(--bs-body-bg);
overflow: auto;
}
#terminal-wrapper {
display: inline-block;
text-align: left;
/* Removed height: 100% to allow shrink-wrapping */
}
#graph-tab-pane .card-body {
display: flex;
flex-direction: column;
justify-content: center; /* Vertically center the chart content */
}
.xterm .xterm-viewport {
background-color: transparent !important;
}
.xterm-viewport::-webkit-scrollbar {
width: 12px;
}
.xterm-viewport::-webkit-scrollbar-track {
background: var(--bs-tertiary-bg);
}
.xterm-viewport::-webkit-scrollbar-thumb {
background-color: var(--bs-secondary-bg);
border-radius: 10px;
border: 3px solid var(--bs-tertiary-bg);
}
.xterm-viewport::-webkit-scrollbar-thumb:hover {
background-color: var(--bs-secondary-color);
}
.xterm-viewport {
scrollbar-width: thin;
scrollbar-color: var(--bs-secondary-bg) var(--bs-tertiary-bg);
}
.chart-canvas {
height: 30rem !important;
}
.control-wrapper {
min-height: 38px; /* Match button height for alignment */
display: flex;
align-items: center;
justify-content: flex-end;
}
.control-wrapper .form-switch {
display: flex;
align-items: center;
height: 100%;
}
footer {
flex-shrink: 0;
font-size: 0.9em;
}
footer a {
text-decoration: none;
}
.wifi-ap-row {
cursor: pointer;
}
/* Mobile Optimizations */
@media (max-width: 767.98px) {
.main-header {
flex-direction: column;
}
.header-controls {
width: 100%;
justify-content: space-between !important;
margin-top: 0.5rem;
}
.main-header h1 {
font-size: 1.75rem;
}
.control-list-item {
flex-direction: column;
align-items: flex-start !important;
}
.control-list-item > .control-wrapper {
margin-top: 0.5rem;
width: 100%;
display: flex;
justify-content: space-between;
}
.control-list-item > .control-wrapper > .btn {
flex-grow: 1;
}
#terminal-wrapper, #terminal-container {
width: 100%;
/* Removed height: 100% */
}
#terminal-tab-pane .card-body {
padding: 0;
}
}

135
page/src/terminal.js Normal file
View File

@@ -0,0 +1,135 @@
/**
* @file terminal.js
* @description This module manages the Xterm.js terminal instance, including setup,
* theme handling, and data communication with the WebSocket.
*/
import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
import { FitAddon } from '@xterm/addon-fit';
import { terminalContainer } from './dom.js';
import { isMobile } from './utils.js';
import { websocket, sendWebsocketMessage } from './websocket.js';
// Exported terminal instance and addon for global access
export let term;
export let fitAddon;
// Theme definitions for the terminal
const lightTheme = {
background: 'transparent',
foreground: '#212529',
cursor: '#212529',
selectionBackground: 'rgba(0,0,0,0.1)'
};
const darkTheme = {
background: 'transparent',
foreground: '#dee2e6',
cursor: '#dee2e6',
selectionBackground: 'rgba(255,255,255,0.1)'
};
/**
* Initializes the Xterm.js terminal, loads addons, and attaches it to the DOM.
* Sets up the initial size and the data handler for sending input to the WebSocket.
*/
export function setupTerminal() {
// Ensure the container is empty before creating a new terminal
while (terminalContainer.firstChild) {
terminalContainer.removeChild(terminalContainer.firstChild);
}
fitAddon = new FitAddon();
term = new Terminal({ convertEol: true, cursorBlink: true });
term.loadAddon(fitAddon);
term.open(terminalContainer);
// Adjust terminal size based on device type
if (isMobile()) {
fitAddon.fit();
} else {
term.resize(80, 24); // Default size for desktop
}
// Handle user input and send it over the WebSocket
term.onData(data => {
sendWebsocketMessage(data);
});
}
/**
* Applies a new theme (light or dark) to the terminal.
* @param {string} themeName - The name of the theme to apply ('light' or 'dark').
*/
export function applyTerminalTheme(themeName) {
if (!term) return;
const isDark = themeName === 'dark';
term.options.theme = isDark ? darkTheme : lightTheme;
}
/**
* Clears the terminal screen.
*/
export function clearTerminal() {
if (term) {
term.clear();
}
}
/**
* Fits the terminal to the size of its container element.
* Useful for responsive adjustments on window resize.
*/
export function fitTerminal() {
if (fitAddon) {
fitAddon.fit();
}
}
/**
* Gathers all text from the terminal buffer and downloads it as a .log file.
*/
export function downloadTerminalOutput() {
if (!term) {
console.error("Terminal is not initialized.");
return;
}
const buffer = term.buffer.active;
let fullText = '';
// Iterate through the buffer to get all lines
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) {
fullText += line.translateToString() + '\n';
}
}
if (fullText.trim() === '') {
console.warn("Terminal is empty, nothing to download.");
// Optionally, provide user feedback here (e.g., a toast notification)
return;
}
// Create a blob from the text content
const blob = new Blob([fullText], { type: 'text/plain;charset=utf-8' });
// Create a link element to trigger the download
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// Generate a filename with a timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
link.download = `odroid-log-${timestamp}.log`;
link.href = url;
// Append to the document, click, and then remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the object URL
URL.revokeObjectURL(url);
}

346
page/src/ui.js Normal file
View File

@@ -0,0 +1,346 @@
/**
* @file ui.js
* @description This module manages all UI interactions and updates.
* It bridges the gap between backend data (from API and WebSockets) and the user-facing components,
* handling everything from theme changes to dynamic content updates.
*/
import * as bootstrap from 'bootstrap';
import * as dom from './dom.js';
import * as api from './api.js';
import { formatUptime, isMobile } from './utils.js';
import { applyTerminalTheme, fitTerminal } from './terminal.js';
import { applyChartsTheme, resizeCharts, updateCharts } from './chart.js';
// Instance of the Bootstrap Modal for Wi-Fi connection
let wifiModal;
/**
* Initializes the UI components, such as the Bootstrap modal.
*/
export function initUI() {
wifiModal = new bootstrap.Modal(dom.wifiModalEl);
}
/**
* Applies the selected theme (light or dark) to the entire application.
* @param {string} themeName - The name of the theme ('light' or 'dark').
*/
export function applyTheme(themeName) {
const isDark = themeName === 'dark';
dom.htmlEl.setAttribute('data-bs-theme', themeName);
dom.themeIcon.className = isDark ? 'bi bi-moon-stars-fill' : 'bi bi-sun-fill';
dom.themeToggle.checked = isDark;
applyTerminalTheme(themeName);
applyChartsTheme(themeName);
}
/**
* Updates the UI with the latest sensor data.
* @param {Object} data - The sensor data object from the WebSocket.
*/
export function updateSensorUI(data) {
dom.voltageDisplay.textContent = `${data.voltage.toFixed(2)} V`;
dom.currentDisplay.textContent = `${data.current.toFixed(2)} A`;
dom.powerDisplay.textContent = `${data.power.toFixed(2)} W`;
if (data.uptime_sec !== undefined) {
dom.uptimeDisplay.textContent = formatUptime(data.uptime_sec);
}
updateCharts(data);
}
/**
* Updates the Wi-Fi status indicator in the header.
* @param {Object} data - The Wi-Fi status object from the WebSocket.
*/
export function updateWifiStatusUI(data) {
if (data.connected) {
dom.wifiSsidStatus.textContent = data.ssid;
dom.wifiStatus.title = `Signal Strength: ${data.rssi} dBm`;
let iconClass = 'bi me-2 ';
if (data.rssi >= -60) iconClass += 'bi-wifi';
else if (data.rssi >= -75) iconClass += 'bi-wifi-2';
else iconClass += 'bi-wifi-1';
dom.wifiIcon.className = iconClass;
dom.wifiStatus.classList.replace('text-muted', 'text-success');
dom.wifiStatus.classList.remove('text-danger');
} else {
dom.wifiSsidStatus.textContent = 'Disconnected';
dom.wifiStatus.title = '';
dom.wifiIcon.className = 'bi bi-wifi-off me-2';
dom.wifiStatus.classList.replace('text-success', 'text-muted');
dom.wifiStatus.classList.remove('text-danger');
}
}
/**
* Initiates a Wi-Fi scan and updates the settings modal with the results.
*/
export async function scanForWifi() {
dom.scanWifiButton.disabled = true;
dom.scanWifiButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Scanning...`;
dom.wifiApList.innerHTML = '<tr><td colspan="3" class="text-center">Scanning for networks...</td></tr>';
try {
const apRecords = await api.fetchWifiScan();
dom.wifiApList.innerHTML = ''; // Clear loading message
if (apRecords.length === 0) {
dom.wifiApList.innerHTML = '<tr><td colspan="3" class="text-center">No networks found.</td></tr>';
} else {
apRecords.forEach(ap => {
const row = document.createElement('tr');
row.className = 'wifi-ap-row';
let rssiIcon;
if (ap.rssi >= -60) rssiIcon = 'bi-wifi';
else if (ap.rssi >= -75) rssiIcon = 'bi-wifi-2';
else rssiIcon = 'bi-wifi-1';
row.innerHTML = `
<td>${ap.ssid}</td>
<td class="text-center"><i class="bi ${rssiIcon}"></i></td>
<td>${ap.authmode}</td>
`;
row.addEventListener('click', () => {
dom.wifiSsidConnectInput.value = ap.ssid;
dom.wifiPasswordConnectInput.value = '';
wifiModal.show();
dom.wifiModalEl.addEventListener('shown.bs.modal', () => {
dom.wifiPasswordConnectInput.focus();
}, { once: true });
});
dom.wifiApList.appendChild(row);
});
}
} catch (error) {
console.error('Error scanning for Wi-Fi:', error);
dom.wifiApList.innerHTML = `<tr><td colspan="3" class="text-center text-danger">Scan failed: ${error.message}</td></tr>`;
} finally {
dom.scanWifiButton.disabled = false;
dom.scanWifiButton.innerHTML = 'Scan';
}
}
/**
* Handles the Wi-Fi connection process, sending credentials to the server.
*/
export async function connectToWifi() {
const ssid = dom.wifiSsidConnectInput.value;
const password = dom.wifiPasswordConnectInput.value;
if (!ssid) return;
dom.wifiConnectButton.disabled = true;
dom.wifiConnectButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Connecting...`;
try {
const result = await api.postWifiConnect(ssid, password);
if (result.status === 'connection_initiated') {
wifiModal.hide();
setTimeout(() => {
alert(`Connection to "${ssid}" initiated. The device will try to reconnect. Please check the Wi-Fi status icon.`);
}, 500);
} else {
throw new Error(result.message || 'Unknown server response.');
}
} catch (error) {
console.error('Error connecting to Wi-Fi:', error);
alert(`Failed to connect: ${error.message}`);
} finally {
dom.wifiConnectButton.disabled = false;
dom.wifiConnectButton.innerHTML = 'Connect';
}
}
/**
* Applies network settings (Static IP or DHCP) by sending the configuration to the server.
*/
export async function applyNetworkSettings() {
const useStatic = dom.staticIpToggle.checked;
let payload;
dom.networkApplyButton.disabled = true;
dom.networkApplyButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Applying...`;
if (useStatic) {
const ip = dom.staticIpInput.value;
const gateway = dom.staticGatewayInput.value;
const subnet = dom.staticNetmaskInput.value;
const dns1 = dom.dns1Input.value;
const dns2 = dom.dns2Input.value;
if (!ip || !gateway || !subnet || !dns1) {
alert('For static IP, you must provide IP Address, Gateway, Netmask, and DNS Server.');
dom.networkApplyButton.disabled = false;
dom.networkApplyButton.innerHTML = 'Apply';
return;
}
payload = { net_type: 'static', ip, gateway, subnet, dns1 };
if (dns2) payload.dns2 = dns2;
} else {
payload = { net_type: 'dhcp' };
}
try {
await api.postNetworkSettings(payload);
alert('Network settings applied. Reconnect to the network for changes to take effect.');
initializeSettings();
} catch (error) {
console.error('Error applying network settings:', error);
alert(`Failed to apply settings: ${error.message}`);
} finally {
dom.networkApplyButton.disabled = false;
dom.networkApplyButton.innerHTML = 'Apply';
}
}
/**
* Applies AP Mode settings (AP+STA or STA) by sending the configuration to the server.
*/
export async function applyApModeSettings() {
const mode = dom.apModeToggle.checked ? 'apsta' : 'sta';
let payload = { mode };
dom.apModeApplyButton.disabled = true;
dom.apModeApplyButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Applying...`;
if (mode === 'apsta') {
const ap_ssid = dom.apSsidInput.value;
const ap_password = dom.apPasswordInput.value;
if (!ap_ssid) {
alert('AP SSID cannot be empty when enabling APSTA mode.');
dom.apModeApplyButton.disabled = false;
dom.apModeApplyButton.innerHTML = 'Apply';
return;
}
payload.ap_ssid = ap_ssid;
if (ap_password) {
payload.ap_password = ap_password;
}
}
try {
await api.postNetworkSettings(payload); // Reuses the same API endpoint
alert(`Successfully switched mode to ${mode}. The device will now reconfigure.`);
initializeSettings();
} catch (error) {
console.error('Error switching Wi-Fi mode:', error);
alert(`Failed to switch mode: ${error.message}`);
} finally {
dom.apModeApplyButton.disabled = false;
dom.apModeApplyButton.innerHTML = 'Apply';
}
}
/**
* Applies the selected UART baud rate by sending it to the server.
*/
export async function applyBaudRateSettings() {
const baudrate = dom.baudRateSelect.value;
dom.baudRateApplyButton.disabled = true;
dom.baudRateApplyButton.innerHTML = `<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Applying...`;
try {
await api.postBaudRateSetting(baudrate);
} catch (error) {
console.error('Error applying baud rate:', error);
} finally {
dom.baudRateApplyButton.disabled = false;
dom.baudRateApplyButton.innerHTML = 'Apply';
}
}
/**
* Fetches and displays the current network and device settings in the settings modal.
*/
export async function initializeSettings() {
try {
const data = await api.fetchSettings();
// Wi-Fi Connection Status
if (data.connected) {
dom.currentWifiSsid.textContent = data.ssid;
dom.currentWifiIp.textContent = `IP Address: ${data.ip ? data.ip.ip : 'N/A'}`;
} else {
dom.currentWifiSsid.textContent = 'Not Connected';
dom.currentWifiIp.textContent = 'IP Address: -';
}
// Network (Static/DHCP) Settings
if (data.ip) {
dom.staticIpInput.value = data.ip.ip || '';
dom.staticGatewayInput.value = data.ip.gateway || '';
dom.staticNetmaskInput.value = data.ip.subnet || '';
dom.dns1Input.value = data.ip.dns1 || '';
dom.dns2Input.value = data.ip.dns2 || '';
}
dom.staticIpToggle.checked = data.net_type === 'static';
dom.staticIpConfig.style.display = dom.staticIpToggle.checked ? 'block' : 'none';
// AP Mode Settings
dom.apModeToggle.checked = data.mode === 'apsta';
dom.apModeConfig.style.display = dom.apModeToggle.checked ? 'block' : 'none';
dom.apSsidInput.value = ''; // For security, don't pre-fill
dom.apPasswordInput.value = '';
// Device Settings
if (data.baudrate) {
dom.baudRateSelect.value = data.baudrate;
}
} catch (error) {
console.error('Error initializing settings:', error);
// Reset fields on error
dom.currentWifiSsid.textContent = 'Status Unknown';
dom.currentWifiIp.textContent = 'IP Address: -';
dom.staticIpToggle.checked = false;
dom.staticIpConfig.style.display = 'none';
dom.apModeToggle.checked = false;
dom.apModeConfig.style.display = 'none';
}
}
/**
* Fetches and updates the status of the power control toggles.
*/
export async function updateControlStatus() {
try {
const status = await api.fetchControlStatus();
dom.mainPowerToggle.checked = status.load_12v_on;
dom.usbPowerToggle.checked = status.load_5v_on;
} catch (error) {
console.error('Error fetching control status:', error);
}
}
/**
* Handles window resize events to make components responsive.
*/
export function handleResize() {
if (isMobile()) {
fitTerminal();
}
resizeCharts();
}
/**
* Updates the WebSocket connection status indicator in the header.
* @param {boolean} isConnected - True if the WebSocket is connected, false otherwise.
*/
export function updateWebsocketStatus(isConnected) {
if (isConnected) {
dom.websocketStatusText.textContent = 'Online';
dom.websocketIcon.className = 'bi bi-check-circle-fill me-2';
dom.websocketStatus.classList.remove('text-danger');
dom.websocketStatus.classList.add('text-success');
} else {
dom.websocketStatusText.textContent = 'Offline';
dom.websocketIcon.className = 'bi bi-x-circle-fill me-2';
dom.websocketStatus.classList.remove('text-success');
dom.websocketStatus.classList.add('text-danger');
}
}

44
page/src/utils.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* @file utils.js
* @description Provides utility functions used throughout the application.
*/
/**
* Creates a debounced function that delays invoking `func` until after `delay` milliseconds
* have elapsed since the last time the debounced function was invoked.
* @param {Function} func The function to debounce.
* @param {number} delay The number of milliseconds to delay.
* @returns {Function} Returns the new debounced function.
*/
export function debounce(func, delay) {
let timeout;
return function (...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), delay);
};
}
/**
* Formats a duration in total seconds into a human-readable string (e.g., "1d 02:30:15").
* @param {number} totalSeconds The total seconds to format.
* @returns {string} The formatted uptime string.
*/
export function formatUptime(totalSeconds) {
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (num) => String(num).padStart(2, '0');
const timeString = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
return days > 0 ? `${days}d ${timeString}` : timeString;
}
/**
* Checks if the current device is likely a mobile device based on screen width.
* @returns {boolean} True if the window width is less than or equal to 767.98px, false otherwise.
*/
export function isMobile() {
// The 767.98px breakpoint is typically used by Bootstrap for medium (md) devices.
return window.innerWidth <= 767.98;
}

41
page/src/websocket.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* @file websocket.js
* @description This module handles the WebSocket connection for real-time, two-way
* communication with the server. It provides functions to initialize the connection
* and send messages.
*/
// The WebSocket instance, exported for potential direct access if needed.
export let websocket;
// The WebSocket server address, derived from the current page's hostname.
const gateway = `ws://${window.location.hostname}/ws`;
/**
* Initializes the WebSocket connection and sets up event handlers.
* @param {Object} callbacks - An object containing callback functions for WebSocket events.
* @param {function} callbacks.onOpen - Called when the connection is successfully opened.
* @param {function} callbacks.onClose - Called when the connection is closed.
* @param {function} callbacks.onMessage - Called when a message is received from the server.
*/
export function initWebSocket({ onOpen, onClose, onMessage }) {
console.log(`Trying to open a WebSocket connection to ${gateway}...`);
websocket = new WebSocket(gateway);
// Set binary type to arraybuffer to handle raw binary data from the UART.
websocket.binaryType = "arraybuffer";
// Assign event handlers from the provided callbacks
if (onOpen) websocket.onopen = onOpen;
if (onClose) websocket.onclose = onClose;
if (onMessage) websocket.onmessage = onMessage;
}
/**
* Sends data over the WebSocket connection if it is open.
* @param {string | ArrayBuffer} data - The data to send to the server.
*/
export function sendWebsocketMessage(data) {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(data);
}
}

10
page/vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import { viteSingleFile } from 'vite-plugin-singlefile';
import viteCompression from 'vite-plugin-compression';
export default defineConfig({
plugins: [
viteSingleFile(),
viteCompression(),
],
});

6
partitions.csv Normal file
View File

@@ -0,0 +1,6 @@
# ESP-IDF Partition Table
# Name, Type, SubType, Offset, Size, Flags
nvs,data,nvs,0x9000,24K,
phy_init,data,phy,0xf000,4K,
factory,app,factory,0x10000,2M,
littlefs, data, littlefs, ,1536K,
1 # ESP-IDF Partition Table
2 # Name, Type, SubType, Offset, Size, Flags
3 nvs,data,nvs,0x9000,24K,
4 phy_init,data,phy,0xf000,4K,
5 factory,app,factory,0x10000,2M,
6 littlefs, data, littlefs, ,1536K,

2105
sdkconfig Normal file

File diff suppressed because it is too large Load Diff

8
sdkconfig.defaults Normal file
View File

@@ -0,0 +1,8 @@
# This file was generated using idf.py save-defconfig. It can be edited manually.
# Espressif IoT Development Framework (ESP-IDF) 5.4.0 Project Minimal Configuration
#
CONFIG_IDF_TARGET="esp32c3"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_UART_ISR_IN_IRAM=y
CONFIG_HTTPD_WS_SUPPORT=y