You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
4272 lines
157 KiB
4272 lines
157 KiB
/**
|
|
* @file user_main.c
|
|
* @brief ESP32 Channel3 Main Application
|
|
*
|
|
* Main entry point and demo screens for the Channel3 video broadcast system.
|
|
* Provides various demonstration states showing text, graphics, and 3D rendering.
|
|
*
|
|
* Original Copyright 2015 <>< Charles Lohr
|
|
* ESP32 Port 2024
|
|
*/
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/task.h"
|
|
#include "freertos/event_groups.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 "esp_timer.h"
|
|
#include "esp_http_server.h"
|
|
#include "esp_http_client.h"
|
|
#include "lwip/sockets.h"
|
|
#include "lwip/netdb.h"
|
|
#include "esp_sntp.h"
|
|
#include "esp_crt_bundle.h"
|
|
#include "cJSON.h"
|
|
#include "mqtt_client.h"
|
|
|
|
#include "video_broadcast.h"
|
|
#include "3d.h"
|
|
|
|
static const char *TAG = "channel3";
|
|
|
|
// From video_broadcast.c - for jam_color control
|
|
extern int8_t jam_color;
|
|
|
|
// Video broadcast state
|
|
static bool video_running = false;
|
|
|
|
// WiFi connection state
|
|
static bool wifi_connected = false;
|
|
static char wifi_ip_str[16] = "0.0.0.0";
|
|
|
|
// Demo state machine
|
|
#define INITIAL_SHOW_STATE 13 // Start with weather display
|
|
|
|
extern int gframe;
|
|
extern uint32_t last_internal_frametime;
|
|
static char lastct[256];
|
|
static uint8_t showstate = INITIAL_SHOW_STATE;
|
|
static uint8_t showallowadvance = 1;
|
|
static int framessostate = 0;
|
|
static int showtemp = 0;
|
|
|
|
// HTTP Server handle
|
|
static httpd_handle_t http_server = NULL;
|
|
|
|
// Uploaded image buffer (116x220 at 4bpp = 12760 bytes)
|
|
#define IMG_WIDTH 116
|
|
#define IMG_HEIGHT 220
|
|
#define IMG_BUFFER_SIZE ((IMG_WIDTH * IMG_HEIGHT) / 2)
|
|
static uint8_t uploaded_image[IMG_BUFFER_SIZE];
|
|
static bool has_uploaded_image = false;
|
|
|
|
// Video streaming server
|
|
#define STREAM_PORT 5000
|
|
static bool streaming_active = false;
|
|
|
|
// Weather data
|
|
static char weather_city[24] = "Loading...";
|
|
static char weather_condition[24] = "";
|
|
static char weather_temp[8] = "--";
|
|
static char weather_humidity[8] = "--";
|
|
static char weather_wind[16] = "--";
|
|
static uint32_t last_weather_fetch = 0;
|
|
static uint32_t last_weather_screen_toggle = 0;
|
|
static int weather_screen_page = 0; // 0 = current conditions, 1 = forecast
|
|
#define WEATHER_FETCH_INTERVAL_MS 300000 // 5 minutes
|
|
#define WEATHER_SCREEN_TOGGLE_MS 5000 // 5 seconds per screen
|
|
|
|
// Cedar Park, TX location (78613)
|
|
#define WEATHER_CITY_NAME "Cedar Park"
|
|
#define WEATHER_LAT "30.51"
|
|
#define WEATHER_LON "-97.82"
|
|
|
|
// Hourly forecast (6 entries, every 2 hours)
|
|
#define FORECAST_ENTRIES 6
|
|
typedef struct {
|
|
char time[8]; // "2pm", "4pm", etc.
|
|
char temp[8]; // "56F"
|
|
char cond[12]; // "Rain", "Clear", etc.
|
|
char humidity[8]; // "100%"
|
|
char wind[12]; // "3mph"
|
|
} forecast_entry_t;
|
|
static forecast_entry_t hourly_forecast[FORECAST_ENTRIES];
|
|
|
|
// Screen calibration margins (stored in NVS)
|
|
static int8_t margin_left = 0;
|
|
static int8_t margin_top = 0;
|
|
static int8_t margin_right = 0;
|
|
static int8_t margin_bottom = 0;
|
|
#define NVS_NAMESPACE "channel3"
|
|
#define NVS_KEY_MARGIN_L "margin_l"
|
|
#define NVS_KEY_MARGIN_T "margin_t"
|
|
#define NVS_KEY_MARGIN_R "margin_r"
|
|
#define NVS_KEY_MARGIN_B "margin_b"
|
|
|
|
// MQTT Configuration (stored in NVS)
|
|
#define ALERT_DURATION_MS 5000
|
|
#define MAX_ALERTS 4
|
|
#define NVS_KEY_MQTT_BROKER "mqtt_broker"
|
|
#define NVS_KEY_MQTT_PORT "mqtt_port"
|
|
#define NVS_KEY_MQTT_USER "mqtt_user"
|
|
#define NVS_KEY_MQTT_PASS "mqtt_pass"
|
|
#define NVS_KEY_ALERT_TOPIC "alert_topic"
|
|
#define NVS_KEY_ALERT_MSG "alert_msg"
|
|
|
|
static char mqtt_broker[64] = "10.0.0.18";
|
|
static uint16_t mqtt_port = 1883;
|
|
static char mqtt_username[64] = "homeassistant";
|
|
static char mqtt_password[128] = "oes5gohng9gau1Quei2ohpixashi4Thidoon1shohGai2mae0ru2zaph2vooshai";
|
|
static esp_mqtt_client_handle_t mqtt_client = NULL;
|
|
|
|
// Alert configurations (topic -> message mapping)
|
|
typedef struct {
|
|
char topic[64];
|
|
char message[24]; // Short message for TV display
|
|
} alert_config_t;
|
|
|
|
static alert_config_t alerts[MAX_ALERTS] = {
|
|
{ "channel3/intruder", "INTRUDER!" },
|
|
{ "channel3/door", "DOOR OPENED!" },
|
|
{ "", "" },
|
|
{ "", "" }
|
|
};
|
|
|
|
// Alert state
|
|
static volatile bool alert_active = false;
|
|
static uint32_t alert_start_time = 0;
|
|
static char current_alert_message[64] = "ALERT!";
|
|
|
|
// MQTT debug state
|
|
static volatile bool mqtt_connected = false;
|
|
static volatile bool mqtt_needs_restart = false;
|
|
static int mqtt_subscribe_msg_ids[MAX_ALERTS] = {0};
|
|
static int mqtt_subscribed_count = 0;
|
|
static char mqtt_last_topic[64] = "";
|
|
static char mqtt_last_data[32] = "";
|
|
static uint32_t mqtt_last_msg_time = 0;
|
|
static uint32_t mqtt_connect_count = 0;
|
|
|
|
// HTTP client response buffer (Open-Meteo JSON is ~1.5KB)
|
|
static char http_response_buffer[2048];
|
|
static int http_response_len = 0;
|
|
|
|
// ============================================================================
|
|
// Home Assistant Integration
|
|
// ============================================================================
|
|
|
|
// HA Connection Config (stored in NVS)
|
|
#define NVS_KEY_HA_URL "ha_url"
|
|
#define NVS_KEY_HA_TOKEN "ha_token"
|
|
#define NVS_KEY_HA_INTERVAL "ha_interval"
|
|
#define NVS_KEY_IMAGE "uploaded_img"
|
|
|
|
static char ha_url[96] = ""; // e.g., "http://10.0.0.5:8123"
|
|
static char ha_token[256] = ""; // Long-lived access token
|
|
static uint32_t ha_poll_interval_ms = 60000;
|
|
|
|
// Sensor Config (max 8 sensors)
|
|
#define MAX_HA_SENSORS 8
|
|
#define HA_DISPLAY_TEXT 0
|
|
#define HA_DISPLAY_GAUGE 1
|
|
|
|
typedef struct {
|
|
char entity_id[64]; // e.g., "sensor.outdoor_temperature"
|
|
char attribute[32]; // e.g., "humidity" or empty for state
|
|
char name[16]; // User-friendly display name
|
|
uint8_t display_type; // HA_DISPLAY_TEXT or HA_DISPLAY_GAUGE
|
|
int16_t min_value; // Gauge minimum
|
|
int16_t max_value; // Gauge maximum
|
|
uint8_t enabled; // Include in rotation
|
|
char cached_value[16]; // Runtime: cached display value
|
|
uint32_t last_update; // Runtime: last fetch timestamp
|
|
} ha_sensor_config_t;
|
|
|
|
static ha_sensor_config_t ha_sensors[MAX_HA_SENSORS] = {0};
|
|
static int ha_sensor_count = 0;
|
|
static int ha_current_sensor = 0;
|
|
static uint32_t last_ha_fetch = 0;
|
|
|
|
// Screen Rotation Configuration
|
|
#define MAX_ROTATION_SLOTS 12
|
|
#define SCREEN_TYPE_WEATHER 0
|
|
#define SCREEN_TYPE_CLOCK 1
|
|
#define SCREEN_TYPE_HA_SENSOR 2
|
|
#define SCREEN_TYPE_IMAGE 3
|
|
|
|
typedef struct {
|
|
uint8_t screen_type; // 0=Weather, 1=Clock, 2=HA Sensor, 3=Image
|
|
uint8_t sensor_idx; // For HA sensors: which sensor (0-7)
|
|
uint8_t enabled; // Include in rotation
|
|
uint16_t duration_sec; // How long to display (5-300 seconds)
|
|
} rotation_slot_t;
|
|
|
|
static rotation_slot_t rotation_slots[MAX_ROTATION_SLOTS] = {
|
|
{SCREEN_TYPE_WEATHER, 0, 1, 30}, // Weather, 30 sec
|
|
{SCREEN_TYPE_CLOCK, 0, 1, 15}, // Clock, 15 sec
|
|
{0, 0, 0, 0}, // Empty slots
|
|
};
|
|
static uint8_t rotation_count = 2;
|
|
static uint8_t current_rotation_idx = 0;
|
|
static uint32_t rotation_slot_start_time = 0;
|
|
|
|
// Screen Transition Configuration
|
|
#define TRANS_NONE 0
|
|
#define TRANS_FADE 1
|
|
#define TRANS_WIPE_L 2 // New slides in from right
|
|
#define TRANS_WIPE_R 3 // New slides in from left
|
|
#define TRANS_WIPE_D 4 // New slides in from top
|
|
#define TRANS_WIPE_U 5 // New slides in from bottom
|
|
#define TRANS_DISSOLVE 6
|
|
|
|
#define NVS_KEY_TRANS_TYPE "trans_type"
|
|
#define NVS_KEY_TRANS_SPEED "trans_speed"
|
|
|
|
// Transition state
|
|
static uint8_t transition_active = 0;
|
|
static uint8_t transition_type = TRANS_NONE;
|
|
static uint8_t transition_progress = 0; // 0-255 (0=start, 255=complete)
|
|
static uint8_t transition_speed = 12; // Progress increment per frame (~20 frames)
|
|
static uint8_t prev_frame[(FBW2/2) * FBH]; // Store previous frame for blending
|
|
|
|
// Global default transition (NVS persisted)
|
|
static uint8_t default_transition = TRANS_FADE;
|
|
static uint8_t default_trans_speed = 12;
|
|
|
|
/**
|
|
* @brief Load screen margins from NVS
|
|
*/
|
|
static void load_margins(void)
|
|
{
|
|
nvs_handle_t nvs;
|
|
if (nvs_open(NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) {
|
|
nvs_get_i8(nvs, NVS_KEY_MARGIN_L, &margin_left);
|
|
nvs_get_i8(nvs, NVS_KEY_MARGIN_T, &margin_top);
|
|
nvs_get_i8(nvs, NVS_KEY_MARGIN_R, &margin_right);
|
|
nvs_get_i8(nvs, NVS_KEY_MARGIN_B, &margin_bottom);
|
|
nvs_close(nvs);
|
|
ESP_LOGI(TAG, "Loaded margins: L=%d T=%d R=%d B=%d",
|
|
margin_left, margin_top, margin_right, margin_bottom);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Save screen margins to NVS
|
|
*/
|
|
static void save_margins(void)
|
|
{
|
|
nvs_handle_t nvs;
|
|
video_broadcast_pause();
|
|
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) {
|
|
nvs_set_i8(nvs, NVS_KEY_MARGIN_L, margin_left);
|
|
nvs_set_i8(nvs, NVS_KEY_MARGIN_T, margin_top);
|
|
nvs_set_i8(nvs, NVS_KEY_MARGIN_R, margin_right);
|
|
nvs_set_i8(nvs, NVS_KEY_MARGIN_B, margin_bottom);
|
|
nvs_commit(nvs);
|
|
nvs_close(nvs);
|
|
ESP_LOGI(TAG, "Saved margins: L=%d T=%d R=%d B=%d",
|
|
margin_left, margin_top, margin_right, margin_bottom);
|
|
}
|
|
video_broadcast_resume();
|
|
}
|
|
|
|
/**
|
|
* @brief Save uploaded image to NVS
|
|
*/
|
|
static void save_uploaded_image(void)
|
|
{
|
|
nvs_handle_t nvs;
|
|
video_broadcast_pause();
|
|
|
|
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "Failed to open NVS for image save: %s", esp_err_to_name(err));
|
|
video_broadcast_resume();
|
|
return;
|
|
}
|
|
|
|
// Get NVS stats to check available space
|
|
nvs_stats_t nvs_stats;
|
|
if (nvs_get_stats(NULL, &nvs_stats) == ESP_OK) {
|
|
ESP_LOGI(TAG, "NVS stats: used=%d free=%d total=%d",
|
|
nvs_stats.used_entries, nvs_stats.free_entries, nvs_stats.total_entries);
|
|
}
|
|
|
|
err = nvs_set_blob(nvs, NVS_KEY_IMAGE, uploaded_image, IMG_BUFFER_SIZE);
|
|
if (err == ESP_OK) {
|
|
err = nvs_commit(nvs);
|
|
if (err == ESP_OK) {
|
|
ESP_LOGI(TAG, "Saved uploaded image to NVS (%d bytes)", IMG_BUFFER_SIZE);
|
|
} else {
|
|
ESP_LOGE(TAG, "Failed to commit image to NVS: %s", esp_err_to_name(err));
|
|
}
|
|
} else {
|
|
ESP_LOGE(TAG, "Failed to save image to NVS: %s (size=%d)", esp_err_to_name(err), IMG_BUFFER_SIZE);
|
|
}
|
|
nvs_close(nvs);
|
|
video_broadcast_resume();
|
|
}
|
|
|
|
/**
|
|
* @brief Load uploaded image from NVS
|
|
*/
|
|
static void load_uploaded_image(void)
|
|
{
|
|
nvs_handle_t nvs;
|
|
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &nvs);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGW(TAG, "Failed to open NVS for image load: %s", esp_err_to_name(err));
|
|
return;
|
|
}
|
|
|
|
size_t len = IMG_BUFFER_SIZE;
|
|
err = nvs_get_blob(nvs, NVS_KEY_IMAGE, uploaded_image, &len);
|
|
if (err == ESP_OK && len == IMG_BUFFER_SIZE) {
|
|
has_uploaded_image = true;
|
|
ESP_LOGI(TAG, "Loaded uploaded image from NVS (%d bytes)", len);
|
|
} else if (err == ESP_ERR_NVS_NOT_FOUND) {
|
|
ESP_LOGI(TAG, "No saved image found in NVS");
|
|
} else {
|
|
ESP_LOGE(TAG, "Failed to load image from NVS: %s (len=%d, expected=%d)",
|
|
esp_err_to_name(err), len, IMG_BUFFER_SIZE);
|
|
}
|
|
nvs_close(nvs);
|
|
}
|
|
|
|
/**
|
|
* @brief Load MQTT configuration from NVS
|
|
*/
|
|
static void load_mqtt_config(void)
|
|
{
|
|
nvs_handle_t nvs;
|
|
if (nvs_open(NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) {
|
|
size_t len = sizeof(mqtt_broker);
|
|
nvs_get_str(nvs, NVS_KEY_MQTT_BROKER, mqtt_broker, &len);
|
|
nvs_get_u16(nvs, NVS_KEY_MQTT_PORT, &mqtt_port);
|
|
len = sizeof(mqtt_username);
|
|
nvs_get_str(nvs, NVS_KEY_MQTT_USER, mqtt_username, &len);
|
|
len = sizeof(mqtt_password);
|
|
nvs_get_str(nvs, NVS_KEY_MQTT_PASS, mqtt_password, &len);
|
|
|
|
// Load alert configurations (only overwrite if NVS has non-empty value)
|
|
for (int i = 0; i < MAX_ALERTS; i++) {
|
|
char key[16];
|
|
char temp[64];
|
|
snprintf(key, sizeof(key), "%s%d", NVS_KEY_ALERT_TOPIC, i);
|
|
len = sizeof(temp);
|
|
if (nvs_get_str(nvs, key, temp, &len) == ESP_OK && temp[0]) {
|
|
strncpy(alerts[i].topic, temp, sizeof(alerts[i].topic) - 1);
|
|
alerts[i].topic[sizeof(alerts[i].topic) - 1] = '\0';
|
|
}
|
|
snprintf(key, sizeof(key), "%s%d", NVS_KEY_ALERT_MSG, i);
|
|
len = sizeof(temp);
|
|
if (nvs_get_str(nvs, key, temp, &len) == ESP_OK && temp[0]) {
|
|
strncpy(alerts[i].message, temp, sizeof(alerts[i].message) - 1);
|
|
alerts[i].message[sizeof(alerts[i].message) - 1] = '\0';
|
|
}
|
|
}
|
|
|
|
nvs_close(nvs);
|
|
ESP_LOGI(TAG, "MQTT config: %s:%d user=%s", mqtt_broker, mqtt_port, mqtt_username);
|
|
for (int i = 0; i < MAX_ALERTS; i++) {
|
|
if (alerts[i].topic[0]) {
|
|
ESP_LOGI(TAG, "Alert %d: %s -> %s", i, alerts[i].topic, alerts[i].message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Save MQTT configuration to NVS
|
|
*/
|
|
static void save_mqtt_config(void)
|
|
{
|
|
nvs_handle_t nvs;
|
|
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) {
|
|
nvs_set_str(nvs, NVS_KEY_MQTT_BROKER, mqtt_broker);
|
|
nvs_set_u16(nvs, NVS_KEY_MQTT_PORT, mqtt_port);
|
|
nvs_set_str(nvs, NVS_KEY_MQTT_USER, mqtt_username);
|
|
nvs_set_str(nvs, NVS_KEY_MQTT_PASS, mqtt_password);
|
|
|
|
// Save alert configurations
|
|
for (int i = 0; i < MAX_ALERTS; i++) {
|
|
char key[16];
|
|
snprintf(key, sizeof(key), "%s%d", NVS_KEY_ALERT_TOPIC, i);
|
|
nvs_set_str(nvs, key, alerts[i].topic);
|
|
snprintf(key, sizeof(key), "%s%d", NVS_KEY_ALERT_MSG, i);
|
|
nvs_set_str(nvs, key, alerts[i].message);
|
|
}
|
|
|
|
nvs_commit(nvs);
|
|
nvs_close(nvs);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Load Home Assistant configuration from NVS
|
|
*/
|
|
static void load_ha_config(void)
|
|
{
|
|
nvs_handle_t nvs;
|
|
if (nvs_open(NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) {
|
|
size_t len = sizeof(ha_url);
|
|
nvs_get_str(nvs, NVS_KEY_HA_URL, ha_url, &len);
|
|
len = sizeof(ha_token);
|
|
nvs_get_str(nvs, NVS_KEY_HA_TOKEN, ha_token, &len);
|
|
nvs_get_u32(nvs, NVS_KEY_HA_INTERVAL, &ha_poll_interval_ms);
|
|
|
|
// Load sensor configurations
|
|
ha_sensor_count = 0;
|
|
for (int i = 0; i < MAX_HA_SENSORS; i++) {
|
|
char key[16];
|
|
char temp[64];
|
|
|
|
// Load entity_id
|
|
snprintf(key, sizeof(key), "ha_ent%d", i);
|
|
len = sizeof(temp);
|
|
if (nvs_get_str(nvs, key, temp, &len) == ESP_OK && temp[0]) {
|
|
strncpy(ha_sensors[i].entity_id, temp, sizeof(ha_sensors[i].entity_id) - 1);
|
|
|
|
// Load attribute
|
|
snprintf(key, sizeof(key), "ha_attr%d", i);
|
|
len = sizeof(ha_sensors[i].attribute);
|
|
nvs_get_str(nvs, key, ha_sensors[i].attribute, &len);
|
|
|
|
// Load display name
|
|
snprintf(key, sizeof(key), "ha_name%d", i);
|
|
len = sizeof(ha_sensors[i].name);
|
|
nvs_get_str(nvs, key, ha_sensors[i].name, &len);
|
|
|
|
// Load display type
|
|
snprintf(key, sizeof(key), "ha_type%d", i);
|
|
uint8_t dtype = 0;
|
|
nvs_get_u8(nvs, key, &dtype);
|
|
ha_sensors[i].display_type = dtype;
|
|
|
|
// Load gauge range
|
|
snprintf(key, sizeof(key), "ha_min%d", i);
|
|
nvs_get_i16(nvs, key, &ha_sensors[i].min_value);
|
|
snprintf(key, sizeof(key), "ha_max%d", i);
|
|
nvs_get_i16(nvs, key, &ha_sensors[i].max_value);
|
|
|
|
// Load enabled flag
|
|
snprintf(key, sizeof(key), "ha_en%d", i);
|
|
uint8_t en = 1;
|
|
nvs_get_u8(nvs, key, &en);
|
|
ha_sensors[i].enabled = en;
|
|
|
|
ha_sensor_count++;
|
|
ESP_LOGI(TAG, "HA Sensor %d: %s (attr=%s, name=%s, type=%d)",
|
|
i, ha_sensors[i].entity_id, ha_sensors[i].attribute,
|
|
ha_sensors[i].name, ha_sensors[i].display_type);
|
|
}
|
|
}
|
|
|
|
// Load rotation configuration
|
|
uint8_t rot_count = 0;
|
|
if (nvs_get_u8(nvs, "rot_count", &rot_count) == ESP_OK && rot_count > 0) {
|
|
rotation_count = (rot_count > MAX_ROTATION_SLOTS) ? MAX_ROTATION_SLOTS : rot_count;
|
|
for (int i = 0; i < rotation_count; i++) {
|
|
char key[16];
|
|
snprintf(key, sizeof(key), "rot_type%d", i);
|
|
nvs_get_u8(nvs, key, &rotation_slots[i].screen_type);
|
|
snprintf(key, sizeof(key), "rot_sens%d", i);
|
|
nvs_get_u8(nvs, key, &rotation_slots[i].sensor_idx);
|
|
snprintf(key, sizeof(key), "rot_en%d", i);
|
|
nvs_get_u8(nvs, key, &rotation_slots[i].enabled);
|
|
snprintf(key, sizeof(key), "rot_dur%d", i);
|
|
nvs_get_u16(nvs, key, &rotation_slots[i].duration_sec);
|
|
}
|
|
}
|
|
|
|
// Load transition configuration
|
|
nvs_get_u8(nvs, NVS_KEY_TRANS_TYPE, &default_transition);
|
|
nvs_get_u8(nvs, NVS_KEY_TRANS_SPEED, &default_trans_speed);
|
|
if (default_trans_speed < 1) default_trans_speed = 1;
|
|
if (default_trans_speed > 50) default_trans_speed = 50;
|
|
ESP_LOGI(TAG, "Transition config: type=%d, speed=%d", default_transition, default_trans_speed);
|
|
|
|
nvs_close(nvs);
|
|
ESP_LOGI(TAG, "HA config loaded: URL=%s, interval=%lums, %d sensors, %d rotation slots",
|
|
ha_url, (unsigned long)ha_poll_interval_ms, ha_sensor_count, rotation_count);
|
|
}
|
|
|
|
// Initialize rotation timer
|
|
rotation_slot_start_time = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
|
current_rotation_idx = 0;
|
|
}
|
|
|
|
/**
|
|
* @brief Save Home Assistant configuration to NVS
|
|
*/
|
|
static void save_ha_config(void)
|
|
{
|
|
nvs_handle_t nvs;
|
|
video_broadcast_pause(); // Pause video to prevent flash cache conflicts
|
|
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) {
|
|
nvs_set_str(nvs, NVS_KEY_HA_URL, ha_url);
|
|
nvs_set_str(nvs, NVS_KEY_HA_TOKEN, ha_token);
|
|
nvs_set_u32(nvs, NVS_KEY_HA_INTERVAL, ha_poll_interval_ms);
|
|
nvs_commit(nvs);
|
|
nvs_close(nvs);
|
|
ESP_LOGI(TAG, "HA config saved");
|
|
}
|
|
video_broadcast_resume();
|
|
}
|
|
|
|
/**
|
|
* @brief Save a single HA sensor configuration to NVS
|
|
*/
|
|
static void save_ha_sensor(int idx)
|
|
{
|
|
if (idx < 0 || idx >= MAX_HA_SENSORS) return;
|
|
|
|
nvs_handle_t nvs;
|
|
video_broadcast_pause();
|
|
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) {
|
|
char key[16];
|
|
|
|
snprintf(key, sizeof(key), "ha_ent%d", idx);
|
|
nvs_set_str(nvs, key, ha_sensors[idx].entity_id);
|
|
|
|
snprintf(key, sizeof(key), "ha_attr%d", idx);
|
|
nvs_set_str(nvs, key, ha_sensors[idx].attribute);
|
|
|
|
snprintf(key, sizeof(key), "ha_name%d", idx);
|
|
nvs_set_str(nvs, key, ha_sensors[idx].name);
|
|
|
|
snprintf(key, sizeof(key), "ha_type%d", idx);
|
|
nvs_set_u8(nvs, key, ha_sensors[idx].display_type);
|
|
|
|
snprintf(key, sizeof(key), "ha_min%d", idx);
|
|
nvs_set_i16(nvs, key, ha_sensors[idx].min_value);
|
|
|
|
snprintf(key, sizeof(key), "ha_max%d", idx);
|
|
nvs_set_i16(nvs, key, ha_sensors[idx].max_value);
|
|
|
|
snprintf(key, sizeof(key), "ha_en%d", idx);
|
|
nvs_set_u8(nvs, key, ha_sensors[idx].enabled);
|
|
|
|
nvs_commit(nvs);
|
|
nvs_close(nvs);
|
|
}
|
|
video_broadcast_resume();
|
|
}
|
|
|
|
/**
|
|
* @brief Save rotation configuration to NVS
|
|
*/
|
|
static void save_rotation_config(void)
|
|
{
|
|
nvs_handle_t nvs;
|
|
video_broadcast_pause();
|
|
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) {
|
|
nvs_set_u8(nvs, "rot_count", rotation_count);
|
|
|
|
for (int i = 0; i < rotation_count; i++) {
|
|
char key[16];
|
|
snprintf(key, sizeof(key), "rot_type%d", i);
|
|
nvs_set_u8(nvs, key, rotation_slots[i].screen_type);
|
|
snprintf(key, sizeof(key), "rot_sens%d", i);
|
|
nvs_set_u8(nvs, key, rotation_slots[i].sensor_idx);
|
|
snprintf(key, sizeof(key), "rot_en%d", i);
|
|
nvs_set_u8(nvs, key, rotation_slots[i].enabled);
|
|
snprintf(key, sizeof(key), "rot_dur%d", i);
|
|
nvs_set_u16(nvs, key, rotation_slots[i].duration_sec);
|
|
}
|
|
|
|
nvs_commit(nvs);
|
|
nvs_close(nvs);
|
|
ESP_LOGI(TAG, "Rotation config saved: %d slots", rotation_count);
|
|
}
|
|
video_broadcast_resume();
|
|
}
|
|
|
|
/**
|
|
* @brief Save transition configuration to NVS
|
|
*/
|
|
static void save_transition_config(void)
|
|
{
|
|
nvs_handle_t nvs;
|
|
video_broadcast_pause();
|
|
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) {
|
|
nvs_set_u8(nvs, NVS_KEY_TRANS_TYPE, default_transition);
|
|
nvs_set_u8(nvs, NVS_KEY_TRANS_SPEED, default_trans_speed);
|
|
nvs_commit(nvs);
|
|
nvs_close(nvs);
|
|
ESP_LOGI(TAG, "Transition config saved: type=%d, speed=%d", default_transition, default_trans_speed);
|
|
}
|
|
video_broadcast_resume();
|
|
}
|
|
|
|
/**
|
|
* @brief Get showstate value for a rotation screen type
|
|
*/
|
|
static int rotation_type_to_state(uint8_t screen_type)
|
|
{
|
|
switch (screen_type) {
|
|
case SCREEN_TYPE_WEATHER: return 13;
|
|
case SCREEN_TYPE_CLOCK: return 16;
|
|
case SCREEN_TYPE_HA_SENSOR: return 17;
|
|
case SCREEN_TYPE_IMAGE: return 12;
|
|
default: return 13;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Advance to next enabled rotation slot
|
|
* @return The showstate for the new slot
|
|
*/
|
|
static int advance_rotation(void)
|
|
{
|
|
if (rotation_count == 0) return 13; // Default to weather
|
|
|
|
// Find next enabled slot
|
|
for (int tries = 0; tries < rotation_count; tries++) {
|
|
current_rotation_idx = (current_rotation_idx + 1) % rotation_count;
|
|
if (rotation_slots[current_rotation_idx].enabled) {
|
|
rotation_slot_start_time = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
|
|
|
// For HA sensor type, set which sensor to display
|
|
if (rotation_slots[current_rotation_idx].screen_type == SCREEN_TYPE_HA_SENSOR) {
|
|
ha_current_sensor = rotation_slots[current_rotation_idx].sensor_idx;
|
|
}
|
|
|
|
return rotation_type_to_state(rotation_slots[current_rotation_idx].screen_type);
|
|
}
|
|
}
|
|
|
|
return 13; // Default to weather if nothing enabled
|
|
}
|
|
|
|
/**
|
|
* @brief Check if current rotation slot duration has expired
|
|
*/
|
|
static bool rotation_duration_expired(void)
|
|
{
|
|
if (rotation_count == 0) return false;
|
|
|
|
uint32_t now = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
|
uint32_t duration_ms = rotation_slots[current_rotation_idx].duration_sec * 1000;
|
|
|
|
return (now - rotation_slot_start_time) >= duration_ms;
|
|
}
|
|
|
|
/**
|
|
* @brief MQTT event handler
|
|
*/
|
|
static void mqtt_event_handler(void *args, esp_event_base_t base,
|
|
int32_t event_id, void *event_data)
|
|
{
|
|
esp_mqtt_event_handle_t event = event_data;
|
|
|
|
switch ((esp_mqtt_event_id_t)event_id) {
|
|
case MQTT_EVENT_CONNECTED:
|
|
ESP_LOGI(TAG, "MQTT connected to broker");
|
|
mqtt_connected = true;
|
|
mqtt_connect_count++;
|
|
mqtt_subscribed_count = 0;
|
|
// Subscribe to channel3/# wildcard - any message payload becomes the alert text
|
|
{
|
|
int msg_id = esp_mqtt_client_subscribe(event->client, "channel3/#", 0);
|
|
mqtt_subscribe_msg_ids[0] = msg_id;
|
|
ESP_LOGI(TAG, "Subscribed to 'channel3/#' msg_id=%d", msg_id);
|
|
}
|
|
break;
|
|
|
|
case MQTT_EVENT_SUBSCRIBED:
|
|
ESP_LOGI(TAG, "MQTT subscribed, msg_id=%d", event->msg_id);
|
|
mqtt_subscribed_count++;
|
|
break;
|
|
|
|
case MQTT_EVENT_DATA:
|
|
ESP_LOGI(TAG, "MQTT data: topic_len=%d data_len=%d", event->topic_len, event->data_len);
|
|
mqtt_last_msg_time = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
|
// Store last received topic/data for debug
|
|
if (event->topic_len > 0) {
|
|
int len = event->topic_len < 63 ? event->topic_len : 63;
|
|
memcpy(mqtt_last_topic, event->topic, len);
|
|
mqtt_last_topic[len] = '\0';
|
|
}
|
|
if (event->data_len > 0) {
|
|
int len = event->data_len < 31 ? event->data_len : 31;
|
|
memcpy(mqtt_last_data, event->data, len);
|
|
mqtt_last_data[len] = '\0';
|
|
}
|
|
|
|
// Use the message payload as the alert text
|
|
if (event->data_len > 0) {
|
|
int len = event->data_len < sizeof(current_alert_message) - 1 ? event->data_len : sizeof(current_alert_message) - 1;
|
|
memcpy(current_alert_message, event->data, len);
|
|
current_alert_message[len] = '\0';
|
|
|
|
alert_active = true;
|
|
framessostate = 0; // Reset frame counter for alert duration
|
|
showstate = 15; // Switch to alert screen
|
|
showallowadvance = 0;
|
|
ESP_LOGI(TAG, "ALERT triggered: %s", current_alert_message);
|
|
}
|
|
break;
|
|
|
|
case MQTT_EVENT_DISCONNECTED:
|
|
ESP_LOGW(TAG, "MQTT disconnected");
|
|
mqtt_connected = false;
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Start MQTT client with current configuration
|
|
*/
|
|
static void start_mqtt_client(void)
|
|
{
|
|
char uri[128];
|
|
snprintf(uri, sizeof(uri), "mqtt://%s:%d", mqtt_broker, mqtt_port);
|
|
|
|
esp_mqtt_client_config_t mqtt_cfg = {
|
|
.broker.address.uri = uri,
|
|
};
|
|
|
|
// Add credentials if configured
|
|
if (mqtt_username[0]) {
|
|
mqtt_cfg.credentials.username = mqtt_username;
|
|
}
|
|
if (mqtt_password[0]) {
|
|
mqtt_cfg.credentials.authentication.password = mqtt_password;
|
|
}
|
|
|
|
mqtt_client = esp_mqtt_client_init(&mqtt_cfg);
|
|
esp_mqtt_client_register_event(mqtt_client, ESP_EVENT_ANY_ID,
|
|
mqtt_event_handler, NULL);
|
|
esp_mqtt_client_start(mqtt_client);
|
|
ESP_LOGI(TAG, "MQTT client started: %s user=%s", uri, mqtt_username[0] ? mqtt_username : "(none)");
|
|
}
|
|
|
|
/**
|
|
* @brief HTTP event handler - accumulates response data
|
|
*/
|
|
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
|
|
{
|
|
switch (evt->event_id) {
|
|
case HTTP_EVENT_ON_DATA:
|
|
if (http_response_len + evt->data_len < sizeof(http_response_buffer) - 1) {
|
|
memcpy(http_response_buffer + http_response_len, evt->data, evt->data_len);
|
|
http_response_len += evt->data_len;
|
|
http_response_buffer[http_response_len] = '\0';
|
|
} else {
|
|
ESP_LOGW(TAG, "HTTP response buffer overflow! Current: %d, incoming: %d, max: %d",
|
|
http_response_len, evt->data_len, sizeof(http_response_buffer));
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
|
|
/**
|
|
* @brief Helper to make HTTP request and get response
|
|
*/
|
|
static bool http_get(const char *url)
|
|
{
|
|
http_response_len = 0;
|
|
http_response_buffer[0] = '\0';
|
|
|
|
esp_http_client_config_t config = {
|
|
.url = url,
|
|
.event_handler = http_event_handler,
|
|
.timeout_ms = 15000,
|
|
.crt_bundle_attach = esp_crt_bundle_attach, // Enable HTTPS with CA bundle
|
|
};
|
|
|
|
esp_http_client_handle_t client = esp_http_client_init(&config);
|
|
if (!client) return false;
|
|
|
|
esp_http_client_set_header(client, "User-Agent", "curl/7.0");
|
|
esp_err_t err = esp_http_client_perform(client);
|
|
int status = esp_http_client_get_status_code(client);
|
|
esp_http_client_cleanup(client);
|
|
|
|
return (err == ESP_OK && status == 200 && http_response_len > 0);
|
|
}
|
|
|
|
|
|
/**
|
|
* @brief Map WMO weather code to short condition string
|
|
* WMO codes: https://open-meteo.com/en/docs (weather_code description)
|
|
*/
|
|
static const char* wmo_to_condition(int code)
|
|
{
|
|
switch (code) {
|
|
case 0: return "Clear";
|
|
case 1: return "Mostly Clr";
|
|
case 2: return "PtCloud";
|
|
case 3: return "Cloudy";
|
|
case 45:
|
|
case 48: return "Fog";
|
|
case 51:
|
|
case 53:
|
|
case 55: return "Drizzle";
|
|
case 56:
|
|
case 57: return "FrzDrzl";
|
|
case 61:
|
|
case 63:
|
|
case 65: return "Rain";
|
|
case 66:
|
|
case 67: return "FrzRain";
|
|
case 71:
|
|
case 73:
|
|
case 75: return "Snow";
|
|
case 77: return "SnowGrn";
|
|
case 80:
|
|
case 81:
|
|
case 82: return "Showers";
|
|
case 85:
|
|
case 86: return "SnowShwr";
|
|
case 95: return "TStorm";
|
|
case 96:
|
|
case 99: return "TStorm+";
|
|
default: return "Unknown";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Format hour from ISO timestamp to 12-hour format
|
|
* Input: "2025-01-21T14:00" -> Output: "2pm"
|
|
*/
|
|
static void format_hour_from_iso(const char *iso_time, char *out, size_t out_size)
|
|
{
|
|
// Extract hour from "YYYY-MM-DDTHH:MM"
|
|
const char *t = strchr(iso_time, 'T');
|
|
if (!t || strlen(t) < 3) {
|
|
strncpy(out, "??", out_size);
|
|
return;
|
|
}
|
|
int hour = atoi(t + 1);
|
|
// Ensure valid hour range 0-23
|
|
if (hour < 0) hour = 0;
|
|
if (hour > 23) hour = 23;
|
|
|
|
if (hour == 0) {
|
|
snprintf(out, out_size, "12am");
|
|
} else if (hour < 12) {
|
|
snprintf(out, out_size, "%dam", hour); // max "11am" = 4 chars
|
|
} else if (hour == 12) {
|
|
snprintf(out, out_size, "12pm");
|
|
} else {
|
|
snprintf(out, out_size, "%dpm", hour - 12); // max "11pm" = 4 chars
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Fetch weather data from Open-Meteo API
|
|
* Uses ~1.5KB JSON with proper hourly forecasts
|
|
*/
|
|
static void fetch_weather(void)
|
|
{
|
|
if (!wifi_connected) {
|
|
ESP_LOGW(TAG, "WiFi not connected, skipping weather fetch");
|
|
return;
|
|
}
|
|
|
|
// Open-Meteo API URL for Cedar Park, TX
|
|
const char *url = "https://api.open-meteo.com/v1/forecast?"
|
|
"latitude=" WEATHER_LAT "&longitude=" WEATHER_LON
|
|
"¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m"
|
|
"&hourly=temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m"
|
|
"&temperature_unit=fahrenheit&wind_speed_unit=mph"
|
|
"&timezone=America/Chicago&forecast_hours=12";
|
|
|
|
ESP_LOGI(TAG, "Fetching weather from Open-Meteo...");
|
|
|
|
if (!http_get(url)) {
|
|
ESP_LOGE(TAG, "Failed to fetch weather");
|
|
strcpy(weather_city, "Fetch fail");
|
|
return;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "Response length: %d bytes", http_response_len);
|
|
|
|
// Parse JSON
|
|
cJSON *root = cJSON_Parse(http_response_buffer);
|
|
if (!root) {
|
|
ESP_LOGE(TAG, "JSON parse failed");
|
|
strcpy(weather_city, "Parse fail");
|
|
return;
|
|
}
|
|
|
|
// Set city name (static - Open-Meteo doesn't return location name)
|
|
strncpy(weather_city, WEATHER_CITY_NAME, sizeof(weather_city) - 1);
|
|
weather_city[sizeof(weather_city) - 1] = '\0';
|
|
|
|
// Parse current conditions
|
|
cJSON *current = cJSON_GetObjectItem(root, "current");
|
|
if (current) {
|
|
cJSON *temp = cJSON_GetObjectItem(current, "temperature_2m");
|
|
cJSON *humidity = cJSON_GetObjectItem(current, "relative_humidity_2m");
|
|
cJSON *weather_code = cJSON_GetObjectItem(current, "weather_code");
|
|
cJSON *wind = cJSON_GetObjectItem(current, "wind_speed_10m");
|
|
|
|
if (temp && cJSON_IsNumber(temp)) {
|
|
snprintf(weather_temp, sizeof(weather_temp), "%.0fF", temp->valuedouble);
|
|
}
|
|
if (humidity && cJSON_IsNumber(humidity)) {
|
|
snprintf(weather_humidity, sizeof(weather_humidity), "%.0f%%", humidity->valuedouble);
|
|
}
|
|
if (weather_code && cJSON_IsNumber(weather_code)) {
|
|
strncpy(weather_condition, wmo_to_condition((int)weather_code->valuedouble),
|
|
sizeof(weather_condition) - 1);
|
|
weather_condition[sizeof(weather_condition) - 1] = '\0';
|
|
}
|
|
if (wind && cJSON_IsNumber(wind)) {
|
|
snprintf(weather_wind, sizeof(weather_wind), "%.0fmph", wind->valuedouble);
|
|
}
|
|
}
|
|
|
|
// Parse hourly forecast
|
|
cJSON *hourly = cJSON_GetObjectItem(root, "hourly");
|
|
if (hourly) {
|
|
cJSON *times = cJSON_GetObjectItem(hourly, "time");
|
|
cJSON *temps = cJSON_GetObjectItem(hourly, "temperature_2m");
|
|
cJSON *codes = cJSON_GetObjectItem(hourly, "weather_code");
|
|
cJSON *humids = cJSON_GetObjectItem(hourly, "relative_humidity_2m");
|
|
cJSON *winds = cJSON_GetObjectItem(hourly, "wind_speed_10m");
|
|
|
|
if (times && temps && codes && humids && winds &&
|
|
cJSON_IsArray(times) && cJSON_IsArray(temps)) {
|
|
|
|
int array_size = cJSON_GetArraySize(times);
|
|
// Get 6 forecasts at 2-hour intervals (indices 0, 2, 4, 6, 8, 10)
|
|
for (int i = 0; i < FORECAST_ENTRIES && (i * 2) < array_size; i++) {
|
|
int idx = i * 2; // Every 2 hours
|
|
|
|
cJSON *time_item = cJSON_GetArrayItem(times, idx);
|
|
cJSON *temp_item = cJSON_GetArrayItem(temps, idx);
|
|
cJSON *code_item = cJSON_GetArrayItem(codes, idx);
|
|
cJSON *humid_item = cJSON_GetArrayItem(humids, idx);
|
|
cJSON *wind_item = cJSON_GetArrayItem(winds, idx);
|
|
|
|
if (time_item && cJSON_IsString(time_item)) {
|
|
format_hour_from_iso(time_item->valuestring,
|
|
hourly_forecast[i].time,
|
|
sizeof(hourly_forecast[i].time));
|
|
}
|
|
if (temp_item && cJSON_IsNumber(temp_item)) {
|
|
snprintf(hourly_forecast[i].temp, sizeof(hourly_forecast[i].temp),
|
|
"%.0fF", temp_item->valuedouble);
|
|
}
|
|
if (code_item && cJSON_IsNumber(code_item)) {
|
|
strncpy(hourly_forecast[i].cond,
|
|
wmo_to_condition((int)code_item->valuedouble),
|
|
sizeof(hourly_forecast[i].cond) - 1);
|
|
hourly_forecast[i].cond[sizeof(hourly_forecast[i].cond) - 1] = '\0';
|
|
}
|
|
if (humid_item && cJSON_IsNumber(humid_item)) {
|
|
snprintf(hourly_forecast[i].humidity, sizeof(hourly_forecast[i].humidity),
|
|
"%.0f%%", humid_item->valuedouble);
|
|
}
|
|
if (wind_item && cJSON_IsNumber(wind_item)) {
|
|
snprintf(hourly_forecast[i].wind, sizeof(hourly_forecast[i].wind),
|
|
"%.0fmph", wind_item->valuedouble);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cJSON_Delete(root);
|
|
|
|
last_weather_fetch = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
|
ESP_LOGI(TAG, "Weather updated: %s - %s %s, Humidity: %s, Wind: %s",
|
|
weather_city, weather_temp, weather_condition, weather_humidity, weather_wind);
|
|
}
|
|
|
|
/**
|
|
* @brief Fetch a single Home Assistant entity state
|
|
* @param entity_id Entity ID (e.g., "sensor.temperature")
|
|
* @param attribute Attribute name (empty string for state)
|
|
* @param out_value Output buffer for the value
|
|
* @param out_size Size of output buffer
|
|
* @return true on success
|
|
*/
|
|
static bool fetch_ha_entity(const char *entity_id, const char *attribute,
|
|
char *out_value, size_t out_size)
|
|
{
|
|
if (!wifi_connected || !ha_url[0] || !ha_token[0]) {
|
|
return false;
|
|
}
|
|
|
|
// Build URL: {ha_url}/api/states/{entity_id}
|
|
char url[256];
|
|
snprintf(url, sizeof(url), "%s/api/states/%s", ha_url, entity_id);
|
|
|
|
http_response_len = 0;
|
|
http_response_buffer[0] = '\0';
|
|
|
|
esp_http_client_config_t config = {
|
|
.url = url,
|
|
.event_handler = http_event_handler,
|
|
.timeout_ms = 10000,
|
|
};
|
|
|
|
esp_http_client_handle_t client = esp_http_client_init(&config);
|
|
if (!client) return false;
|
|
|
|
// Add authorization header
|
|
char auth_header[280];
|
|
snprintf(auth_header, sizeof(auth_header), "Bearer %s", ha_token);
|
|
esp_http_client_set_header(client, "Authorization", auth_header);
|
|
esp_http_client_set_header(client, "Content-Type", "application/json");
|
|
|
|
esp_err_t err = esp_http_client_perform(client);
|
|
int status = esp_http_client_get_status_code(client);
|
|
esp_http_client_cleanup(client);
|
|
|
|
if (err != ESP_OK || status != 200 || http_response_len == 0) {
|
|
ESP_LOGW(TAG, "HA fetch failed: %s status=%d", entity_id, status);
|
|
return false;
|
|
}
|
|
|
|
// Parse JSON response
|
|
cJSON *root = cJSON_Parse(http_response_buffer);
|
|
if (!root) {
|
|
ESP_LOGW(TAG, "HA JSON parse failed for %s", entity_id);
|
|
return false;
|
|
}
|
|
|
|
bool success = false;
|
|
|
|
if (attribute && attribute[0]) {
|
|
// Get attribute from attributes object
|
|
cJSON *attrs = cJSON_GetObjectItem(root, "attributes");
|
|
if (attrs) {
|
|
cJSON *attr_val = cJSON_GetObjectItem(attrs, attribute);
|
|
if (attr_val) {
|
|
if (cJSON_IsNumber(attr_val)) {
|
|
snprintf(out_value, out_size, "%.1f", attr_val->valuedouble);
|
|
success = true;
|
|
} else if (cJSON_IsString(attr_val)) {
|
|
strncpy(out_value, attr_val->valuestring, out_size - 1);
|
|
out_value[out_size - 1] = '\0';
|
|
success = true;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Get state value
|
|
cJSON *state = cJSON_GetObjectItem(root, "state");
|
|
if (state && cJSON_IsString(state)) {
|
|
strncpy(out_value, state->valuestring, out_size - 1);
|
|
out_value[out_size - 1] = '\0';
|
|
success = true;
|
|
}
|
|
}
|
|
|
|
cJSON_Delete(root);
|
|
return success;
|
|
}
|
|
|
|
/**
|
|
* @brief Fetch all enabled HA sensors and update cached values
|
|
*/
|
|
static void fetch_all_ha_sensors(void)
|
|
{
|
|
if (!wifi_connected || !ha_url[0] || !ha_token[0]) {
|
|
return;
|
|
}
|
|
|
|
uint32_t now = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
|
|
|
for (int i = 0; i < MAX_HA_SENSORS; i++) {
|
|
if (ha_sensors[i].entity_id[0] && ha_sensors[i].enabled) {
|
|
char value[16];
|
|
if (fetch_ha_entity(ha_sensors[i].entity_id, ha_sensors[i].attribute,
|
|
value, sizeof(value))) {
|
|
strncpy(ha_sensors[i].cached_value, value, sizeof(ha_sensors[i].cached_value) - 1);
|
|
ha_sensors[i].cached_value[sizeof(ha_sensors[i].cached_value) - 1] = '\0';
|
|
ha_sensors[i].last_update = now;
|
|
ESP_LOGI(TAG, "HA sensor %s = %s", ha_sensors[i].entity_id, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
last_ha_fetch = now;
|
|
}
|
|
|
|
/**
|
|
* @brief TCP streaming server task
|
|
* Receives raw 4bpp frames (12760 bytes each) and displays them
|
|
*/
|
|
// Static buffer for streaming to avoid stack overflow
|
|
static uint8_t stream_frame_buffer[IMG_BUFFER_SIZE];
|
|
|
|
static void stream_server_task(void *arg)
|
|
{
|
|
struct sockaddr_in server_addr, client_addr;
|
|
socklen_t client_len = sizeof(client_addr);
|
|
int listen_sock, client_sock;
|
|
|
|
ESP_LOGI(TAG, "Starting video stream server on port %d", STREAM_PORT);
|
|
|
|
// Create socket
|
|
listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
|
if (listen_sock < 0) {
|
|
ESP_LOGE(TAG, "Failed to create stream socket");
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
// Allow socket reuse
|
|
int opt = 1;
|
|
setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
|
|
|
|
// Bind
|
|
server_addr.sin_family = AF_INET;
|
|
server_addr.sin_addr.s_addr = INADDR_ANY;
|
|
server_addr.sin_port = htons(STREAM_PORT);
|
|
|
|
if (bind(listen_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
|
|
ESP_LOGE(TAG, "Failed to bind stream socket");
|
|
close(listen_sock);
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
// Listen
|
|
if (listen(listen_sock, 1) < 0) {
|
|
ESP_LOGE(TAG, "Failed to listen on stream socket");
|
|
close(listen_sock);
|
|
vTaskDelete(NULL);
|
|
return;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "Stream server listening on port %d", STREAM_PORT);
|
|
|
|
while (1) {
|
|
// Accept connection
|
|
client_sock = accept(listen_sock, (struct sockaddr *)&client_addr, &client_len);
|
|
if (client_sock < 0) {
|
|
ESP_LOGW(TAG, "Accept failed");
|
|
continue;
|
|
}
|
|
|
|
char addr_str[16];
|
|
inet_ntoa_r(client_addr.sin_addr, addr_str, sizeof(addr_str));
|
|
ESP_LOGI(TAG, "Stream client connected from %s", addr_str);
|
|
|
|
streaming_active = true;
|
|
showstate = 12; // Switch to uploaded image display
|
|
showallowadvance = 0; // Disable auto-advance during streaming
|
|
|
|
// Set socket timeout
|
|
struct timeval timeout = { .tv_sec = 5, .tv_usec = 0 };
|
|
setsockopt(client_sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
|
|
|
// Receive frames
|
|
while (1) {
|
|
int total_received = 0;
|
|
int remaining = IMG_BUFFER_SIZE;
|
|
|
|
// Receive one complete frame
|
|
while (remaining > 0) {
|
|
int received = recv(client_sock, stream_frame_buffer + total_received, remaining, 0);
|
|
if (received <= 0) {
|
|
if (received == 0) {
|
|
ESP_LOGI(TAG, "Stream client disconnected");
|
|
} else if (errno != EAGAIN && errno != EWOULDBLOCK) {
|
|
ESP_LOGW(TAG, "Stream recv error: %d", errno);
|
|
}
|
|
goto disconnect;
|
|
}
|
|
total_received += received;
|
|
remaining -= received;
|
|
}
|
|
|
|
// Copy frame to display buffer
|
|
memcpy(uploaded_image, stream_frame_buffer, IMG_BUFFER_SIZE);
|
|
has_uploaded_image = true;
|
|
}
|
|
|
|
disconnect:
|
|
close(client_sock);
|
|
streaming_active = false;
|
|
ESP_LOGI(TAG, "Stream ended, received frames displayed");
|
|
}
|
|
|
|
close(listen_sock);
|
|
vTaskDelete(NULL);
|
|
}
|
|
|
|
// HTML page for web interface
|
|
static const char *html_page =
|
|
"<!DOCTYPE html><html><head><title>Channel3 ESP32</title>"
|
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
"<style>"
|
|
"@import url('https://fonts.googleapis.com/css2?family=VT323&display=swap');"
|
|
"*{box-sizing:border-box}"
|
|
"html{scrollbar-color:#1a5a1a #0a0a0a;scrollbar-width:thin}"
|
|
"body{font-family:'VT323',monospace;background:#0a0a0a;color:#33ff33;padding:15px;font-size:18px;line-height:1.3;margin:0;"
|
|
"min-height:100vh;background-image:repeating-linear-gradient(0deg,rgba(0,0,0,0.15),rgba(0,0,0,0.15) 1px,transparent 1px,transparent 2px)}"
|
|
".crt::before{content:'';position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;"
|
|
"background:repeating-linear-gradient(0deg,rgba(0,0,0,0.1),rgba(0,0,0,0.1) 1px,transparent 1px,transparent 2px);z-index:1000}"
|
|
".container{max-width:900px;margin:0 auto}"
|
|
".grid{display:grid;grid-template-columns:1fr;gap:15px;align-items:start}@media(min-width:700px){.grid{grid-template-columns:1fr 1fr}}"
|
|
".full{grid-column:1/-1}"
|
|
"h1{color:#33ff33;text-shadow:0 0 10px #33ff33,0 0 20px #33ff33;margin:0 0 5px 0;font-size:24px}"
|
|
".subtitle{color:#00aa00;margin-bottom:15px;font-size:14px}"
|
|
"h2{color:#33ff33;border-left:3px solid #33ff33;padding:5px 10px;margin:0 0 10px 0;font-size:18px;"
|
|
"background:rgba(51,255,51,0.05)}"
|
|
".panel{background:rgba(0,40,0,0.3);padding:12px;border:1px solid #1a5a1a;margin:0}"
|
|
".status{background:rgba(0,40,0,0.4);padding:10px;border:1px solid #33ff33;margin-bottom:15px;font-size:16px}"
|
|
".btn{background:#0a0a0a;color:#33ff33;border:1px solid #33ff33;padding:6px 12px;margin:3px;cursor:pointer;"
|
|
"font-family:'VT323',monospace;font-size:15px;text-transform:uppercase;transition:all 0.15s}"
|
|
".btn:hover{background:#33ff33;color:#0a0a0a;box-shadow:0 0 10px #33ff33}"
|
|
".btn.active{background:#00ff00;color:#0a0a0a}"
|
|
".btn-sm{padding:3px 8px;font-size:14px}"
|
|
".flex{display:flex;flex-wrap:wrap;gap:8px;align-items:center}"
|
|
".gap-sm{gap:5px}"
|
|
"input,select{background:#0a0a0a;border:1px solid #1a5a1a;color:#33ff33;padding:5px 8px;"
|
|
"font-family:'VT323',monospace;font-size:15px}"
|
|
"input:focus,select:focus{border-color:#33ff33;outline:none}"
|
|
"input[type=number]{width:55px}"
|
|
"input[type=checkbox]{width:16px;height:16px;accent-color:#33ff33}"
|
|
"label{color:#00cc00;font-size:15px;display:flex;align-items:center;gap:5px}"
|
|
".jamcolor{display:inline-block;width:24px;height:24px;cursor:pointer;border:2px solid #1a1a1a}"
|
|
".jamcolor:hover{border-color:#33ff33}.jamcolor.active{border-color:#33ff33;box-shadow:0 0 8px #33ff33}"
|
|
"#preview{border:2px solid #33ff33;background:#000;image-rendering:pixelated;max-width:100%}"
|
|
".msg{margin:8px 0;color:#00ff00;font-size:15px}"
|
|
"b{color:#66ff66}::placeholder{color:#1a5a1a}"
|
|
".sep{border-top:1px solid #1a5a1a;margin:10px 0;padding-top:10px}"
|
|
"@keyframes flicker{0%,100%{opacity:1}92%{opacity:1}93%{opacity:0.8}94%{opacity:1}}"
|
|
".title-wrap{animation:flicker 4s infinite}"
|
|
"::-webkit-scrollbar{width:10px}::-webkit-scrollbar-track{background:#0a0a0a}"
|
|
"::-webkit-scrollbar-thumb{background:#1a5a1a;border:1px solid #33ff33}"
|
|
"</style></head><body class='crt'><div class='container'>"
|
|
"<div class='title-wrap'><h1>// CHANNEL3 ESP32</h1><div class='subtitle'>[ RF BROADCAST TERMINAL v1.0 ]</div></div>"
|
|
"<div class='status' id='status'>> INITIALIZING...</div>"
|
|
"<div class='grid'>"
|
|
"<div><h2>> TX_CONTROL</h2><div class='panel'>"
|
|
"<div class='flex'><button class='btn' id='videoBtn' onclick='toggleVideo()'>VIDEO: ON</button>"
|
|
"<label><input type='checkbox' id='autoAdv' onchange='setControl()'> Auto</label>"
|
|
"<label>Scr:<input type='number' id='screenNum' min='0' max='17' value='0' onchange='setControl()'></label>"
|
|
"<button class='btn' onclick='setControl()'>GO</button></div>"
|
|
"<div class='sep'><b>Jam:</b> <button class='btn btn-sm' id='jamOff' onclick='setJam(-1)'>OFF</button>"
|
|
"<span id='jamColors' class='flex gap-sm' style='display:inline-flex;margin-left:5px'></span></div>"
|
|
"</div></div>"
|
|
"<div><h2>> CALIBRATION</h2><div class='panel'>"
|
|
"<div class='flex'><label>L:<input type='number' id='marginL' min='0' max='50' value='0' oninput='previewMargins()'></label>"
|
|
"<label>T:<input type='number' id='marginT' min='0' max='50' value='0' oninput='previewMargins()'></label>"
|
|
"<label>R:<input type='number' id='marginR' min='0' max='50' value='0' oninput='previewMargins()'></label>"
|
|
"<label>B:<input type='number' id='marginB' min='0' max='50' value='0' oninput='previewMargins()'></label></div>"
|
|
"<div class='flex' style='margin-top:8px'><button class='btn' onclick='saveMargins()'>SAVE</button>"
|
|
"<button class='btn' onclick='quickScreen(14)'>TEST</button><button class='btn' onclick='resetMargins()'>RESET</button></div>"
|
|
"<div class='sep'><b>Backup/Restore:</b></div>"
|
|
"<div class='flex'><button class='btn' onclick='exportSettings()'>EXPORT</button>"
|
|
"<input type='file' id='importFile' accept='.json' style='width:120px'>"
|
|
"<button class='btn' onclick='importSettings()'>IMPORT</button></div>"
|
|
"<div id='backupStatus' class='msg'></div>"
|
|
"</div></div>"
|
|
"<div><h2>> IMAGE_UPLOAD</h2><div class='panel'>"
|
|
"<div class='flex'><input type='file' id='imgFile' accept='image/*' style='width:150px'>"
|
|
"<button class='btn' onclick='uploadImage()'>UPLOAD</button></div>"
|
|
"<div style='margin-top:8px'><canvas id='preview' width='116' height='220'></canvas></div>"
|
|
"<div id='imgStatus' class='msg'></div></div></div>"
|
|
"<div><h2>> DEMO_SCREENS</h2><div class='panel'><div class='flex'>"
|
|
"<button class='btn' onclick='quickScreen(0)'>STATUS</button>"
|
|
"<button class='btn' onclick='quickScreen(2)'>SYSINFO</button>"
|
|
"<button class='btn' onclick='quickScreen(6)'>LINES</button>"
|
|
"<button class='btn' onclick='quickScreen(7)'>SPHERES</button>"
|
|
"<button class='btn' onclick='quickScreen(8)'>TERRAIN</button>"
|
|
"<button class='btn' onclick='quickScreen(11)'>COLORS</button>"
|
|
"<button class='btn' onclick='quickScreen(12)'>IMAGE</button>"
|
|
"<button class='btn' onclick='quickScreen(13)'>WEATHER</button>"
|
|
"</div></div></div>"
|
|
"<div class='full'><h2>> ROTATION</h2><div class='panel'>"
|
|
"<div id='rotationList' style='margin-bottom:8px'></div>"
|
|
"<div class='flex'><label>Add:<select id='rotType'><option value='0'>Weather</option><option value='1'>Clock</option><option value='2'>HA Sensor</option><option value='3'>Image</option></select></label>"
|
|
"<select id='rotSensor' style='display:none;width:80px'></select>"
|
|
"<label>Dur:<input id='rotDur' type='number' value='15' style='width:45px'>s</label>"
|
|
"<button class='btn' onclick='addRotSlot()'>ADD</button></div>"
|
|
"<div class='sep'><b>Transition:</b></div>"
|
|
"<div class='flex'><label>Effect:<select id='transType' onchange='setTransition()'>"
|
|
"<option value='0'>None</option><option value='1'>Fade</option><option value='2'>Wipe Left</option>"
|
|
"<option value='3'>Wipe Right</option><option value='4'>Wipe Down</option><option value='5'>Wipe Up</option>"
|
|
"<option value='6'>Dissolve</option></select></label>"
|
|
"<label>Speed:<input id='transSpeed' type='number' min='1' max='50' value='12' style='width:45px' onchange='setTransition()'></label>"
|
|
"<button class='btn' onclick='testTransition()'>TEST</button></div>"
|
|
"</div></div>"
|
|
"<div><h2>> MQTT_ALERTS</h2><div class='panel'>"
|
|
"<div class='flex'><label>Broker:<input id='mqttBroker' style='width:100px'></label>"
|
|
"<label>Port:<input id='mqttPort' type='number' style='width:55px'></label></div>"
|
|
"<div class='flex' style='margin-top:5px'><label>User:<input id='mqttUser' style='width:80px'></label>"
|
|
"<label>Pass:<input id='mqttPass' type='password' style='width:80px'></label></div>"
|
|
"<div class='sep'><b>Topics:</b></div>"
|
|
"<div class='flex gap-sm'><input id='topic0' placeholder='topic' style='width:120px'><input id='msg0' placeholder='msg' style='width:90px'></div>"
|
|
"<div class='flex gap-sm' style='margin-top:3px'><input id='topic1' placeholder='topic' style='width:120px'><input id='msg1' placeholder='msg' style='width:90px'></div>"
|
|
"<div class='flex gap-sm' style='margin-top:3px'><input id='topic2' placeholder='topic' style='width:120px'><input id='msg2' placeholder='msg' style='width:90px'></div>"
|
|
"<div class='flex gap-sm' style='margin-top:3px'><input id='topic3' placeholder='topic' style='width:120px'><input id='msg3' placeholder='msg' style='width:90px'></div>"
|
|
"<div class='flex' style='margin-top:8px'><button class='btn' onclick='saveMqtt()'>SAVE</button>"
|
|
"<button class='btn' onclick='testAlert(0)'>TEST1</button><button class='btn' onclick='testAlert(1)'>TEST2</button></div>"
|
|
"<div id='mqttStatus' class='msg'></div></div></div>"
|
|
"<div><h2>> HOME_ASSISTANT</h2><div class='panel'>"
|
|
"<div class='flex'><label>URL:<input id='haUrl' placeholder='http://ip:8123' style='width:140px'></label></div>"
|
|
"<div class='flex' style='margin-top:5px'><label>Token:<input id='haToken' type='password' placeholder='access token' style='width:160px'></label></div>"
|
|
"<div class='flex' style='margin-top:5px'><label>Poll(ms):<input id='haInterval' type='number' value='60000' style='width:70px'></label>"
|
|
"<button class='btn' onclick='saveHaConfig()'>SAVE</button></div>"
|
|
"<div id='haStatus' class='msg'></div>"
|
|
"<div class='sep'><b>Sensors:</b></div><div id='haSensorList'></div>"
|
|
"<div class='sep'><b>Add:</b></div>"
|
|
"<div class='flex'><input id='haEntityId' placeholder='sensor.temp' style='width:140px'><button class='btn' onclick='fetchHaEntity()'>FETCH</button></div>"
|
|
"<div class='flex' style='margin-top:5px'><label>Attr:<select id='haAttr' style='width:80px'><option value=''>State</option></select></label>"
|
|
"<label>Name:<input id='haName' placeholder='Name' style='width:60px'></label></div>"
|
|
"<div class='flex' style='margin-top:5px'><label>Type:<select id='haType' onchange='toggleGaugeOpts()'><option value='0'>Text</option><option value='1'>Gauge</option></select></label>"
|
|
"<span id='gaugeOpts' style='display:none'><label>Min:<input id='haMin' type='number' value='0' style='width:45px'></label>"
|
|
"<label>Max:<input id='haMax' type='number' value='100' style='width:45px'></label></span></div>"
|
|
"<div style='margin-top:5px'><button class='btn' onclick='addHaSensor()'>ADD SENSOR</button></div>"
|
|
"</div></div>"
|
|
"</div></div>"
|
|
"<script>"
|
|
"var W=116,H=220;"
|
|
"var pal=[[0,0,0],[0,0,170],[0,170,0],[0,170,170],[170,0,0],[170,0,170],[170,85,0],[170,170,170],"
|
|
"[85,85,85],[85,85,255],[85,255,85],[85,255,255],[255,85,85],[255,85,255],[255,255,85],[255,255,255]];"
|
|
"var colors=['#000','#00a','#0a0','#0aa','#a00','#a0a','#a50','#aaa','#555','#55f','#5f5','#5ff','#f55','#f5f','#ff5','#fff'];"
|
|
"var cvs=document.getElementById('preview'),ctx=cvs.getContext('2d');"
|
|
"var imgData=null;"
|
|
"var jc=document.getElementById('jamColors');"
|
|
"for(var i=0;i<16;i++){var d=document.createElement('div');d.className='jamcolor';d.style.background=colors[i];"
|
|
"d.onclick=(function(n){return function(){setJam(n)};})(i);d.id='jc'+i;jc.appendChild(d);}"
|
|
"document.getElementById('imgFile').onchange=function(e){"
|
|
"var f=e.target.files[0];if(!f)return;"
|
|
"var img=new Image();img.onload=function(){"
|
|
"ctx.fillStyle='#000';ctx.fillRect(0,0,W,H);"
|
|
"var PAR=2.53,DW=W*PAR;"
|
|
"var scale=Math.min(DW/img.width,H/img.height),nw=img.width*scale/PAR,nh=img.height*scale;"
|
|
"var ox=(W-nw)/2,oy=(H-nh)/2;"
|
|
"ctx.drawImage(img,ox,oy,nw,nh);"
|
|
"var src=ctx.getImageData(0,0,W,H);"
|
|
"imgData=dither(src);"
|
|
"ctx.putImageData(imgData,0,0);"
|
|
"document.getElementById('imgStatus').innerText='Image ready. Click Upload to send.';"
|
|
"};img.src=URL.createObjectURL(f);};"
|
|
"function dither(src){"
|
|
"var d=src.data,out=ctx.createImageData(W,H),o=out.data;"
|
|
"var err=[];for(var i=0;i<W*H*3;i++)err[i]=0;"
|
|
"for(var y=0;y<H;y++)for(var x=0;x<W;x++){"
|
|
"var i=(y*W+x)*4,ei=(y*W+x)*3;"
|
|
"var r=d[i]+err[ei],g=d[i+1]+err[ei+1],b=d[i+2]+err[ei+2];"
|
|
"var best=0,bd=999999;"
|
|
"for(var c=0;c<16;c++){var dr=r-pal[c][0],dg=g-pal[c][1],db=b-pal[c][2],dist=dr*dr+dg*dg+db*db;"
|
|
"if(dist<bd){bd=dist;best=c;}}"
|
|
"o[i]=pal[best][0];o[i+1]=pal[best][1];o[i+2]=pal[best][2];o[i+3]=255;"
|
|
"var er=r-pal[best][0],eg=g-pal[best][1],eb=b-pal[best][2];"
|
|
"if(x<W-1){err[ei+3]+=er*7/16;err[ei+4]+=eg*7/16;err[ei+5]+=eb*7/16;}"
|
|
"if(y<H-1){if(x>0){err[ei+W*3-3]+=er*3/16;err[ei+W*3-2]+=eg*3/16;err[ei+W*3-1]+=eb*3/16;}"
|
|
"err[ei+W*3]+=er*5/16;err[ei+W*3+1]+=eg*5/16;err[ei+W*3+2]+=eb*5/16;"
|
|
"if(x<W-1){err[ei+W*3+3]+=er*1/16;err[ei+W*3+4]+=eg*1/16;err[ei+W*3+5]+=eb*1/16;}}}"
|
|
"return out;}"
|
|
"function uploadImage(){"
|
|
"if(!imgData){alert('Select an image first');return;}"
|
|
"var bin=new Uint8Array(W*H/2);"
|
|
"for(var y=0;y<H;y++)for(var x=0;x<W;x+=2){"
|
|
"var i1=(y*W+x)*4,i2=(y*W+x+1)*4;"
|
|
"var c1=findColor(imgData.data[i1],imgData.data[i1+1],imgData.data[i1+2]);"
|
|
"var c2=findColor(imgData.data[i2],imgData.data[i2+1],imgData.data[i2+2]);"
|
|
"bin[(y*W+x)/2]=(c1<<4)|c2;}"
|
|
"document.getElementById('imgStatus').innerText='Uploading...';"
|
|
"fetch('/upload',{method:'POST',body:bin}).then(r=>r.text()).then(t=>{"
|
|
"document.getElementById('imgStatus').innerText=t+' (add to rotation or click IMAGE)';"
|
|
"}).catch(e=>{document.getElementById('imgStatus').innerText='Error: '+e;});}"
|
|
"function findColor(r,g,b){for(var i=0;i<16;i++)if(pal[i][0]==r&&pal[i][1]==g&&pal[i][2]==b)return i;return 0;}"
|
|
"var marginsLoaded=false;"
|
|
"function updateStatus(){fetch('/status').then(r=>r.json()).then(d=>{"
|
|
"document.getElementById('status').innerHTML='> FRAME: '+d.frame+' | SCREEN: '+d.screen+"
|
|
"' | AUTO: '+(d.auto?'ON':'OFF')+' | JAM: '+(d.jam<0?'OFF':d.jam)+' | IP: '+d.ip;"
|
|
"document.getElementById('autoAdv').checked=d.auto;document.getElementById('screenNum').value=d.screen;"
|
|
"document.getElementById('videoBtn').innerText='VIDEO: '+(d.video?'ON':'OFF');"
|
|
"document.getElementById('videoBtn').style.background=d.video?'#33ff33':'#330000';"
|
|
"document.getElementById('videoBtn').style.color=d.video?'#0a0a0a':'#ff3333';"
|
|
"document.getElementById('videoBtn').style.borderColor=d.video?'#33ff33':'#ff3333';"
|
|
"document.getElementById('jamOff').className='btn'+(d.jam<0?' active':'');"
|
|
"for(var i=0;i<16;i++)document.getElementById('jc'+i).className='jamcolor'+(d.jam==i?' active':'');"
|
|
"if(d.margins&&!marginsLoaded){document.getElementById('marginL').value=d.margins.left;"
|
|
"document.getElementById('marginT').value=d.margins.top;"
|
|
"document.getElementById('marginR').value=d.margins.right;"
|
|
"document.getElementById('marginB').value=d.margins.bottom;marginsLoaded=true;}"
|
|
"}).catch(e=>console.log(e));}"
|
|
"function toggleVideo(){var btn=document.getElementById('videoBtn');var isOn=btn.innerText.includes('ON');"
|
|
"fetch('/video?on='+(isOn?0:1)).then(()=>updateStatus());}"
|
|
"function previewMargins(){var l=document.getElementById('marginL').value||0,t=document.getElementById('marginT').value||0,"
|
|
"r=document.getElementById('marginR').value||0,b=document.getElementById('marginB').value||0;"
|
|
"fetch('/margins/preview?l='+l+'&t='+t+'&r='+r+'&b='+b);}"
|
|
"function saveMargins(){var l=document.getElementById('marginL').value||0,t=document.getElementById('marginT').value||0,"
|
|
"r=document.getElementById('marginR').value||0,b=document.getElementById('marginB').value||0;"
|
|
"fetch('/margins?l='+l+'&t='+t+'&r='+r+'&b='+b).then(()=>{marginsLoaded=false;updateStatus();});}"
|
|
"function resetMargins(){document.getElementById('marginL').value=0;document.getElementById('marginT').value=0;"
|
|
"document.getElementById('marginR').value=0;document.getElementById('marginB').value=0;previewMargins();}"
|
|
"function setControl(){var adv=document.getElementById('autoAdv').checked?1:0,scr=document.getElementById('screenNum').value;"
|
|
"fetch('/control?screen='+scr+'&auto='+adv).then(()=>updateStatus());}"
|
|
"function setJam(n){fetch('/jam?c='+n).then(()=>updateStatus());}"
|
|
"function quickScreen(n){document.getElementById('screenNum').value=n;document.getElementById('autoAdv').checked=false;"
|
|
"fetch('/control?screen='+n+'&auto=0').then(()=>updateStatus());}"
|
|
"function loadMqtt(){fetch('/mqtt/status').then(r=>r.json()).then(d=>{"
|
|
"document.getElementById('mqttBroker').value=d.broker;"
|
|
"document.getElementById('mqttPort').value=d.port;"
|
|
"document.getElementById('mqttUser').value=d.user||'';"
|
|
"document.getElementById('mqttPass').value='';"
|
|
"for(var i=0;i<4;i++){document.getElementById('topic'+i).value=d.alerts[i]?d.alerts[i].topic:'';"
|
|
"document.getElementById('msg'+i).value=d.alerts[i]?d.alerts[i].message:'';}"
|
|
"document.getElementById('mqttStatus').innerHTML='> STATUS: '+(d.connected?'<span style=\"color:#33ff33\">CONNECTED</span>':'<span style=\"color:#ff3333\">DISCONNECTED</span>');"
|
|
"}).catch(e=>console.log(e));}"
|
|
"function saveMqtt(){var q='broker='+encodeURIComponent(document.getElementById('mqttBroker').value);"
|
|
"q+='&port='+document.getElementById('mqttPort').value;"
|
|
"q+='&user='+encodeURIComponent(document.getElementById('mqttUser').value);"
|
|
"var p=document.getElementById('mqttPass').value;if(p)q+='&pass='+encodeURIComponent(p);"
|
|
"for(var i=0;i<4;i++){q+='&t'+i+'='+encodeURIComponent(document.getElementById('topic'+i).value);"
|
|
"q+='&m'+i+'='+encodeURIComponent(document.getElementById('msg'+i).value);}"
|
|
"fetch('/mqtt/config?'+q).then(()=>loadMqtt());}"
|
|
"function testAlert(n){fetch('/mqtt/test?n='+n);}"
|
|
"var haConfig={sensors:[]};"
|
|
"function loadHaConfig(){fetch('/ha/status').then(r=>r.json()).then(d=>{"
|
|
"haConfig=d;document.getElementById('haUrl').value=d.url||'';"
|
|
"document.getElementById('haInterval').value=d.interval||60000;"
|
|
"document.getElementById('haStatus').innerHTML=d.token_set?'<span style=\"color:#33ff33\">> TOKEN OK</span>':'<span style=\"color:#ff3333\">> NO TOKEN</span>';"
|
|
"renderHaSensors();}).catch(e=>console.log(e));}"
|
|
"function saveHaConfig(){var q='url='+encodeURIComponent(document.getElementById('haUrl').value);"
|
|
"var t=document.getElementById('haToken').value;if(t)q+='&token='+encodeURIComponent(t);"
|
|
"q+='&interval='+document.getElementById('haInterval').value;"
|
|
"fetch('/ha/config?'+q).then(()=>loadHaConfig());}"
|
|
"function renderHaSensors(){var h='';for(var i=0;i<8;i++){var s=haConfig.sensors[i];"
|
|
"if(s&&s.entity_id){h+='<div class=\"row\" style=\"margin-bottom:5px\"><span style=\"color:#4ecca3\">'+i+': '+(s.name||'(unnamed)')+' ('+s.entity_id+')</span>"
|
|
"<span style=\"margin-left:10px\">= '+s.value+'</span><br>"
|
|
"<input id=\"rename'+i+'\" placeholder=\"New name\" style=\"width:80px;margin-top:3px\" value=\"'+s.name+'\">"
|
|
"<button class=\"btn\" style=\"padding:2px 8px;margin-left:5px\" onclick=\"renameHaSensor('+i+')\">Rename</button>"
|
|
"<button class=\"btn\" style=\"padding:2px 8px;margin-left:5px\" onclick=\"removeHaSensor('+i+')\">Remove</button></div>';}}"
|
|
"if(!h)h='<div class=\"row\" style=\"color:#888\">No sensors configured</div>';"
|
|
"document.getElementById('haSensorList').innerHTML=h;updateRotSensorSelect();}"
|
|
"function renameHaSensor(idx){var n=document.getElementById('rename'+idx).value;"
|
|
"fetch('/ha/sensor/rename?idx='+idx+'&name='+encodeURIComponent(n)).then(()=>loadHaConfig());}"
|
|
"function fetchHaEntity(){var eid=document.getElementById('haEntityId').value;if(!eid)return;"
|
|
"fetch('/ha/entity?entity_id='+encodeURIComponent(eid)).then(r=>r.json()).then(d=>{"
|
|
"var sel=document.getElementById('haAttr');sel.innerHTML='<option value=\"\">State ('+d.state+')</option>';"
|
|
"if(d.attributes){for(var k in d.attributes){if(typeof d.attributes[k]!='object')"
|
|
"sel.innerHTML+='<option value=\"'+k+'\">'+k+' ('+d.attributes[k]+')</option>';}}}).catch(e=>alert('Error: '+e));}"
|
|
"function toggleGaugeOpts(){document.getElementById('gaugeOpts').style.display="
|
|
"document.getElementById('haType').value=='1'?'inline':'none';}"
|
|
"function addHaSensor(){var q='entity_id='+encodeURIComponent(document.getElementById('haEntityId').value);"
|
|
"q+='&attr='+encodeURIComponent(document.getElementById('haAttr').value);"
|
|
"q+='&name='+encodeURIComponent(document.getElementById('haName').value);"
|
|
"q+='&type='+document.getElementById('haType').value;"
|
|
"q+='&min='+document.getElementById('haMin').value;"
|
|
"q+='&max='+document.getElementById('haMax').value+'&enabled=1';"
|
|
"fetch('/ha/sensor/add?'+q).then(()=>loadHaConfig());}"
|
|
"function removeHaSensor(idx){fetch('/ha/sensor/remove?idx='+idx).then(()=>loadHaConfig());}"
|
|
"var rotConfig={slots:[]};"
|
|
"function loadRotation(){fetch('/rotation/status').then(r=>r.json()).then(d=>{"
|
|
"rotConfig=d;renderRotation();}).catch(e=>console.log(e));}"
|
|
"function renderRotation(){var h='';var types=['Weather','Clock','HA Sensor','Image'];"
|
|
"for(var i=0;i<rotConfig.count;i++){var s=rotConfig.slots[i];if(!s)continue;"
|
|
"var name=types[s.type]||'Unknown';if(s.type==2&&haConfig.sensors[s.sensor_idx])"
|
|
"name+=' ('+haConfig.sensors[s.sensor_idx].name+')';"
|
|
"h+='<div class=\"row\"><input type=\"checkbox\" '+(s.enabled?'checked':'')+' onchange=\"setRotSlot('+i+',this.checked)\">"
|
|
"<span>'+name+'</span><input type=\"number\" value=\"'+s.duration+'\" style=\"width:55px;margin-left:10px\" "
|
|
"onblur=\"setRotDur('+i+',this.value)\">s"
|
|
"<button class=\"btn\" style=\"padding:2px 8px;margin-left:10px\" onclick=\"removeRotSlot('+i+')\">X</button></div>';}"
|
|
"if(!h)h='<div class=\"row\" style=\"color:#888\">No rotation slots</div>';"
|
|
"document.getElementById('rotationList').innerHTML=h;}"
|
|
"function updateRotSensorSelect(){var sel=document.getElementById('rotSensor');sel.innerHTML='';"
|
|
"for(var i=0;i<8;i++){var s=haConfig.sensors[i];if(s&&s.entity_id)"
|
|
"sel.innerHTML+='<option value=\"'+i+'\">'+s.name+'</option>';}}"
|
|
"document.getElementById('rotType').onchange=function(){"
|
|
"document.getElementById('rotSensor').style.display=this.value=='2'?'inline':'none';};"
|
|
"function setRotSlot(idx,en){fetch('/rotation/set?idx='+idx+'&enabled='+(en?1:0)).then(()=>loadRotation());}"
|
|
"function setRotDur(idx,dur){fetch('/rotation/set?idx='+idx+'&duration='+dur);}"
|
|
"function addRotSlot(){var t=document.getElementById('rotType').value;"
|
|
"var s=document.getElementById('rotSensor').value||0;"
|
|
"var d=document.getElementById('rotDur').value||15;"
|
|
"fetch('/rotation/add?type='+t+'&sensor='+s+'&duration='+d).then(()=>loadRotation());}"
|
|
"function removeRotSlot(idx){fetch('/rotation/remove?idx='+idx).then(()=>loadRotation());}"
|
|
"function loadTransition(){fetch('/transition/status').then(r=>r.json()).then(d=>{"
|
|
"document.getElementById('transType').value=d.type;"
|
|
"document.getElementById('transSpeed').value=d.speed;}).catch(e=>console.log(e));}"
|
|
"function setTransition(){var t=document.getElementById('transType').value;"
|
|
"var s=document.getElementById('transSpeed').value;"
|
|
"fetch('/transition/set?type='+t+'&speed='+s);}"
|
|
"function testTransition(){fetch('/transition/test');}"
|
|
"function exportSettings(){document.getElementById('backupStatus').innerText='Exporting...';"
|
|
"fetch('/settings/export').then(r=>r.blob()).then(b=>{"
|
|
"var a=document.createElement('a');a.href=URL.createObjectURL(b);"
|
|
"a.download='channel3_settings.json';a.click();"
|
|
"document.getElementById('backupStatus').innerText='Settings exported!';}).catch(e=>{"
|
|
"document.getElementById('backupStatus').innerText='Export failed: '+e;});}"
|
|
"function importSettings(){var f=document.getElementById('importFile').files[0];"
|
|
"if(!f){alert('Select a settings file first');return;}"
|
|
"document.getElementById('backupStatus').innerText='Importing...';"
|
|
"var reader=new FileReader();reader.onload=function(e){"
|
|
"fetch('/settings/import',{method:'POST',body:e.target.result,"
|
|
"headers:{'Content-Type':'application/json'}}).then(r=>r.text()).then(t=>{"
|
|
"document.getElementById('backupStatus').innerText=t;"
|
|
"loadMqtt();loadHaConfig();loadRotation();loadTransition();marginsLoaded=false;updateStatus();"
|
|
"}).catch(e=>{document.getElementById('backupStatus').innerText='Import failed: '+e;});};"
|
|
"reader.readAsText(f);}"
|
|
"updateStatus();loadMqtt();loadHaConfig();loadRotation();loadTransition();setInterval(updateStatus,2000);setInterval(loadHaConfig,10000);"
|
|
"</script></body></html>";
|
|
|
|
/**
|
|
* @brief Handler for GET / - main page
|
|
*/
|
|
static esp_err_t root_handler(httpd_req_t *req)
|
|
{
|
|
httpd_resp_set_type(req, "text/html");
|
|
httpd_resp_send(req, html_page, strlen(html_page));
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /status - JSON status
|
|
*/
|
|
static esp_err_t status_handler(httpd_req_t *req)
|
|
{
|
|
char json[420];
|
|
snprintf(json, sizeof(json),
|
|
"{\"frame\":%d,\"frametime\":%lu,\"screen\":%d,\"auto\":%s,\"jam\":%d,\"wifi\":%s,\"ip\":\"%s\","
|
|
"\"video\":%s,\"margins\":{\"left\":%d,\"top\":%d,\"right\":%d,\"bottom\":%d}}",
|
|
gframe, (unsigned long)last_internal_frametime, showstate,
|
|
showallowadvance ? "true" : "false", (int)jam_color,
|
|
wifi_connected ? "true" : "false", wifi_ip_str,
|
|
video_running ? "true" : "false",
|
|
margin_left, margin_top, margin_right, margin_bottom);
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, json, strlen(json));
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /screen?n=X - change demo screen (legacy)
|
|
*/
|
|
static esp_err_t screen_handler(httpd_req_t *req)
|
|
{
|
|
char buf[32];
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
char param[8];
|
|
if (httpd_query_key_value(buf, "n", param, sizeof(param)) == ESP_OK) {
|
|
int screen = atoi(param);
|
|
if (screen >= 0 && screen <= 14) {
|
|
showstate = screen;
|
|
showallowadvance = (screen == 7) ? 1 : 0;
|
|
framessostate = 0;
|
|
showtemp = 0;
|
|
ESP_LOGI(TAG, "Screen changed to %d", screen);
|
|
if (screen == 13) {
|
|
fetch_weather(); // Refresh weather when switching to weather screen
|
|
}
|
|
}
|
|
}
|
|
}
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /control?screen=X&auto=Y - full control
|
|
*/
|
|
static esp_err_t control_handler(httpd_req_t *req)
|
|
{
|
|
char buf[64];
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
char param[8];
|
|
if (httpd_query_key_value(buf, "screen", param, sizeof(param)) == ESP_OK) {
|
|
int screen = atoi(param);
|
|
if (screen >= 0 && screen <= 14) {
|
|
showstate = screen;
|
|
framessostate = 0;
|
|
showtemp = 0;
|
|
ESP_LOGI(TAG, "Screen changed to %d", screen);
|
|
if (screen == 13) {
|
|
fetch_weather(); // Refresh weather when switching to weather screen
|
|
}
|
|
}
|
|
}
|
|
if (httpd_query_key_value(buf, "auto", param, sizeof(param)) == ESP_OK) {
|
|
showallowadvance = atoi(param) ? 1 : 0;
|
|
ESP_LOGI(TAG, "Auto-advance: %s", showallowadvance ? "ON" : "OFF");
|
|
}
|
|
}
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /jam?c=X - set jam color for RF testing
|
|
* c=-1 disables jam, c=0-15 sets a specific color
|
|
*/
|
|
static esp_err_t jam_handler(httpd_req_t *req)
|
|
{
|
|
char buf[32];
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
char param[8];
|
|
if (httpd_query_key_value(buf, "c", param, sizeof(param)) == ESP_OK) {
|
|
int color = atoi(param);
|
|
if (color >= -1 && color <= 15) {
|
|
jam_color = (int8_t)color;
|
|
if (color >= 0) {
|
|
ESP_LOGI(TAG, "Jam color set to %d - RF test mode", color);
|
|
} else {
|
|
ESP_LOGI(TAG, "Jam color disabled - normal mode");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /video?on=0|1 - toggle video broadcast on/off
|
|
*/
|
|
static esp_err_t video_toggle_handler(httpd_req_t *req)
|
|
{
|
|
char buf[32];
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
char param[8];
|
|
if (httpd_query_key_value(buf, "on", param, sizeof(param)) == ESP_OK) {
|
|
int on = atoi(param);
|
|
if (on && !video_running) {
|
|
video_broadcast_init();
|
|
video_running = true;
|
|
ESP_LOGI(TAG, "Video broadcast started");
|
|
} else if (!on && video_running) {
|
|
video_broadcast_stop();
|
|
video_running = false;
|
|
ESP_LOGI(TAG, "Video broadcast stopped");
|
|
}
|
|
}
|
|
}
|
|
httpd_resp_send(req, video_running ? "ON" : "OFF", video_running ? 2 : 3);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Parse margin values from query string into variables
|
|
*/
|
|
static bool parse_margin_params(const char *buf, int8_t *l, int8_t *t, int8_t *r, int8_t *b)
|
|
{
|
|
bool changed = false;
|
|
char param[8];
|
|
|
|
if (httpd_query_key_value(buf, "l", param, sizeof(param)) == ESP_OK) {
|
|
int val = atoi(param);
|
|
if (val >= 0 && val <= 50) {
|
|
*l = (int8_t)val;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (httpd_query_key_value(buf, "t", param, sizeof(param)) == ESP_OK) {
|
|
int val = atoi(param);
|
|
if (val >= 0 && val <= 50) {
|
|
*t = (int8_t)val;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (httpd_query_key_value(buf, "r", param, sizeof(param)) == ESP_OK) {
|
|
int val = atoi(param);
|
|
if (val >= 0 && val <= 50) {
|
|
*r = (int8_t)val;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (httpd_query_key_value(buf, "b", param, sizeof(param)) == ESP_OK) {
|
|
int val = atoi(param);
|
|
if (val >= 0 && val <= 50) {
|
|
*b = (int8_t)val;
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /margins/preview - preview margins without saving to NVS
|
|
* Updates margin values in memory only for real-time preview
|
|
*/
|
|
static esp_err_t margins_preview_handler(httpd_req_t *req)
|
|
{
|
|
char buf[64];
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
parse_margin_params(buf, &margin_left, &margin_top, &margin_right, &margin_bottom);
|
|
}
|
|
|
|
// Return current margins as JSON (no save)
|
|
char json[128];
|
|
snprintf(json, sizeof(json),
|
|
"{\"left\":%d,\"top\":%d,\"right\":%d,\"bottom\":%d}",
|
|
margin_left, margin_top, margin_right, margin_bottom);
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, json, strlen(json));
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /margins - set and SAVE screen calibration margins to NVS
|
|
*/
|
|
static esp_err_t margins_handler(httpd_req_t *req)
|
|
{
|
|
char buf[64];
|
|
bool changed = false;
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
changed = parse_margin_params(buf, &margin_left, &margin_top, &margin_right, &margin_bottom);
|
|
}
|
|
|
|
if (changed) {
|
|
save_margins();
|
|
ESP_LOGI(TAG, "Margins saved: L=%d T=%d R=%d B=%d",
|
|
margin_left, margin_top, margin_right, margin_bottom);
|
|
}
|
|
|
|
// Return current margins as JSON
|
|
char json[128];
|
|
snprintf(json, sizeof(json),
|
|
"{\"left\":%d,\"top\":%d,\"right\":%d,\"bottom\":%d}",
|
|
margin_left, margin_top, margin_right, margin_bottom);
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, json, strlen(json));
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for POST /upload - receive image data
|
|
*/
|
|
static esp_err_t upload_handler(httpd_req_t *req)
|
|
{
|
|
if (req->content_len != IMG_BUFFER_SIZE) {
|
|
ESP_LOGE(TAG, "Invalid image size: %d (expected %d)", req->content_len, IMG_BUFFER_SIZE);
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid image size");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
int received = 0;
|
|
while (received < IMG_BUFFER_SIZE) {
|
|
int ret = httpd_req_recv(req, (char*)&uploaded_image[received], IMG_BUFFER_SIZE - received);
|
|
if (ret <= 0) {
|
|
if (ret == HTTPD_SOCK_ERR_TIMEOUT) continue;
|
|
ESP_LOGE(TAG, "Image receive error");
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Receive failed");
|
|
return ESP_FAIL;
|
|
}
|
|
received += ret;
|
|
}
|
|
|
|
has_uploaded_image = true;
|
|
ESP_LOGI(TAG, "Image uploaded: %d bytes", received);
|
|
save_uploaded_image(); // Save to NVS for persistence
|
|
httpd_resp_send(req, "Image saved!", -1);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /mqtt/status - Return current MQTT config as JSON
|
|
*/
|
|
static esp_err_t mqtt_status_handler(httpd_req_t *req)
|
|
{
|
|
char response[512];
|
|
int len = snprintf(response, sizeof(response),
|
|
"{\"broker\":\"%s\",\"port\":%d,\"user\":\"%s\",\"connected\":%s,\"alerts\":[",
|
|
mqtt_broker, mqtt_port, mqtt_username,
|
|
mqtt_client ? "true" : "false");
|
|
|
|
for (int i = 0; i < MAX_ALERTS; i++) {
|
|
if (i > 0) len += snprintf(response + len, sizeof(response) - len, ",");
|
|
len += snprintf(response + len, sizeof(response) - len,
|
|
"{\"topic\":\"%s\",\"message\":\"%s\"}",
|
|
alerts[i].topic, alerts[i].message);
|
|
}
|
|
snprintf(response + len, sizeof(response) - len, "]}");
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, response, strlen(response));
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /mqtt - Save MQTT config with credentials and alerts
|
|
*/
|
|
static esp_err_t mqtt_config_handler(httpd_req_t *req)
|
|
{
|
|
char buf[256];
|
|
char param[64];
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
// Parse user
|
|
if (httpd_query_key_value(buf, "user", param, sizeof(param)) == ESP_OK) {
|
|
strncpy(mqtt_username, param, sizeof(mqtt_username)-1);
|
|
}
|
|
// Parse pass
|
|
if (httpd_query_key_value(buf, "pass", param, sizeof(param)) == ESP_OK) {
|
|
strncpy(mqtt_password, param, sizeof(mqtt_password)-1);
|
|
}
|
|
// Parse broker
|
|
if (httpd_query_key_value(buf, "broker", param, sizeof(param)) == ESP_OK) {
|
|
strncpy(mqtt_broker, param, sizeof(mqtt_broker)-1);
|
|
}
|
|
// Parse port
|
|
if (httpd_query_key_value(buf, "port", param, sizeof(param)) == ESP_OK) {
|
|
mqtt_port = atoi(param);
|
|
}
|
|
httpd_resp_send(req, "PARSED", -1);
|
|
return ESP_OK;
|
|
}
|
|
httpd_resp_send(req, "NO QUERY", -1);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /mqtt/test - Trigger test alert
|
|
* Use ?n=0 through ?n=3 to test specific alert, default tests alert 0
|
|
*/
|
|
static esp_err_t mqtt_test_handler(httpd_req_t *req)
|
|
{
|
|
int idx = 0;
|
|
char buf[32];
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
char param[8];
|
|
if (httpd_query_key_value(buf, "n", param, sizeof(param)) == ESP_OK) {
|
|
idx = atoi(param);
|
|
if (idx < 0 || idx >= MAX_ALERTS) idx = 0;
|
|
}
|
|
}
|
|
|
|
// Use message from alerts config, or default
|
|
const char *msg = alerts[idx].message[0] ? alerts[idx].message : "ALERT!";
|
|
strncpy(current_alert_message, msg, sizeof(current_alert_message)-1);
|
|
current_alert_message[sizeof(current_alert_message)-1] = '\0';
|
|
|
|
alert_active = true;
|
|
framessostate = 0; // Reset frame counter for alert duration
|
|
showstate = 15;
|
|
showallowadvance = 0;
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /mqtt/debug - Show MQTT debug info
|
|
*/
|
|
static esp_err_t mqtt_debug_handler(httpd_req_t *req)
|
|
{
|
|
char response[768];
|
|
uint32_t now_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
|
uint32_t last_msg_ago = mqtt_last_msg_time ? (now_ms - mqtt_last_msg_time) / 1000 : 0;
|
|
|
|
int len = snprintf(response, sizeof(response),
|
|
"MQTT Debug Info\n"
|
|
"===============\n"
|
|
"Broker: %s:%d\n"
|
|
"Connected: %s\n"
|
|
"Connect count: %lu\n"
|
|
"Subscriptions confirmed: %d\n\n"
|
|
"Subscribe msg_ids: [%d, %d, %d, %d]\n\n"
|
|
"Configured topics:\n"
|
|
" 0: '%s' -> '%s'\n"
|
|
" 1: '%s' -> '%s'\n"
|
|
" 2: '%s' -> '%s'\n"
|
|
" 3: '%s' -> '%s'\n\n"
|
|
"Last message:\n"
|
|
" Topic: '%s'\n"
|
|
" Data: '%s'\n"
|
|
" %lu seconds ago\n",
|
|
mqtt_broker, mqtt_port,
|
|
mqtt_connected ? "YES" : "NO",
|
|
(unsigned long)mqtt_connect_count,
|
|
mqtt_subscribed_count,
|
|
mqtt_subscribe_msg_ids[0], mqtt_subscribe_msg_ids[1],
|
|
mqtt_subscribe_msg_ids[2], mqtt_subscribe_msg_ids[3],
|
|
alerts[0].topic, alerts[0].message,
|
|
alerts[1].topic, alerts[1].message,
|
|
alerts[2].topic, alerts[2].message,
|
|
alerts[3].topic, alerts[3].message,
|
|
mqtt_last_topic,
|
|
mqtt_last_data,
|
|
(unsigned long)last_msg_ago);
|
|
|
|
httpd_resp_set_type(req, "text/plain");
|
|
httpd_resp_send(req, response, len);
|
|
return ESP_OK;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Home Assistant HTTP Handlers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* @brief Handler for GET /ha/status - Return HA config + all sensor states as JSON
|
|
*/
|
|
static esp_err_t ha_status_handler(httpd_req_t *req)
|
|
{
|
|
char *response = malloc(2048);
|
|
if (!response) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
int len = snprintf(response, 2048,
|
|
"{\"url\":\"%s\",\"token_set\":%s,\"interval\":%lu,\"sensors\":[",
|
|
ha_url, ha_token[0] ? "true" : "false", (unsigned long)ha_poll_interval_ms);
|
|
|
|
for (int i = 0; i < MAX_HA_SENSORS; i++) {
|
|
if (i > 0) len += snprintf(response + len, 2048 - len, ",");
|
|
len += snprintf(response + len, 2048 - len,
|
|
"{\"entity_id\":\"%s\",\"attribute\":\"%s\",\"name\":\"%s\","
|
|
"\"type\":%d,\"min\":%d,\"max\":%d,\"enabled\":%d,\"value\":\"%s\"}",
|
|
ha_sensors[i].entity_id, ha_sensors[i].attribute, ha_sensors[i].name,
|
|
ha_sensors[i].display_type, ha_sensors[i].min_value, ha_sensors[i].max_value,
|
|
ha_sensors[i].enabled, ha_sensors[i].cached_value);
|
|
}
|
|
snprintf(response + len, 2048 - len, "]}");
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, response, strlen(response));
|
|
free(response);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Simple URL decode (handles %XX sequences)
|
|
*/
|
|
static void url_decode(char *dst, const char *src, size_t dst_size)
|
|
{
|
|
size_t i = 0, j = 0;
|
|
while (src[i] && j < dst_size - 1) {
|
|
if (src[i] == '%' && src[i+1] && src[i+2]) {
|
|
char hex[3] = {src[i+1], src[i+2], 0};
|
|
dst[j++] = (char)strtol(hex, NULL, 16);
|
|
i += 3;
|
|
} else if (src[i] == '+') {
|
|
dst[j++] = ' ';
|
|
i++;
|
|
} else {
|
|
dst[j++] = src[i++];
|
|
}
|
|
}
|
|
dst[j] = '\0';
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /ha/config - Save HA connection settings
|
|
* Query params: url, token, interval
|
|
*/
|
|
static esp_err_t ha_config_handler(httpd_req_t *req)
|
|
{
|
|
// Use heap allocation to avoid stack overflow
|
|
char *buf = malloc(512);
|
|
char *param = malloc(300);
|
|
if (!buf || !param) {
|
|
free(buf);
|
|
free(param);
|
|
httpd_resp_send_500(req);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
ESP_LOGI(TAG, "HA config handler called");
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, 512) == ESP_OK) {
|
|
if (httpd_query_key_value(buf, "url", param, 300) == ESP_OK) {
|
|
url_decode(ha_url, param, sizeof(ha_url));
|
|
ESP_LOGI(TAG, "HA URL set: %s", ha_url);
|
|
}
|
|
if (httpd_query_key_value(buf, "token", param, 300) == ESP_OK) {
|
|
url_decode(ha_token, param, sizeof(ha_token));
|
|
ESP_LOGI(TAG, "HA Token set (len=%d)", (int)strlen(ha_token));
|
|
}
|
|
if (httpd_query_key_value(buf, "interval", param, 300) == ESP_OK) {
|
|
int interval = atoi(param);
|
|
if (interval >= 5000 && interval <= 3600000) {
|
|
ha_poll_interval_ms = interval;
|
|
}
|
|
}
|
|
save_ha_config();
|
|
ESP_LOGI(TAG, "HA config saved: url=%s interval=%lu token_len=%d",
|
|
ha_url, (unsigned long)ha_poll_interval_ms, (int)strlen(ha_token));
|
|
}
|
|
|
|
free(buf);
|
|
free(param);
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /ha/entity - Proxy to HA API, return entity state + attributes
|
|
* Query params: entity_id
|
|
*/
|
|
static esp_err_t ha_entity_handler(httpd_req_t *req)
|
|
{
|
|
char buf[128];
|
|
char entity_id[64] = "";
|
|
|
|
ESP_LOGI(TAG, "HA entity fetch request received");
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
httpd_query_key_value(buf, "entity_id", entity_id, sizeof(entity_id));
|
|
}
|
|
|
|
ESP_LOGI(TAG, "HA entity_id: %s", entity_id);
|
|
|
|
if (!entity_id[0]) {
|
|
ESP_LOGW(TAG, "Missing entity_id in request");
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing entity_id");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (!ha_url[0] || !ha_token[0]) {
|
|
ESP_LOGW(TAG, "HA not configured: url=%s token_len=%d", ha_url, (int)strlen(ha_token));
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "HA not configured");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Build URL: {ha_url}/api/states/{entity_id}
|
|
char url[256];
|
|
snprintf(url, sizeof(url), "%s/api/states/%s", ha_url, entity_id);
|
|
ESP_LOGI(TAG, "Fetching HA entity from: %s", url);
|
|
|
|
http_response_len = 0;
|
|
http_response_buffer[0] = '\0';
|
|
|
|
esp_http_client_config_t config = {
|
|
.url = url,
|
|
.event_handler = http_event_handler,
|
|
.timeout_ms = 10000,
|
|
};
|
|
|
|
esp_http_client_handle_t client = esp_http_client_init(&config);
|
|
if (!client) {
|
|
ESP_LOGE(TAG, "Failed to init HTTP client");
|
|
httpd_resp_send_500(req);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
char auth_header[280];
|
|
snprintf(auth_header, sizeof(auth_header), "Bearer %s", ha_token);
|
|
esp_http_client_set_header(client, "Authorization", auth_header);
|
|
esp_http_client_set_header(client, "Content-Type", "application/json");
|
|
|
|
ESP_LOGI(TAG, "Performing HA API request...");
|
|
esp_err_t err = esp_http_client_perform(client);
|
|
int status = esp_http_client_get_status_code(client);
|
|
esp_http_client_cleanup(client);
|
|
|
|
ESP_LOGI(TAG, "HA API response: err=%d status=%d len=%d", err, status, http_response_len);
|
|
|
|
if (err != ESP_OK || status != 200) {
|
|
char errmsg[64];
|
|
snprintf(errmsg, sizeof(errmsg), "HA API error: %d", status);
|
|
ESP_LOGW(TAG, "%s", errmsg);
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, errmsg);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Return raw JSON from HA
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, http_response_buffer, http_response_len);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /ha/sensor/add - Add/update sensor config
|
|
* Query params: idx, entity_id, attr, name, type, min, max, enabled
|
|
*/
|
|
static esp_err_t ha_sensor_add_handler(httpd_req_t *req)
|
|
{
|
|
char buf[384];
|
|
char param[64];
|
|
int idx = -1;
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing params");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (httpd_query_key_value(buf, "idx", param, sizeof(param)) == ESP_OK) {
|
|
idx = atoi(param);
|
|
}
|
|
|
|
// If idx not specified, find first empty slot
|
|
if (idx < 0) {
|
|
for (int i = 0; i < MAX_HA_SENSORS; i++) {
|
|
if (!ha_sensors[i].entity_id[0]) {
|
|
idx = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (idx < 0 || idx >= MAX_HA_SENSORS) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "No available slot");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Parse sensor config (URL decode text fields)
|
|
if (httpd_query_key_value(buf, "entity_id", param, sizeof(param)) == ESP_OK) {
|
|
url_decode(ha_sensors[idx].entity_id, param, sizeof(ha_sensors[idx].entity_id));
|
|
}
|
|
if (httpd_query_key_value(buf, "attr", param, sizeof(param)) == ESP_OK) {
|
|
url_decode(ha_sensors[idx].attribute, param, sizeof(ha_sensors[idx].attribute));
|
|
}
|
|
if (httpd_query_key_value(buf, "name", param, sizeof(param)) == ESP_OK) {
|
|
url_decode(ha_sensors[idx].name, param, sizeof(ha_sensors[idx].name));
|
|
}
|
|
if (httpd_query_key_value(buf, "type", param, sizeof(param)) == ESP_OK) {
|
|
ha_sensors[idx].display_type = atoi(param);
|
|
}
|
|
if (httpd_query_key_value(buf, "min", param, sizeof(param)) == ESP_OK) {
|
|
ha_sensors[idx].min_value = atoi(param);
|
|
}
|
|
if (httpd_query_key_value(buf, "max", param, sizeof(param)) == ESP_OK) {
|
|
ha_sensors[idx].max_value = atoi(param);
|
|
}
|
|
if (httpd_query_key_value(buf, "enabled", param, sizeof(param)) == ESP_OK) {
|
|
ha_sensors[idx].enabled = atoi(param) ? 1 : 0;
|
|
} else {
|
|
ha_sensors[idx].enabled = 1; // Default enabled
|
|
}
|
|
|
|
// Recount sensors
|
|
ha_sensor_count = 0;
|
|
for (int i = 0; i < MAX_HA_SENSORS; i++) {
|
|
if (ha_sensors[i].entity_id[0]) ha_sensor_count++;
|
|
}
|
|
|
|
save_ha_sensor(idx);
|
|
ESP_LOGI(TAG, "HA sensor %d added: %s", idx, ha_sensors[idx].entity_id);
|
|
|
|
char response[32];
|
|
snprintf(response, sizeof(response), "{\"idx\":%d}", idx);
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, response, strlen(response));
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /ha/sensor/remove - Remove sensor config
|
|
* Query params: idx
|
|
*/
|
|
static esp_err_t ha_sensor_remove_handler(httpd_req_t *req)
|
|
{
|
|
char buf[32];
|
|
char param[8];
|
|
int idx = -1;
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
if (httpd_query_key_value(buf, "idx", param, sizeof(param)) == ESP_OK) {
|
|
idx = atoi(param);
|
|
}
|
|
}
|
|
|
|
if (idx < 0 || idx >= MAX_HA_SENSORS) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid idx");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Clear sensor
|
|
memset(&ha_sensors[idx], 0, sizeof(ha_sensor_config_t));
|
|
|
|
// Recount sensors
|
|
ha_sensor_count = 0;
|
|
for (int i = 0; i < MAX_HA_SENSORS; i++) {
|
|
if (ha_sensors[i].entity_id[0]) ha_sensor_count++;
|
|
}
|
|
|
|
save_ha_sensor(idx);
|
|
ESP_LOGI(TAG, "HA sensor %d removed", idx);
|
|
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /ha/sensor/rename - Rename a sensor
|
|
* Query params: idx, name
|
|
*/
|
|
static esp_err_t ha_sensor_rename_handler(httpd_req_t *req)
|
|
{
|
|
char buf[128];
|
|
char param[64];
|
|
int idx = -1;
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing params");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (httpd_query_key_value(buf, "idx", param, sizeof(param)) == ESP_OK) {
|
|
idx = atoi(param);
|
|
}
|
|
|
|
if (idx < 0 || idx >= MAX_HA_SENSORS || !ha_sensors[idx].entity_id[0]) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid sensor idx");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (httpd_query_key_value(buf, "name", param, sizeof(param)) == ESP_OK) {
|
|
url_decode(ha_sensors[idx].name, param, sizeof(ha_sensors[idx].name));
|
|
save_ha_sensor(idx);
|
|
ESP_LOGI(TAG, "HA sensor %d renamed to: %s", idx, ha_sensors[idx].name);
|
|
}
|
|
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Rotation HTTP Handlers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* @brief Handler for GET /rotation/status - Return all rotation slots as JSON
|
|
*/
|
|
static esp_err_t rotation_status_handler(httpd_req_t *req)
|
|
{
|
|
char *response = malloc(1024);
|
|
if (!response) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
int len = snprintf(response, 1024, "{\"count\":%d,\"current\":%d,\"slots\":[", rotation_count, current_rotation_idx);
|
|
|
|
for (int i = 0; i < MAX_ROTATION_SLOTS; i++) {
|
|
if (i > 0) len += snprintf(response + len, 1024 - len, ",");
|
|
len += snprintf(response + len, 1024 - len,
|
|
"{\"type\":%d,\"sensor_idx\":%d,\"enabled\":%d,\"duration\":%d}",
|
|
rotation_slots[i].screen_type, rotation_slots[i].sensor_idx,
|
|
rotation_slots[i].enabled, rotation_slots[i].duration_sec);
|
|
}
|
|
snprintf(response + len, 1024 - len, "]}");
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, response, strlen(response));
|
|
free(response);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /rotation/set - Update a rotation slot
|
|
* Query params: idx, type, sensor, enabled, duration
|
|
*/
|
|
static esp_err_t rotation_set_handler(httpd_req_t *req)
|
|
{
|
|
char buf[128];
|
|
char param[16];
|
|
int idx = -1;
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing params");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (httpd_query_key_value(buf, "idx", param, sizeof(param)) == ESP_OK) {
|
|
idx = atoi(param);
|
|
}
|
|
|
|
if (idx < 0 || idx >= MAX_ROTATION_SLOTS) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid idx");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (httpd_query_key_value(buf, "type", param, sizeof(param)) == ESP_OK) {
|
|
rotation_slots[idx].screen_type = atoi(param);
|
|
}
|
|
if (httpd_query_key_value(buf, "sensor", param, sizeof(param)) == ESP_OK) {
|
|
rotation_slots[idx].sensor_idx = atoi(param);
|
|
}
|
|
if (httpd_query_key_value(buf, "enabled", param, sizeof(param)) == ESP_OK) {
|
|
rotation_slots[idx].enabled = atoi(param) ? 1 : 0;
|
|
}
|
|
if (httpd_query_key_value(buf, "duration", param, sizeof(param)) == ESP_OK) {
|
|
int dur = atoi(param);
|
|
if (dur >= 5 && dur <= 300) {
|
|
rotation_slots[idx].duration_sec = dur;
|
|
}
|
|
}
|
|
|
|
save_rotation_config();
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /rotation/add - Add new rotation slot
|
|
* Query params: type, sensor, duration
|
|
*/
|
|
static esp_err_t rotation_add_handler(httpd_req_t *req)
|
|
{
|
|
if (rotation_count >= MAX_ROTATION_SLOTS) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Max slots reached");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
char buf[64];
|
|
char param[16];
|
|
int idx = rotation_count;
|
|
|
|
rotation_slots[idx].enabled = 1;
|
|
rotation_slots[idx].duration_sec = 15; // Default
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
if (httpd_query_key_value(buf, "type", param, sizeof(param)) == ESP_OK) {
|
|
rotation_slots[idx].screen_type = atoi(param);
|
|
}
|
|
if (httpd_query_key_value(buf, "sensor", param, sizeof(param)) == ESP_OK) {
|
|
rotation_slots[idx].sensor_idx = atoi(param);
|
|
}
|
|
if (httpd_query_key_value(buf, "duration", param, sizeof(param)) == ESP_OK) {
|
|
int dur = atoi(param);
|
|
if (dur >= 5 && dur <= 300) {
|
|
rotation_slots[idx].duration_sec = dur;
|
|
}
|
|
}
|
|
}
|
|
|
|
rotation_count++;
|
|
save_rotation_config();
|
|
|
|
char response[32];
|
|
snprintf(response, sizeof(response), "{\"idx\":%d}", idx);
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, response, strlen(response));
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /rotation/remove - Remove rotation slot
|
|
* Query params: idx
|
|
*/
|
|
static esp_err_t rotation_remove_handler(httpd_req_t *req)
|
|
{
|
|
char buf[32];
|
|
char param[8];
|
|
int idx = -1;
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
if (httpd_query_key_value(buf, "idx", param, sizeof(param)) == ESP_OK) {
|
|
idx = atoi(param);
|
|
}
|
|
}
|
|
|
|
if (idx < 0 || idx >= rotation_count) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid idx");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Shift remaining slots down
|
|
for (int i = idx; i < rotation_count - 1; i++) {
|
|
rotation_slots[i] = rotation_slots[i + 1];
|
|
}
|
|
rotation_count--;
|
|
memset(&rotation_slots[rotation_count], 0, sizeof(rotation_slot_t));
|
|
|
|
// Adjust current index if needed
|
|
if (current_rotation_idx >= rotation_count && rotation_count > 0) {
|
|
current_rotation_idx = 0;
|
|
}
|
|
|
|
save_rotation_config();
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /rotation/reorder - Reorder rotation slots
|
|
* Query params: order (comma-separated indices, e.g., "0,2,1,3")
|
|
*/
|
|
static esp_err_t rotation_reorder_handler(httpd_req_t *req)
|
|
{
|
|
char buf[128];
|
|
char param[64];
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing order param");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (httpd_query_key_value(buf, "order", param, sizeof(param)) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing order param");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Parse order and create new arrangement
|
|
rotation_slot_t temp_slots[MAX_ROTATION_SLOTS];
|
|
memcpy(temp_slots, rotation_slots, sizeof(temp_slots));
|
|
|
|
char *token = strtok(param, ",");
|
|
int new_idx = 0;
|
|
while (token && new_idx < rotation_count) {
|
|
int old_idx = atoi(token);
|
|
if (old_idx >= 0 && old_idx < rotation_count) {
|
|
rotation_slots[new_idx] = temp_slots[old_idx];
|
|
new_idx++;
|
|
}
|
|
token = strtok(NULL, ",");
|
|
}
|
|
|
|
save_rotation_config();
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Transition HTTP Handlers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* @brief Handler for GET /transition/status - Return transition settings as JSON
|
|
*/
|
|
static esp_err_t transition_status_handler(httpd_req_t *req)
|
|
{
|
|
char response[64];
|
|
snprintf(response, sizeof(response), "{\"type\":%d,\"speed\":%d,\"active\":%d}",
|
|
default_transition, default_trans_speed, transition_active);
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_send(req, response, strlen(response));
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /transition/set - Set transition type and/or speed
|
|
* Query params: type (0-6), speed (1-50)
|
|
*/
|
|
static esp_err_t transition_set_handler(httpd_req_t *req)
|
|
{
|
|
char buf[64];
|
|
char param[8];
|
|
|
|
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
|
|
if (httpd_query_key_value(buf, "type", param, sizeof(param)) == ESP_OK) {
|
|
int t = atoi(param);
|
|
if (t >= 0 && t <= 6) {
|
|
default_transition = t;
|
|
}
|
|
}
|
|
if (httpd_query_key_value(buf, "speed", param, sizeof(param)) == ESP_OK) {
|
|
int s = atoi(param);
|
|
if (s >= 1 && s <= 50) {
|
|
default_trans_speed = s;
|
|
}
|
|
}
|
|
}
|
|
|
|
save_transition_config();
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /transition/test - Trigger a test transition
|
|
* Forces a transition by jumping to a different rotation screen
|
|
*/
|
|
static esp_err_t transition_test_handler(httpd_req_t *req)
|
|
{
|
|
// Only test if we have a transition type set
|
|
if (default_transition == TRANS_NONE) {
|
|
httpd_resp_send(req, "No transition set", -1);
|
|
return ESP_OK;
|
|
}
|
|
|
|
// Save current frame for transition
|
|
memcpy(prev_frame, frontframe, sizeof(prev_frame));
|
|
transition_active = 1;
|
|
transition_type = default_transition;
|
|
transition_speed = default_trans_speed;
|
|
transition_progress = 0;
|
|
|
|
// Advance to next rotation slot to show the transition
|
|
int next = advance_rotation();
|
|
showstate = next;
|
|
framessostate = 0;
|
|
showtemp = 0;
|
|
|
|
httpd_resp_send(req, "OK", 2);
|
|
return ESP_OK;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Settings Export/Import Handlers
|
|
// ============================================================================
|
|
|
|
// Base64 encoding table
|
|
static const char b64_table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
|
|
/**
|
|
* @brief Encode binary data to base64
|
|
*/
|
|
static size_t base64_encode(const uint8_t *src, size_t src_len, char *dst, size_t dst_len)
|
|
{
|
|
size_t i, j;
|
|
size_t needed = ((src_len + 2) / 3) * 4 + 1;
|
|
if (dst_len < needed) return 0;
|
|
|
|
for (i = 0, j = 0; i < src_len; i += 3) {
|
|
uint32_t v = src[i] << 16;
|
|
if (i + 1 < src_len) v |= src[i + 1] << 8;
|
|
if (i + 2 < src_len) v |= src[i + 2];
|
|
|
|
dst[j++] = b64_table[(v >> 18) & 0x3F];
|
|
dst[j++] = b64_table[(v >> 12) & 0x3F];
|
|
dst[j++] = (i + 1 < src_len) ? b64_table[(v >> 6) & 0x3F] : '=';
|
|
dst[j++] = (i + 2 < src_len) ? b64_table[v & 0x3F] : '=';
|
|
}
|
|
dst[j] = '\0';
|
|
return j;
|
|
}
|
|
|
|
/**
|
|
* @brief Decode base64 to binary data
|
|
*/
|
|
static size_t base64_decode(const char *src, uint8_t *dst, size_t dst_len)
|
|
{
|
|
size_t src_len = strlen(src);
|
|
size_t i, j;
|
|
int8_t dtable[256];
|
|
|
|
memset(dtable, -1, sizeof(dtable));
|
|
for (i = 0; i < 64; i++) dtable[(uint8_t)b64_table[i]] = i;
|
|
|
|
for (i = 0, j = 0; i < src_len && j < dst_len; i += 4) {
|
|
int8_t a = dtable[(uint8_t)src[i]];
|
|
int8_t b = (i + 1 < src_len) ? dtable[(uint8_t)src[i + 1]] : -1;
|
|
int8_t c = (i + 2 < src_len) ? dtable[(uint8_t)src[i + 2]] : -1;
|
|
int8_t d = (i + 3 < src_len) ? dtable[(uint8_t)src[i + 3]] : -1;
|
|
|
|
if (a < 0 || b < 0) break;
|
|
dst[j++] = (a << 2) | (b >> 4);
|
|
if (c >= 0 && j < dst_len) dst[j++] = (b << 4) | (c >> 2);
|
|
if (d >= 0 && j < dst_len) dst[j++] = (c << 6) | d;
|
|
}
|
|
return j;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for GET /settings/export - Export all settings as JSON
|
|
*/
|
|
static esp_err_t settings_export_handler(httpd_req_t *req)
|
|
{
|
|
// Allocate buffer for JSON response (settings + base64 image)
|
|
// Image is 12760 bytes, base64 is ~17KB, plus ~2KB for other settings
|
|
size_t buf_size = 22000;
|
|
char *response = malloc(buf_size);
|
|
if (!response) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
int len = 0;
|
|
|
|
// Start JSON
|
|
len += snprintf(response + len, buf_size - len, "{");
|
|
|
|
// Margins
|
|
len += snprintf(response + len, buf_size - len,
|
|
"\"margins\":{\"left\":%d,\"top\":%d,\"right\":%d,\"bottom\":%d},",
|
|
margin_left, margin_top, margin_right, margin_bottom);
|
|
|
|
// Transition
|
|
len += snprintf(response + len, buf_size - len,
|
|
"\"transition\":{\"type\":%d,\"speed\":%d},",
|
|
default_transition, default_trans_speed);
|
|
|
|
// MQTT
|
|
len += snprintf(response + len, buf_size - len,
|
|
"\"mqtt\":{\"broker\":\"%s\",\"port\":%d,\"user\":\"%s\",\"pass\":\"%s\",\"alerts\":[",
|
|
mqtt_broker, mqtt_port, mqtt_username, mqtt_password);
|
|
for (int i = 0; i < MAX_ALERTS; i++) {
|
|
if (i > 0) len += snprintf(response + len, buf_size - len, ",");
|
|
len += snprintf(response + len, buf_size - len,
|
|
"{\"topic\":\"%s\",\"message\":\"%s\"}",
|
|
alerts[i].topic, alerts[i].message);
|
|
}
|
|
len += snprintf(response + len, buf_size - len, "]},");
|
|
|
|
// Home Assistant
|
|
len += snprintf(response + len, buf_size - len,
|
|
"\"ha\":{\"url\":\"%s\",\"token\":\"%s\",\"interval\":%lu,\"sensors\":[",
|
|
ha_url, ha_token, (unsigned long)ha_poll_interval_ms);
|
|
for (int i = 0; i < MAX_HA_SENSORS; i++) {
|
|
if (i > 0) len += snprintf(response + len, buf_size - len, ",");
|
|
len += snprintf(response + len, buf_size - len,
|
|
"{\"entity_id\":\"%s\",\"attribute\":\"%s\",\"name\":\"%s\","
|
|
"\"display_type\":%d,\"min_value\":%d,\"max_value\":%d,\"enabled\":%d}",
|
|
ha_sensors[i].entity_id, ha_sensors[i].attribute, ha_sensors[i].name,
|
|
ha_sensors[i].display_type, ha_sensors[i].min_value,
|
|
ha_sensors[i].max_value, ha_sensors[i].enabled);
|
|
}
|
|
len += snprintf(response + len, buf_size - len, "]},");
|
|
|
|
// Rotation
|
|
len += snprintf(response + len, buf_size - len,
|
|
"\"rotation\":{\"count\":%d,\"slots\":[", rotation_count);
|
|
for (int i = 0; i < MAX_ROTATION_SLOTS; i++) {
|
|
if (i > 0) len += snprintf(response + len, buf_size - len, ",");
|
|
len += snprintf(response + len, buf_size - len,
|
|
"{\"type\":%d,\"sensor_idx\":%d,\"enabled\":%d,\"duration\":%d}",
|
|
rotation_slots[i].screen_type, rotation_slots[i].sensor_idx,
|
|
rotation_slots[i].enabled, rotation_slots[i].duration_sec);
|
|
}
|
|
len += snprintf(response + len, buf_size - len, "]},");
|
|
|
|
// Image (base64 encoded)
|
|
len += snprintf(response + len, buf_size - len, "\"image\":{\"has_image\":%s",
|
|
has_uploaded_image ? "true" : "false");
|
|
if (has_uploaded_image) {
|
|
len += snprintf(response + len, buf_size - len, ",\"data\":\"");
|
|
len += base64_encode(uploaded_image, IMG_BUFFER_SIZE, response + len, buf_size - len);
|
|
len += snprintf(response + len, buf_size - len, "\"");
|
|
}
|
|
len += snprintf(response + len, buf_size - len, "}");
|
|
|
|
// End JSON
|
|
len += snprintf(response + len, buf_size - len, "}");
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_set_hdr(req, "Content-Disposition", "attachment; filename=\"channel3_settings.json\"");
|
|
httpd_resp_send(req, response, len);
|
|
free(response);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Handler for POST /settings/import - Import settings from JSON
|
|
*/
|
|
static esp_err_t settings_import_handler(httpd_req_t *req)
|
|
{
|
|
// Allocate buffer for receiving JSON
|
|
size_t buf_size = 22000;
|
|
char *buf = malloc(buf_size);
|
|
if (!buf) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Receive the JSON data
|
|
int received = 0;
|
|
int remaining = req->content_len;
|
|
while (remaining > 0) {
|
|
int ret = httpd_req_recv(req, buf + received, remaining);
|
|
if (ret <= 0) {
|
|
free(buf);
|
|
httpd_resp_send_500(req);
|
|
return ESP_FAIL;
|
|
}
|
|
received += ret;
|
|
remaining -= ret;
|
|
}
|
|
buf[received] = '\0';
|
|
|
|
// Parse JSON using cJSON
|
|
cJSON *root = cJSON_Parse(buf);
|
|
if (!root) {
|
|
free(buf);
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Import margins
|
|
cJSON *margins = cJSON_GetObjectItem(root, "margins");
|
|
if (margins) {
|
|
cJSON *item;
|
|
if ((item = cJSON_GetObjectItem(margins, "left"))) margin_left = item->valueint;
|
|
if ((item = cJSON_GetObjectItem(margins, "top"))) margin_top = item->valueint;
|
|
if ((item = cJSON_GetObjectItem(margins, "right"))) margin_right = item->valueint;
|
|
if ((item = cJSON_GetObjectItem(margins, "bottom"))) margin_bottom = item->valueint;
|
|
save_margins();
|
|
}
|
|
|
|
// Import transition
|
|
cJSON *trans = cJSON_GetObjectItem(root, "transition");
|
|
if (trans) {
|
|
cJSON *item;
|
|
if ((item = cJSON_GetObjectItem(trans, "type"))) default_transition = item->valueint;
|
|
if ((item = cJSON_GetObjectItem(trans, "speed"))) default_trans_speed = item->valueint;
|
|
save_transition_config();
|
|
}
|
|
|
|
// Import MQTT
|
|
cJSON *mqtt = cJSON_GetObjectItem(root, "mqtt");
|
|
if (mqtt) {
|
|
cJSON *item;
|
|
if ((item = cJSON_GetObjectItem(mqtt, "broker"))) strncpy(mqtt_broker, item->valuestring, sizeof(mqtt_broker) - 1);
|
|
if ((item = cJSON_GetObjectItem(mqtt, "port"))) mqtt_port = item->valueint;
|
|
if ((item = cJSON_GetObjectItem(mqtt, "user"))) strncpy(mqtt_username, item->valuestring, sizeof(mqtt_username) - 1);
|
|
if ((item = cJSON_GetObjectItem(mqtt, "pass"))) strncpy(mqtt_password, item->valuestring, sizeof(mqtt_password) - 1);
|
|
|
|
cJSON *alerts_arr = cJSON_GetObjectItem(mqtt, "alerts");
|
|
if (alerts_arr && cJSON_IsArray(alerts_arr)) {
|
|
int i = 0;
|
|
cJSON *alert;
|
|
cJSON_ArrayForEach(alert, alerts_arr) {
|
|
if (i >= MAX_ALERTS) break;
|
|
cJSON *topic = cJSON_GetObjectItem(alert, "topic");
|
|
cJSON *msg = cJSON_GetObjectItem(alert, "message");
|
|
if (topic) strncpy(alerts[i].topic, topic->valuestring, sizeof(alerts[i].topic) - 1);
|
|
if (msg) strncpy(alerts[i].message, msg->valuestring, sizeof(alerts[i].message) - 1);
|
|
i++;
|
|
}
|
|
}
|
|
// Note: save_mqtt_config is defined but we need to trigger MQTT restart
|
|
}
|
|
|
|
// Import Home Assistant
|
|
cJSON *ha = cJSON_GetObjectItem(root, "ha");
|
|
if (ha) {
|
|
cJSON *item;
|
|
if ((item = cJSON_GetObjectItem(ha, "url"))) strncpy(ha_url, item->valuestring, sizeof(ha_url) - 1);
|
|
if ((item = cJSON_GetObjectItem(ha, "token"))) strncpy(ha_token, item->valuestring, sizeof(ha_token) - 1);
|
|
if ((item = cJSON_GetObjectItem(ha, "interval"))) ha_poll_interval_ms = item->valueint;
|
|
|
|
cJSON *sensors_arr = cJSON_GetObjectItem(ha, "sensors");
|
|
if (sensors_arr && cJSON_IsArray(sensors_arr)) {
|
|
int i = 0;
|
|
ha_sensor_count = 0;
|
|
cJSON *sensor;
|
|
cJSON_ArrayForEach(sensor, sensors_arr) {
|
|
if (i >= MAX_HA_SENSORS) break;
|
|
cJSON *eid = cJSON_GetObjectItem(sensor, "entity_id");
|
|
if (eid && eid->valuestring[0]) {
|
|
strncpy(ha_sensors[i].entity_id, eid->valuestring, sizeof(ha_sensors[i].entity_id) - 1);
|
|
cJSON *attr = cJSON_GetObjectItem(sensor, "attribute");
|
|
cJSON *name = cJSON_GetObjectItem(sensor, "name");
|
|
cJSON *dtype = cJSON_GetObjectItem(sensor, "display_type");
|
|
cJSON *minv = cJSON_GetObjectItem(sensor, "min_value");
|
|
cJSON *maxv = cJSON_GetObjectItem(sensor, "max_value");
|
|
cJSON *en = cJSON_GetObjectItem(sensor, "enabled");
|
|
if (attr) strncpy(ha_sensors[i].attribute, attr->valuestring, sizeof(ha_sensors[i].attribute) - 1);
|
|
if (name) strncpy(ha_sensors[i].name, name->valuestring, sizeof(ha_sensors[i].name) - 1);
|
|
if (dtype) ha_sensors[i].display_type = dtype->valueint;
|
|
if (minv) ha_sensors[i].min_value = minv->valueint;
|
|
if (maxv) ha_sensors[i].max_value = maxv->valueint;
|
|
if (en) ha_sensors[i].enabled = en->valueint;
|
|
ha_sensor_count++;
|
|
save_ha_sensor(i);
|
|
}
|
|
i++;
|
|
}
|
|
}
|
|
save_ha_config();
|
|
}
|
|
|
|
// Import rotation
|
|
cJSON *rotation = cJSON_GetObjectItem(root, "rotation");
|
|
if (rotation) {
|
|
cJSON *count = cJSON_GetObjectItem(rotation, "count");
|
|
if (count) rotation_count = count->valueint;
|
|
|
|
cJSON *slots_arr = cJSON_GetObjectItem(rotation, "slots");
|
|
if (slots_arr && cJSON_IsArray(slots_arr)) {
|
|
int i = 0;
|
|
cJSON *slot;
|
|
cJSON_ArrayForEach(slot, slots_arr) {
|
|
if (i >= MAX_ROTATION_SLOTS) break;
|
|
cJSON *type = cJSON_GetObjectItem(slot, "type");
|
|
cJSON *sidx = cJSON_GetObjectItem(slot, "sensor_idx");
|
|
cJSON *en = cJSON_GetObjectItem(slot, "enabled");
|
|
cJSON *dur = cJSON_GetObjectItem(slot, "duration");
|
|
if (type) rotation_slots[i].screen_type = type->valueint;
|
|
if (sidx) rotation_slots[i].sensor_idx = sidx->valueint;
|
|
if (en) rotation_slots[i].enabled = en->valueint;
|
|
if (dur) rotation_slots[i].duration_sec = dur->valueint;
|
|
i++;
|
|
}
|
|
}
|
|
save_rotation_config();
|
|
}
|
|
|
|
// Import image
|
|
cJSON *image = cJSON_GetObjectItem(root, "image");
|
|
if (image) {
|
|
cJSON *has_img = cJSON_GetObjectItem(image, "has_image");
|
|
cJSON *data = cJSON_GetObjectItem(image, "data");
|
|
if (has_img && cJSON_IsTrue(has_img) && data && data->valuestring) {
|
|
size_t decoded = base64_decode(data->valuestring, uploaded_image, IMG_BUFFER_SIZE);
|
|
if (decoded == IMG_BUFFER_SIZE) {
|
|
has_uploaded_image = true;
|
|
save_uploaded_image();
|
|
ESP_LOGI(TAG, "Imported image: %d bytes", decoded);
|
|
} else {
|
|
ESP_LOGW(TAG, "Image decode size mismatch: %d vs %d", decoded, IMG_BUFFER_SIZE);
|
|
}
|
|
}
|
|
}
|
|
|
|
cJSON_Delete(root);
|
|
free(buf);
|
|
|
|
httpd_resp_send(req, "Settings imported successfully", -1);
|
|
return ESP_OK;
|
|
}
|
|
|
|
/**
|
|
* @brief Start the HTTP server
|
|
*/
|
|
static void start_webserver(void)
|
|
{
|
|
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
|
config.stack_size = 16384;
|
|
config.max_uri_handlers = 40;
|
|
|
|
if (httpd_start(&http_server, &config) == ESP_OK) {
|
|
// Register URI handlers
|
|
httpd_uri_t root_uri = {
|
|
.uri = "/",
|
|
.method = HTTP_GET,
|
|
.handler = root_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &root_uri);
|
|
|
|
httpd_uri_t status_uri = {
|
|
.uri = "/status",
|
|
.method = HTTP_GET,
|
|
.handler = status_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &status_uri);
|
|
|
|
httpd_uri_t screen_uri = {
|
|
.uri = "/screen",
|
|
.method = HTTP_GET,
|
|
.handler = screen_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &screen_uri);
|
|
|
|
httpd_uri_t control_uri = {
|
|
.uri = "/control",
|
|
.method = HTTP_GET,
|
|
.handler = control_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &control_uri);
|
|
|
|
httpd_uri_t jam_uri = {
|
|
.uri = "/jam",
|
|
.method = HTTP_GET,
|
|
.handler = jam_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &jam_uri);
|
|
|
|
httpd_uri_t video_toggle_uri = {
|
|
.uri = "/video",
|
|
.method = HTTP_GET,
|
|
.handler = video_toggle_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &video_toggle_uri);
|
|
|
|
httpd_uri_t margins_uri = {
|
|
.uri = "/margins",
|
|
.method = HTTP_GET,
|
|
.handler = margins_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &margins_uri);
|
|
|
|
httpd_uri_t margins_preview_uri = {
|
|
.uri = "/margins/preview",
|
|
.method = HTTP_GET,
|
|
.handler = margins_preview_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &margins_preview_uri);
|
|
|
|
httpd_uri_t upload_uri = {
|
|
.uri = "/upload",
|
|
.method = HTTP_POST,
|
|
.handler = upload_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &upload_uri);
|
|
|
|
httpd_uri_t mqtt_status_uri = {
|
|
.uri = "/mqtt/status",
|
|
.method = HTTP_GET,
|
|
.handler = mqtt_status_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &mqtt_status_uri);
|
|
|
|
httpd_uri_t mqtt_config_uri = {
|
|
.uri = "/mqtt/config",
|
|
.method = HTTP_GET,
|
|
.handler = mqtt_config_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &mqtt_config_uri);
|
|
|
|
httpd_uri_t mqtt_test_uri = {
|
|
.uri = "/mqtt/test",
|
|
.method = HTTP_GET,
|
|
.handler = mqtt_test_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &mqtt_test_uri);
|
|
|
|
httpd_uri_t mqtt_debug_uri = {
|
|
.uri = "/mqtt/debug",
|
|
.method = HTTP_GET,
|
|
.handler = mqtt_debug_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &mqtt_debug_uri);
|
|
|
|
// Home Assistant endpoints
|
|
httpd_uri_t ha_status_uri = {
|
|
.uri = "/ha/status",
|
|
.method = HTTP_GET,
|
|
.handler = ha_status_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &ha_status_uri);
|
|
|
|
httpd_uri_t ha_config_uri = {
|
|
.uri = "/ha/config",
|
|
.method = HTTP_GET,
|
|
.handler = ha_config_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &ha_config_uri);
|
|
|
|
httpd_uri_t ha_entity_uri = {
|
|
.uri = "/ha/entity",
|
|
.method = HTTP_GET,
|
|
.handler = ha_entity_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &ha_entity_uri);
|
|
|
|
httpd_uri_t ha_sensor_add_uri = {
|
|
.uri = "/ha/sensor/add",
|
|
.method = HTTP_GET,
|
|
.handler = ha_sensor_add_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &ha_sensor_add_uri);
|
|
|
|
httpd_uri_t ha_sensor_remove_uri = {
|
|
.uri = "/ha/sensor/remove",
|
|
.method = HTTP_GET,
|
|
.handler = ha_sensor_remove_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &ha_sensor_remove_uri);
|
|
|
|
httpd_uri_t ha_sensor_rename_uri = {
|
|
.uri = "/ha/sensor/rename",
|
|
.method = HTTP_GET,
|
|
.handler = ha_sensor_rename_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &ha_sensor_rename_uri);
|
|
|
|
// Rotation endpoints
|
|
httpd_uri_t rotation_status_uri = {
|
|
.uri = "/rotation/status",
|
|
.method = HTTP_GET,
|
|
.handler = rotation_status_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &rotation_status_uri);
|
|
|
|
httpd_uri_t rotation_set_uri = {
|
|
.uri = "/rotation/set",
|
|
.method = HTTP_GET,
|
|
.handler = rotation_set_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &rotation_set_uri);
|
|
|
|
httpd_uri_t rotation_add_uri = {
|
|
.uri = "/rotation/add",
|
|
.method = HTTP_GET,
|
|
.handler = rotation_add_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &rotation_add_uri);
|
|
|
|
httpd_uri_t rotation_remove_uri = {
|
|
.uri = "/rotation/remove",
|
|
.method = HTTP_GET,
|
|
.handler = rotation_remove_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &rotation_remove_uri);
|
|
|
|
httpd_uri_t rotation_reorder_uri = {
|
|
.uri = "/rotation/reorder",
|
|
.method = HTTP_GET,
|
|
.handler = rotation_reorder_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &rotation_reorder_uri);
|
|
|
|
// Transition endpoints
|
|
httpd_uri_t trans_status_uri = {
|
|
.uri = "/transition/status",
|
|
.method = HTTP_GET,
|
|
.handler = transition_status_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &trans_status_uri);
|
|
|
|
httpd_uri_t trans_set_uri = {
|
|
.uri = "/transition/set",
|
|
.method = HTTP_GET,
|
|
.handler = transition_set_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &trans_set_uri);
|
|
|
|
httpd_uri_t trans_test_uri = {
|
|
.uri = "/transition/test",
|
|
.method = HTTP_GET,
|
|
.handler = transition_test_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &trans_test_uri);
|
|
|
|
// Settings export/import endpoints
|
|
httpd_uri_t settings_export_uri = {
|
|
.uri = "/settings/export",
|
|
.method = HTTP_GET,
|
|
.handler = settings_export_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &settings_export_uri);
|
|
|
|
httpd_uri_t settings_import_uri = {
|
|
.uri = "/settings/import",
|
|
.method = HTTP_POST,
|
|
.handler = settings_import_handler
|
|
};
|
|
httpd_register_uri_handler(http_server, &settings_import_uri);
|
|
|
|
ESP_LOGI(TAG, "HTTP server started on port %d", config.server_port);
|
|
} else {
|
|
ESP_LOGE(TAG, "Failed to start HTTP server");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Render transition effect
|
|
* Called at end of DrawFrame() when transition_active is true.
|
|
* Blends prev_frame with current frontframe based on transition_progress.
|
|
*/
|
|
static void render_transition(void)
|
|
{
|
|
if (!transition_active) return;
|
|
|
|
// Advance progress
|
|
int new_progress = transition_progress + transition_speed;
|
|
if (new_progress >= 255) {
|
|
transition_active = 0;
|
|
transition_progress = 255;
|
|
return;
|
|
}
|
|
transition_progress = new_progress;
|
|
|
|
uint8_t p = transition_progress; // 0-255 progress
|
|
|
|
switch (transition_type) {
|
|
case TRANS_FADE:
|
|
// Blend prev_frame and frontframe
|
|
for (int i = 0; i < sizeof(prev_frame); i++) {
|
|
uint8_t old_lo = prev_frame[i] & 0x0F;
|
|
uint8_t old_hi = (prev_frame[i] >> 4) & 0x0F;
|
|
uint8_t new_lo = frontframe[i] & 0x0F;
|
|
uint8_t new_hi = (frontframe[i] >> 4) & 0x0F;
|
|
// Linear interpolation between old and new colors
|
|
uint8_t blend_lo = (old_lo * (255 - p) + new_lo * p) / 255;
|
|
uint8_t blend_hi = (old_hi * (255 - p) + new_hi * p) / 255;
|
|
frontframe[i] = (blend_hi << 4) | blend_lo;
|
|
}
|
|
break;
|
|
|
|
case TRANS_WIPE_L:
|
|
// New screen slides in from right, boundary moves left
|
|
{
|
|
int boundary_x = (FBW2 * (255 - p)) / 255; // pixels from left to keep old
|
|
for (int y = 0; y < FBH; y++) {
|
|
for (int x = 0; x < boundary_x; x++) {
|
|
int byte_idx = y * (FBW2 / 2) + x / 2;
|
|
if (x % 2 == 0) {
|
|
// High nibble
|
|
frontframe[byte_idx] = (frontframe[byte_idx] & 0x0F) |
|
|
(prev_frame[byte_idx] & 0xF0);
|
|
} else {
|
|
// Low nibble
|
|
frontframe[byte_idx] = (frontframe[byte_idx] & 0xF0) |
|
|
(prev_frame[byte_idx] & 0x0F);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case TRANS_WIPE_R:
|
|
// New screen slides in from left, boundary moves right
|
|
{
|
|
int boundary_x = (FBW2 * p) / 255; // pixels from left showing new
|
|
for (int y = 0; y < FBH; y++) {
|
|
for (int x = boundary_x; x < FBW2; x++) {
|
|
int byte_idx = y * (FBW2 / 2) + x / 2;
|
|
if (x % 2 == 0) {
|
|
frontframe[byte_idx] = (frontframe[byte_idx] & 0x0F) |
|
|
(prev_frame[byte_idx] & 0xF0);
|
|
} else {
|
|
frontframe[byte_idx] = (frontframe[byte_idx] & 0xF0) |
|
|
(prev_frame[byte_idx] & 0x0F);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case TRANS_WIPE_D:
|
|
// New screen slides in from top, boundary moves down
|
|
{
|
|
int boundary_y = (FBH * (255 - p)) / 255; // rows from top to keep old
|
|
for (int y = 0; y < boundary_y; y++) {
|
|
memcpy(&frontframe[y * (FBW2 / 2)],
|
|
&prev_frame[y * (FBW2 / 2)],
|
|
FBW2 / 2);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case TRANS_WIPE_U:
|
|
// New screen slides in from bottom, boundary moves up
|
|
{
|
|
int boundary_y = (FBH * p) / 255; // rows from top showing new
|
|
for (int y = boundary_y; y < FBH; y++) {
|
|
memcpy(&frontframe[y * (FBW2 / 2)],
|
|
&prev_frame[y * (FBW2 / 2)],
|
|
FBW2 / 2);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case TRANS_DISSOLVE:
|
|
// Random pixel replacement using LCG
|
|
{
|
|
uint32_t seed = 12345; // Fixed seed for deterministic pattern
|
|
for (int i = 0; i < (int)sizeof(prev_frame); i++) {
|
|
// Two pixels per byte, handle each nibble
|
|
seed = seed * 1103515245 + 12345;
|
|
uint8_t rand1 = (seed >> 16) & 0xFF;
|
|
seed = seed * 1103515245 + 12345;
|
|
uint8_t rand2 = (seed >> 16) & 0xFF;
|
|
|
|
uint8_t result = frontframe[i];
|
|
// High nibble (first pixel)
|
|
if (rand1 > p) {
|
|
result = (result & 0x0F) | (prev_frame[i] & 0xF0);
|
|
}
|
|
// Low nibble (second pixel)
|
|
if (rand2 > p) {
|
|
result = (result & 0xF0) | (prev_frame[i] & 0x0F);
|
|
}
|
|
frontframe[i] = result;
|
|
}
|
|
}
|
|
break;
|
|
|
|
default:
|
|
// TRANS_NONE or unknown - instant switch, no blending
|
|
transition_active = 0;
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Setup projection and modelview matrices
|
|
*/
|
|
static void SetupMatrix(void)
|
|
{
|
|
tdIdentity(ProjectionMatrix);
|
|
tdIdentity(ModelviewMatrix);
|
|
Perspective(600, 250, 50, 8192, ProjectionMatrix);
|
|
}
|
|
|
|
/**
|
|
* @brief Calculate terrain height for mesh demo
|
|
*/
|
|
static int16_t Height(int x, int y, int l)
|
|
{
|
|
return tdCOS((x * x + y * y) + l);
|
|
}
|
|
|
|
/**
|
|
* @brief Draw the current demo frame
|
|
*/
|
|
static void DrawFrame(void)
|
|
{
|
|
char *ctx = &lastct[0];
|
|
int x = 0;
|
|
int y = 0;
|
|
int i;
|
|
int newstate = showstate;
|
|
|
|
// Apply calibration margins to default pen position
|
|
CNFGPenX = 14 + margin_left;
|
|
CNFGPenY = 20 + margin_top;
|
|
memset(frontframe, 0x00, ((FBW / 4) * FBH));
|
|
tdIdentity(ModelviewMatrix);
|
|
tdIdentity(ProjectionMatrix);
|
|
CNFGColor(17);
|
|
|
|
switch (showstate) {
|
|
case 14: // Calibration screen
|
|
{
|
|
// Draw border rectangle at current margins
|
|
// This shows the "safe area" that will be visible on the TV
|
|
int left = margin_left;
|
|
int top = margin_top;
|
|
int right = FBW2 - 1 - margin_right;
|
|
int bottom = FBH - 1 - margin_bottom;
|
|
|
|
// Draw white border rectangle
|
|
CNFGColor(15); // White
|
|
// Top edge
|
|
CNFGTackSegment(left, top, right, top);
|
|
// Bottom edge
|
|
CNFGTackSegment(left, bottom, right, bottom);
|
|
// Left edge
|
|
CNFGTackSegment(left, top, left, bottom);
|
|
// Right edge
|
|
CNFGTackSegment(right, top, right, bottom);
|
|
|
|
// Draw corner markers (more visible)
|
|
CNFGColor(14); // Yellow
|
|
// Top-left corner
|
|
CNFGTackSegment(left, top, left + 10, top);
|
|
CNFGTackSegment(left, top, left, top + 10);
|
|
// Top-right corner
|
|
CNFGTackSegment(right - 10, top, right, top);
|
|
CNFGTackSegment(right, top, right, top + 10);
|
|
// Bottom-left corner
|
|
CNFGTackSegment(left, bottom - 10, left, bottom);
|
|
CNFGTackSegment(left, bottom, left + 10, bottom);
|
|
// Bottom-right corner
|
|
CNFGTackSegment(right - 10, bottom, right, bottom);
|
|
CNFGTackSegment(right, bottom - 10, right, bottom);
|
|
|
|
// Draw crosshair in center
|
|
int cx = FBW2 / 2;
|
|
int cy = FBH / 2;
|
|
CNFGColor(10); // Green
|
|
CNFGTackSegment(cx - 15, cy, cx + 15, cy);
|
|
CNFGTackSegment(cx, cy - 15, cx, cy + 15);
|
|
|
|
// Display margin values
|
|
char cal_text[64];
|
|
CNFGColor(15); // White
|
|
CNFGPenX = 10;
|
|
CNFGPenY = 80;
|
|
CNFGDrawText("CALIBRATION", 3);
|
|
|
|
CNFGColor(11); // Cyan
|
|
CNFGPenX = 10;
|
|
CNFGPenY = 115;
|
|
snprintf(cal_text, sizeof(cal_text), "L:%d R:%d", margin_left, margin_right);
|
|
CNFGDrawText(cal_text, 2);
|
|
|
|
CNFGPenX = 10;
|
|
CNFGPenY = 135;
|
|
snprintf(cal_text, sizeof(cal_text), "T:%d B:%d", margin_top, margin_bottom);
|
|
CNFGDrawText(cal_text, 2);
|
|
|
|
CNFGColor(8); // Gray
|
|
CNFGPenX = 10;
|
|
CNFGPenY = 165;
|
|
CNFGDrawText("Adjust via", 2);
|
|
CNFGPenX = 10;
|
|
CNFGPenY = 183;
|
|
CNFGDrawText("web interface", 2);
|
|
break;
|
|
}
|
|
|
|
case 15: // ALERT - Flashing warning (grayscale friendly)
|
|
{
|
|
// Check if alert should expire
|
|
if (framessostate >= 150) { // ~5 seconds at 30fps
|
|
alert_active = false;
|
|
newstate = 13; // Return to weather
|
|
showallowadvance = 1; // Restore normal rotation
|
|
break;
|
|
}
|
|
|
|
// Flash effect: invert colors every 15 frames
|
|
int inverted = (framessostate / 15) % 2;
|
|
|
|
if (inverted) {
|
|
// White background, black text
|
|
CNFGColor(15); // White
|
|
CNFGTackRectangle(0, 0, FBW - 1, FBH - 1); // FBW for rect (divides by 2 internally)
|
|
CNFGColor(0); // Black text
|
|
} else {
|
|
// Black background (already cleared), white text
|
|
CNFGColor(15); // White
|
|
}
|
|
|
|
// Word wrap and center the alert message
|
|
{
|
|
const int scale = 3;
|
|
const int char_width = 3 * scale; // 6 pixels per char
|
|
const int line_height = 6 * scale; // 12 pixels per line
|
|
const int margin = 4;
|
|
const int screen_width = FBW2; // Use FBW2 (116) for actual visible width
|
|
const int max_width = screen_width - 2 * margin;
|
|
const int max_chars = max_width / char_width; // ~18 chars per line
|
|
|
|
// Build wrapped lines
|
|
char lines[8][24]; // Up to 8 lines, 24 chars each
|
|
int line_lengths[8] = {0};
|
|
int num_lines = 0;
|
|
|
|
const char *src = current_alert_message;
|
|
int line_pos = 0;
|
|
|
|
while (*src && num_lines < 8) {
|
|
// Skip leading spaces
|
|
while (*src == ' ') src++;
|
|
if (!*src) break;
|
|
|
|
// Find word end
|
|
const char *word_start = src;
|
|
while (*src && *src != ' ') src++;
|
|
int word_len = src - word_start;
|
|
|
|
// Check if word fits on current line
|
|
if (line_pos > 0 && line_pos + 1 + word_len > max_chars) {
|
|
// Start new line
|
|
lines[num_lines][line_pos] = '\0';
|
|
line_lengths[num_lines] = line_pos;
|
|
num_lines++;
|
|
line_pos = 0;
|
|
if (num_lines >= 8) break;
|
|
}
|
|
|
|
// Add space if not at line start
|
|
if (line_pos > 0) {
|
|
lines[num_lines][line_pos++] = ' ';
|
|
}
|
|
|
|
// Add word (truncate if too long)
|
|
for (int i = 0; i < word_len && line_pos < 23; i++) {
|
|
lines[num_lines][line_pos++] = word_start[i];
|
|
}
|
|
}
|
|
|
|
// Finish last line
|
|
if (line_pos > 0 && num_lines < 8) {
|
|
lines[num_lines][line_pos] = '\0';
|
|
line_lengths[num_lines] = line_pos;
|
|
num_lines++;
|
|
}
|
|
|
|
// Calculate vertical centering
|
|
int total_height = num_lines * line_height;
|
|
int start_y = (FBH - total_height) / 2;
|
|
|
|
// Draw each line centered horizontally
|
|
for (int i = 0; i < num_lines; i++) {
|
|
int text_width = line_lengths[i] * char_width;
|
|
int start_x = (screen_width - text_width) / 2;
|
|
CNFGPenX = start_x;
|
|
CNFGPenY = start_y + i * line_height;
|
|
CNFGDrawText(lines[i], scale);
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case 16: // Digital clock display
|
|
{
|
|
time_t now_time;
|
|
struct tm timeinfo;
|
|
time(&now_time);
|
|
localtime_r(&now_time, &timeinfo);
|
|
|
|
// Get time components
|
|
int hour = timeinfo.tm_hour;
|
|
int minute = timeinfo.tm_min;
|
|
int second = timeinfo.tm_sec;
|
|
const char *ampm = (hour >= 12) ? "PM" : "AM";
|
|
if (hour == 0) hour = 12;
|
|
else if (hour > 12) hour -= 12;
|
|
|
|
// Format date as "April 1st 2026"
|
|
static const char *months[] = {
|
|
"January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December"
|
|
};
|
|
|
|
// Get ordinal suffix for day
|
|
const char *suffix;
|
|
int day = timeinfo.tm_mday;
|
|
if (day >= 11 && day <= 13) {
|
|
suffix = "th";
|
|
} else {
|
|
switch (day % 10) {
|
|
case 1: suffix = "st"; break;
|
|
case 2: suffix = "nd"; break;
|
|
case 3: suffix = "rd"; break;
|
|
default: suffix = "th"; break;
|
|
}
|
|
}
|
|
|
|
char date_str[32];
|
|
snprintf(date_str, sizeof(date_str), "%s %d%s %d",
|
|
months[timeinfo.tm_mon], day, suffix, timeinfo.tm_year + 1900);
|
|
|
|
// Draw time with custom kerning - colons are narrower
|
|
const int time_scale = 4;
|
|
const int char_w = 3 * time_scale; // 12 pixels for digits
|
|
const int colon_w = 2 * time_scale; // 8 pixels for colons (tighter)
|
|
const int space_w = 2 * time_scale; // 8 pixels for space before AM/PM
|
|
const int ampm_scale = 2; // Smaller AM/PM
|
|
const int ampm_char_w = 3 * ampm_scale; // 6 pixels for AM/PM chars
|
|
|
|
// Calculate total width: HH:MM:SS AM (2+colon+2+colon+2+space+2 for AM/PM)
|
|
int time_width = (6 * char_w) + (2 * colon_w) + space_w + (2 * ampm_char_w);
|
|
int time_x = (FBW2 - time_width) / 2;
|
|
int time_y = 60 + margin_top;
|
|
|
|
CNFGColor(15); // White
|
|
|
|
// Draw hour
|
|
char buf[16];
|
|
snprintf(buf, sizeof(buf), "%2d", hour);
|
|
CNFGPenX = time_x;
|
|
CNFGPenY = time_y;
|
|
CNFGDrawText(buf, time_scale);
|
|
time_x += 2 * char_w;
|
|
|
|
// Draw colon
|
|
CNFGPenX = time_x;
|
|
CNFGPenY = time_y;
|
|
CNFGDrawText(":", time_scale);
|
|
time_x += colon_w;
|
|
|
|
// Draw minute
|
|
snprintf(buf, sizeof(buf), "%02d", minute);
|
|
CNFGPenX = time_x;
|
|
CNFGPenY = time_y;
|
|
CNFGDrawText(buf, time_scale);
|
|
time_x += 2 * char_w;
|
|
|
|
// Draw colon
|
|
CNFGPenX = time_x;
|
|
CNFGPenY = time_y;
|
|
CNFGDrawText(":", time_scale);
|
|
time_x += colon_w;
|
|
|
|
// Draw second
|
|
snprintf(buf, sizeof(buf), "%02d", second);
|
|
CNFGPenX = time_x;
|
|
CNFGPenY = time_y;
|
|
CNFGDrawText(buf, time_scale);
|
|
time_x += 2 * char_w + space_w;
|
|
|
|
// Draw AM/PM smaller
|
|
CNFGColor(15); // White for AM/PM
|
|
CNFGPenX = time_x;
|
|
CNFGPenY = time_y + (time_scale - ampm_scale) * 3; // Align to bottom
|
|
CNFGDrawText(ampm, ampm_scale);
|
|
|
|
// Draw date - smaller centered below
|
|
const int date_scale = 2;
|
|
int date_width = strlen(date_str) * 3 * date_scale;
|
|
int date_x = (FBW2 - date_width) / 2;
|
|
CNFGColor(15); // White
|
|
CNFGPenX = date_x;
|
|
CNFGPenY = 120 + margin_top;
|
|
CNFGDrawText(date_str, date_scale);
|
|
|
|
// Transition when rotation duration expires
|
|
if (rotation_duration_expired()) {
|
|
newstate = advance_rotation();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 17: // Home Assistant Sensor Display
|
|
{
|
|
// Get the specific sensor for this rotation slot
|
|
ha_sensor_config_t *sensor = NULL;
|
|
int enabled_count = 0;
|
|
|
|
// Check if specified sensor is valid and enabled
|
|
if (ha_current_sensor >= 0 && ha_current_sensor < MAX_HA_SENSORS &&
|
|
ha_sensors[ha_current_sensor].entity_id[0] && ha_sensors[ha_current_sensor].enabled) {
|
|
sensor = &ha_sensors[ha_current_sensor];
|
|
}
|
|
|
|
// Count enabled sensors for the "no sensors" check
|
|
for (int i = 0; i < MAX_HA_SENSORS; i++) {
|
|
if (ha_sensors[i].entity_id[0] && ha_sensors[i].enabled) {
|
|
enabled_count++;
|
|
}
|
|
}
|
|
|
|
// If no sensors configured, advance to next rotation slot immediately
|
|
if (!sensor || enabled_count == 0) {
|
|
CNFGColor(8); // Gray
|
|
CNFGPenX = 10 + margin_left;
|
|
CNFGPenY = 90 + margin_top;
|
|
CNFGDrawText("No HA Sensors", 2);
|
|
CNFGPenX = 10 + margin_left;
|
|
CNFGPenY = 115 + margin_top;
|
|
CNFGDrawText("Configure via", 2);
|
|
CNFGPenX = 10 + margin_left;
|
|
CNFGPenY = 135 + margin_top;
|
|
CNFGDrawText("web interface", 2);
|
|
if (framessostate > 150) newstate = advance_rotation();
|
|
break;
|
|
}
|
|
|
|
// Get display name (use entity_id if name not set)
|
|
const char *display_name = sensor->name[0] ? sensor->name : sensor->entity_id;
|
|
|
|
if (sensor->display_type == HA_DISPLAY_GAUGE) {
|
|
// GAUGE LAYOUT - Speedometer style (arc curves UP)
|
|
// Name at top, centered
|
|
CNFGColor(14); // Yellow
|
|
int name_width = strlen(display_name) * 3 * 2;
|
|
CNFGPenX = (FBW2 - name_width) / 2;
|
|
CNFGPenY = 5 + margin_top;
|
|
CNFGDrawText(display_name, 2);
|
|
|
|
// Gauge parameters - taller gauge
|
|
int cx = FBW2 / 2;
|
|
int cy = 130 + margin_top; // Move center down for upward arc
|
|
int radius = 50; // Larger radius
|
|
|
|
// Draw gauge arc (semi-circle from 180 to 0 degrees - top half)
|
|
CNFGColor(8); // Gray for arc
|
|
for (int angle = 0; angle <= 128; angle += 4) {
|
|
int x1 = cx + (radius * tdCOS(angle)) / 256;
|
|
int y1 = cy - (radius * tdSIN(angle)) / 256;
|
|
int x2 = cx + (radius * tdCOS(angle + 4)) / 256;
|
|
int y2 = cy - (radius * tdSIN(angle + 4)) / 256;
|
|
CNFGTackSegment(x1, y1, x2, y2);
|
|
}
|
|
|
|
// Calculate value and range
|
|
float value = atof(sensor->cached_value);
|
|
int range = sensor->max_value - sensor->min_value;
|
|
if (range <= 0) range = 100;
|
|
|
|
// Draw tick marks and intermediate values (0%, 25%, 50%, 75%, 100%)
|
|
// For upward arc: min at left (angle 128), max at right (angle 0)
|
|
CNFGColor(7); // Light gray
|
|
char tick_label[8];
|
|
int tick_radius = radius + 8;
|
|
for (int i = 0; i <= 4; i++) {
|
|
int tick_angle = 128 - (i * 32); // 128, 96, 64, 32, 0
|
|
int tick_value = sensor->min_value + (range * i) / 4;
|
|
|
|
// Draw tick mark (short line outward from arc)
|
|
int tx1 = cx + ((radius - 4) * tdCOS(tick_angle)) / 256;
|
|
int ty1 = cy - ((radius - 4) * tdSIN(tick_angle)) / 256;
|
|
int tx2 = cx + ((radius + 4) * tdCOS(tick_angle)) / 256;
|
|
int ty2 = cy - ((radius + 4) * tdSIN(tick_angle)) / 256;
|
|
CNFGTackSegment(tx1, ty1, tx2, ty2);
|
|
|
|
// Draw tick label
|
|
snprintf(tick_label, sizeof(tick_label), "%d", tick_value);
|
|
int label_len = strlen(tick_label);
|
|
int lx = cx + (tick_radius * tdCOS(tick_angle)) / 256;
|
|
int ly = cy - (tick_radius * tdSIN(tick_angle)) / 256;
|
|
|
|
// Adjust position based on angle (for upward arc)
|
|
if (i == 0) { // Left (min) - angle 128
|
|
CNFGPenX = lx - (label_len * 3 * 2) - 2;
|
|
CNFGPenY = ly - 4;
|
|
} else if (i == 4) { // Right (max) - angle 0
|
|
CNFGPenX = lx + 3;
|
|
CNFGPenY = ly - 4;
|
|
} else if (i == 2) { // Top (middle) - angle 64
|
|
CNFGPenX = lx - (label_len * 3);
|
|
CNFGPenY = ly - 14;
|
|
} else { // 25% and 75%
|
|
CNFGPenX = lx - (label_len * 3) + (i < 2 ? -8 : 8);
|
|
CNFGPenY = ly - 10;
|
|
}
|
|
CNFGDrawText(tick_label, 2);
|
|
}
|
|
|
|
// Calculate needle angle (min=128/left, max=0/right for upward arc)
|
|
int needle_angle;
|
|
if (value <= sensor->min_value) {
|
|
needle_angle = 128; // Far left
|
|
} else if (value >= sensor->max_value) {
|
|
needle_angle = 0; // Far right
|
|
} else {
|
|
needle_angle = 128 - (int)(((value - sensor->min_value) * 128) / range);
|
|
}
|
|
|
|
// Draw thick needle (multiple parallel lines)
|
|
CNFGColor(12); // Red for needle
|
|
int nx = cx + ((radius - 5) * tdCOS(needle_angle)) / 256;
|
|
int ny = cy - ((radius - 5) * tdSIN(needle_angle)) / 256;
|
|
// Main needle
|
|
CNFGTackSegment(cx, cy, nx, ny);
|
|
// Parallel lines for thickness
|
|
CNFGTackSegment(cx - 1, cy, nx - 1, ny);
|
|
CNFGTackSegment(cx + 1, cy, nx + 1, ny);
|
|
CNFGTackSegment(cx, cy - 1, nx, ny - 1);
|
|
CNFGTackSegment(cx, cy + 1, nx, ny + 1);
|
|
|
|
// Draw current value below gauge - large and centered
|
|
CNFGColor(15); // White
|
|
int val_width = strlen(sensor->cached_value) * 3 * 4;
|
|
CNFGPenX = (FBW2 - val_width) / 2;
|
|
CNFGPenY = 150 + margin_top;
|
|
CNFGDrawText(sensor->cached_value, 4);
|
|
} else {
|
|
// TEXT LAYOUT (default)
|
|
// Name centered at top
|
|
CNFGColor(14); // Yellow
|
|
int name_width = strlen(display_name) * 3 * 2;
|
|
CNFGPenX = (FBW2 - name_width) / 2;
|
|
CNFGPenY = 40 + margin_top;
|
|
CNFGDrawText(display_name, 2);
|
|
|
|
// Value large and centered
|
|
CNFGColor(15); // White
|
|
int val_width = strlen(sensor->cached_value) * 3 * 5;
|
|
CNFGPenX = (FBW2 - val_width) / 2;
|
|
CNFGPenY = 90 + margin_top;
|
|
CNFGDrawText(sensor->cached_value, 5);
|
|
}
|
|
|
|
// Transition when rotation duration expires
|
|
if (rotation_duration_expired()) {
|
|
newstate = advance_rotation();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 13: // Weather display - 3 pages: current, forecast 1-3, forecast 4-6
|
|
{
|
|
char weather_text[64];
|
|
uint32_t now_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
|
|
|
// Toggle between 3 screens every 5 seconds
|
|
// Page 0: Current conditions
|
|
// Page 1: Forecast hours 1-3
|
|
// Page 2: Forecast hours 4-6
|
|
if (now_ms - last_weather_screen_toggle >= WEATHER_SCREEN_TOGGLE_MS) {
|
|
weather_screen_page = (weather_screen_page + 1) % 3;
|
|
last_weather_screen_toggle = now_ms;
|
|
}
|
|
|
|
// Get current date for display
|
|
time_t now_time;
|
|
struct tm timeinfo;
|
|
time(&now_time);
|
|
localtime_r(&now_time, &timeinfo);
|
|
char date_str[16];
|
|
strftime(date_str, sizeof(date_str), "%m/%d/%y", &timeinfo);
|
|
|
|
if (weather_screen_page == 0) {
|
|
// PAGE 0: Current conditions
|
|
|
|
// City name - top left
|
|
CNFGColor(14); // Yellow
|
|
CNFGPenX = 2 + margin_left;
|
|
CNFGPenY = 2 + margin_top;
|
|
CNFGDrawText(weather_city, 2);
|
|
|
|
// Date - top right
|
|
CNFGColor(7); // Light gray
|
|
CNFGPenX = 170 + margin_left;
|
|
CNFGPenY = 2 + margin_top;
|
|
CNFGDrawText(date_str, 2);
|
|
|
|
// Current temperature - large
|
|
CNFGColor(15); // White
|
|
CNFGPenX = 2 + margin_left;
|
|
CNFGPenY = 28 + margin_top;
|
|
CNFGDrawText(weather_temp, 5);
|
|
|
|
// Current condition
|
|
CNFGColor(11); // Cyan
|
|
CNFGPenX = 2 + margin_left;
|
|
CNFGPenY = 80 + margin_top;
|
|
CNFGDrawText(weather_condition, 3);
|
|
|
|
// Humidity
|
|
CNFGColor(9); // Light blue
|
|
CNFGPenX = 2 + margin_left;
|
|
CNFGPenY = 120 + margin_top;
|
|
snprintf(weather_text, sizeof(weather_text), "Humidity: %s", weather_humidity);
|
|
CNFGDrawText(weather_text, 2);
|
|
|
|
// Wind
|
|
CNFGColor(10); // Green
|
|
CNFGPenX = 2 + margin_left;
|
|
CNFGPenY = 150 + margin_top;
|
|
snprintf(weather_text, sizeof(weather_text), "Wind: %s", weather_wind);
|
|
CNFGDrawText(weather_text, 2);
|
|
|
|
} else {
|
|
// PAGE 1 or 2: Hourly Forecast (3 entries per page)
|
|
// Page 1 shows entries 0-2, Page 2 shows entries 3-5
|
|
|
|
int start_idx = (weather_screen_page - 1) * 3;
|
|
int forecast_y = 4 + margin_top;
|
|
|
|
for (int i = 0; i < 3; i++) {
|
|
int idx = start_idx + i;
|
|
if (idx >= FORECAST_ENTRIES || hourly_forecast[idx].time[0] == '\0') continue;
|
|
|
|
int base_y = forecast_y + (i * 70);
|
|
|
|
// Line 1: Hour
|
|
CNFGColor(15); // White
|
|
CNFGPenX = 2 + margin_left;
|
|
CNFGPenY = base_y;
|
|
CNFGDrawText(hourly_forecast[idx].time, 2);
|
|
|
|
// Line 2: Temperature and condition
|
|
CNFGColor(11); // Cyan
|
|
CNFGPenX = 2 + margin_left;
|
|
CNFGPenY = base_y + 20;
|
|
snprintf(weather_text, sizeof(weather_text), "%s %s",
|
|
hourly_forecast[idx].temp, hourly_forecast[idx].cond);
|
|
CNFGDrawText(weather_text, 2);
|
|
|
|
// Line 3: Humidity and wind
|
|
CNFGColor(10); // Green
|
|
CNFGPenX = 2 + margin_left;
|
|
CNFGPenY = base_y + 40;
|
|
snprintf(weather_text, sizeof(weather_text), "H:%s Wind:%s",
|
|
hourly_forecast[idx].humidity, hourly_forecast[idx].wind);
|
|
CNFGDrawText(weather_text, 2);
|
|
}
|
|
}
|
|
|
|
// Transition when rotation duration expires
|
|
if (rotation_duration_expired()) {
|
|
newstate = advance_rotation();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 12: // Uploaded image display
|
|
{
|
|
if (has_uploaded_image) {
|
|
// Copy uploaded image to framebuffer
|
|
memcpy(frontframe, uploaded_image, IMG_BUFFER_SIZE);
|
|
} else {
|
|
CNFGDrawText("No image uploaded.\nUse web interface\nto upload an image.", 2);
|
|
}
|
|
// Check rotation
|
|
if (rotation_duration_expired()) {
|
|
newstate = advance_rotation();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 11: // Color test pattern - 16 colored boxes
|
|
{
|
|
// Calculate available area after margins
|
|
int avail_w = FBW2 - margin_left - margin_right;
|
|
int avail_h = FBH - margin_top - margin_bottom;
|
|
int box_w = avail_w / 4;
|
|
int box_h = avail_h / 4;
|
|
|
|
for (i = 0; i < 16; i++) {
|
|
x = (i % 4) * box_w + margin_left;
|
|
y = (i / 4) * box_h + margin_top;
|
|
CNFGColor(i);
|
|
CNFGTackRectangle(x, y, x + box_w - 1, y + box_h - 1);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 10: // Combined demo - text, colors, and 3D spheres
|
|
{
|
|
for (i = 0; i < 16; i++) {
|
|
CNFGPenX = 14 + margin_left;
|
|
CNFGPenY = (i + 1) * 12 + margin_top;
|
|
CNFGColor(i);
|
|
CNFGDrawText("Hello", 3);
|
|
CNFGTackRectangle(120 + margin_left, (i + 1) * 12 + margin_top,
|
|
180 + margin_left, (i + 1) * 12 + 12 + margin_top);
|
|
}
|
|
|
|
SetupMatrix();
|
|
tdRotateEA(ProjectionMatrix, -20, 0, 0);
|
|
tdRotateEA(ModelviewMatrix, framessostate, 0, 0);
|
|
|
|
for (y = 3; y >= 0; y--) {
|
|
for (x = 0; x < 4; x++) {
|
|
CNFGColor(x + y * 4);
|
|
ModelviewMatrix[11] = 1000 + tdSIN((x + y) * 40 + framessostate * 2);
|
|
ModelviewMatrix[3] = 600 * x - 850;
|
|
ModelviewMatrix[7] = 600 * y + 800 - 850;
|
|
DrawGeoSphere();
|
|
}
|
|
}
|
|
|
|
if (framessostate > 500) newstate = 9;
|
|
break;
|
|
}
|
|
|
|
case 9: // Credits text
|
|
{
|
|
const char *s = "ESP32 RF Broadcast\nDMA through I2S!\nTry it yourself!\n\ngithub.com/cnlohr/\nchannel3\n";
|
|
|
|
i = strlen(s);
|
|
if (i > framessostate) i = framessostate;
|
|
memcpy(lastct, s, i);
|
|
lastct[i] = 0;
|
|
CNFGDrawText(lastct, 3);
|
|
if (framessostate > 500) newstate = 0;
|
|
break;
|
|
}
|
|
|
|
case 8: // Dynamic 3D terrain mesh
|
|
{
|
|
CNFGColor(15); // Use white from 16-color palette
|
|
CNFGDrawText("3D Meshes", 2);
|
|
SetupMatrix();
|
|
tdRotateEA(ProjectionMatrix, -20, 0, 0);
|
|
tdRotateEA(ModelviewMatrix, 0, 0, framessostate);
|
|
|
|
for (y = -18; y < 18; y++) {
|
|
for (x = -18; x < 18; x++) {
|
|
int o = -framessostate * 2;
|
|
int t = Height(x, y, o) * 2 + 2000;
|
|
CNFGColor(((t / 100) % 15) + 1);
|
|
int nx = Height(x + 1, y, o) * 2 + 2000;
|
|
int ny = Height(x, y + 1, o) * 2 + 2000;
|
|
int16_t p0[3] = { x * 140, y * 140, t };
|
|
int16_t p1[3] = { (x + 1) * 140, y * 140, nx };
|
|
int16_t p2[3] = { x * 140, (y + 1) * 140, ny };
|
|
Draw3DSegment(p0, p1);
|
|
Draw3DSegment(p0, p2);
|
|
}
|
|
}
|
|
|
|
if (framessostate > 400) newstate = 10;
|
|
break;
|
|
}
|
|
|
|
case 7: // Multiple rotating geodesic spheres
|
|
{
|
|
CNFGDrawText("Matrix-based 3D engine.", 3);
|
|
SetupMatrix();
|
|
tdRotateEA(ProjectionMatrix, -20, 0, 0);
|
|
tdRotateEA(ModelviewMatrix, framessostate, 0, 0);
|
|
|
|
int sphereset = (framessostate / 120);
|
|
if (sphereset > 2) sphereset = 2;
|
|
|
|
if (framessostate > 400) {
|
|
newstate = 8;
|
|
}
|
|
|
|
for (y = -sphereset; y <= sphereset; y++) {
|
|
for (x = -sphereset; x <= sphereset; x++) {
|
|
if (y == 2) continue;
|
|
ModelviewMatrix[11] = 1000 + tdSIN((x + y) * 40 + framessostate * 2);
|
|
ModelviewMatrix[3] = 500 * x;
|
|
ModelviewMatrix[7] = 500 * y + 800;
|
|
DrawGeoSphere();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 6: // Random colored lines
|
|
{
|
|
CNFGDrawText("Lines on double-buffered 232x220.", 2);
|
|
if (framessostate > 60) {
|
|
int avail_w = FBW2 - margin_left - margin_right;
|
|
int avail_h = FBH - margin_top - margin_bottom - 30;
|
|
for (i = 0; i < 350; i++) {
|
|
CNFGColor(rand() % 16);
|
|
CNFGTackSegment(rand() % avail_w + margin_left,
|
|
rand() % avail_h + 30 + margin_top,
|
|
rand() % avail_w + margin_left,
|
|
rand() % avail_h + 30 + margin_top);
|
|
}
|
|
}
|
|
if (framessostate > 240) {
|
|
newstate = 7;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 5: // Memory visualization (simplified for ESP32)
|
|
CNFGColor(17);
|
|
CNFGTackRectangle(70 + margin_left, 110 + margin_top,
|
|
180 + margin_left, 150 + margin_top);
|
|
CNFGColor(16);
|
|
if (framessostate > 160) newstate = 6;
|
|
// Fall through to case 4
|
|
__attribute__((fallthrough));
|
|
|
|
case 4: // Text mode demo
|
|
CNFGPenY += 14 * 7;
|
|
CNFGPenX += 60;
|
|
CNFGDrawText("38x14 TEXT MODE", 2);
|
|
|
|
CNFGPenY += 14;
|
|
CNFGPenX -= 5;
|
|
CNFGDrawText("...on 232x220 gfx", 2);
|
|
|
|
if (framessostate > 60 && showstate == 4) {
|
|
newstate = 5;
|
|
}
|
|
break;
|
|
|
|
case 3: // Scrolling character demo
|
|
for (y = 0; y < 14; y++) {
|
|
for (x = 0; x < 38; x++) {
|
|
i = x + y + 1;
|
|
if (i < framessostate && i > framessostate - 60)
|
|
lastct[x] = (i != 10 && i != 9) ? i : ' ';
|
|
else
|
|
lastct[x] = ' ';
|
|
}
|
|
if (y == 7) {
|
|
memcpy(lastct + 10, "36x12 TEXT MODE", 15);
|
|
}
|
|
lastct[x] = 0;
|
|
CNFGDrawText(lastct, 2);
|
|
CNFGPenY += 14;
|
|
if (framessostate > 120) newstate = 4;
|
|
}
|
|
break;
|
|
|
|
case 2: // ESP32 features list
|
|
ctx += sprintf(ctx, "ESP32 Features:\n 240 MHz Xtensa LX6\n 520kB SRAM\n WiFi 802.11 b/g/n\n Bluetooth 4.2\n"
|
|
" GPIO, SPI, I2C, UART\n PWM, ADC, DAC\n I2S with DMA\n\n Analog Broadcast TV\n");
|
|
{
|
|
int il = ctx - lastct;
|
|
if (framessostate / 2 < il)
|
|
lastct[framessostate / 2] = 0;
|
|
else
|
|
showtemp++;
|
|
}
|
|
CNFGDrawText(lastct, 2);
|
|
if (showtemp == 60) newstate = 3;
|
|
break;
|
|
|
|
case 1: // Transition out
|
|
i = strlen(lastct);
|
|
lastct[i - framessostate] = 0;
|
|
if (i - framessostate == 1) newstate = 2;
|
|
// Fall through to case 0
|
|
__attribute__((fallthrough));
|
|
|
|
case 0: // Main status display
|
|
{
|
|
CNFGDrawText(lastct, 2);
|
|
|
|
ctx += sprintf(ctx, "Channel 3 Broadcasting.\nframe: %d\n", gframe);
|
|
|
|
#ifdef CONFIG_WIFI_MODE_STATION
|
|
// Get WiFi status in station mode
|
|
wifi_ap_record_t ap_info;
|
|
esp_err_t err = esp_wifi_sta_get_ap_info(&ap_info);
|
|
int rssi = (err == ESP_OK) ? ap_info.rssi : 0;
|
|
ctx += sprintf(ctx, "rssi: %d\n", rssi);
|
|
if (wifi_connected) {
|
|
ctx += sprintf(ctx, "IP: %s\n", wifi_ip_str);
|
|
} else {
|
|
ctx += sprintf(ctx, "Connecting...\n");
|
|
}
|
|
#else
|
|
ctx += sprintf(ctx, "AP IP: %s\n", wifi_ip_str);
|
|
#endif
|
|
|
|
ctx += sprintf(ctx, "ESP32 Online\n");
|
|
showtemp++;
|
|
if (showtemp == 30) newstate = 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Handle state transitions
|
|
if (showstate != newstate && showallowadvance) {
|
|
// Start transition for rotation-based screen changes
|
|
// (Weather=13, Clock=16, HA Sensor=17, Image=12)
|
|
if (default_transition != TRANS_NONE && !transition_active &&
|
|
(showstate == 13 || showstate == 16 || showstate == 17 || showstate == 12) &&
|
|
(newstate == 13 || newstate == 16 || newstate == 17 || newstate == 12)) {
|
|
memcpy(prev_frame, frontframe, sizeof(prev_frame));
|
|
transition_active = 1;
|
|
transition_type = default_transition;
|
|
transition_speed = default_trans_speed;
|
|
transition_progress = 0;
|
|
}
|
|
showstate = newstate;
|
|
framessostate = 0;
|
|
showtemp = 0;
|
|
} else {
|
|
framessostate++;
|
|
}
|
|
|
|
// Apply transition effect if active (blends prev_frame with current frontframe)
|
|
render_transition();
|
|
}
|
|
|
|
/**
|
|
* @brief Video rendering task
|
|
*
|
|
* Runs on core 1 to handle frame rendering without interfering with WiFi
|
|
*/
|
|
static void video_task(void *arg)
|
|
{
|
|
uint8_t lastframe = 0;
|
|
|
|
ESP_LOGI(TAG, "Video task started on core %d", xPortGetCoreID());
|
|
|
|
while (1) {
|
|
uint8_t tbuffer = !(gframe & 1);
|
|
|
|
if (lastframe != tbuffer) {
|
|
frontframe = (uint8_t*)&framebuffer[((FBW2 / 4) * FBH) * tbuffer];
|
|
DrawFrame();
|
|
lastframe = tbuffer;
|
|
}
|
|
|
|
// Small delay to prevent watchdog issues
|
|
vTaskDelay(1);
|
|
}
|
|
}
|
|
|
|
#ifdef CONFIG_WIFI_MODE_STATION
|
|
|
|
static int retry_count = 0;
|
|
|
|
/**
|
|
* @brief WiFi event handler for station mode
|
|
*/
|
|
static void wifi_event_handler(void *arg, esp_event_base_t event_base,
|
|
int32_t event_id, void *event_data)
|
|
{
|
|
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
|
|
// Start a scan first, then connect after scan completes
|
|
wifi_scan_config_t scan_config = { .show_hidden = true };
|
|
esp_wifi_scan_start(&scan_config, false);
|
|
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) {
|
|
ESP_LOGI(TAG, "Scan complete, connecting...");
|
|
esp_wifi_connect();
|
|
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_CONNECTED) {
|
|
ESP_LOGI(TAG, "WiFi connected, waiting for IP...");
|
|
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
|
|
wifi_connected = false;
|
|
retry_count++;
|
|
if (retry_count < 10) {
|
|
ESP_LOGI(TAG, "Disconnected, retry %d...", retry_count);
|
|
vTaskDelay(pdMS_TO_TICKS(2000)); // Wait before retry
|
|
esp_wifi_connect();
|
|
} else {
|
|
ESP_LOGE(TAG, "Failed to connect after %d retries", retry_count);
|
|
}
|
|
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
|
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
|
|
snprintf(wifi_ip_str, sizeof(wifi_ip_str), IPSTR, IP2STR(&event->ip_info.ip));
|
|
wifi_connected = true;
|
|
retry_count = 0;
|
|
ESP_LOGI(TAG, "Connected! IP: %s", wifi_ip_str);
|
|
|
|
// Initialize SNTP for time sync
|
|
if (esp_sntp_enabled()) {
|
|
esp_sntp_stop();
|
|
}
|
|
esp_sntp_setoperatingmode(SNTP_OPMODE_POLL);
|
|
esp_sntp_setservername(0, "pool.ntp.org");
|
|
esp_sntp_init();
|
|
ESP_LOGI(TAG, "SNTP initialized, syncing time...");
|
|
|
|
// Set timezone to Central Time (Austin, TX)
|
|
setenv("TZ", "CST6CDT,M3.2.0,M11.1.0", 1);
|
|
tzset();
|
|
|
|
// Load MQTT config and start client
|
|
load_mqtt_config();
|
|
start_mqtt_client();
|
|
|
|
// Load Home Assistant config
|
|
load_ha_config();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Initialize WiFi in Station mode
|
|
*/
|
|
static void wifi_init_station(void)
|
|
{
|
|
ESP_ERROR_CHECK(esp_netif_init());
|
|
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
|
esp_netif_create_default_wifi_sta();
|
|
|
|
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
|
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
|
|
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID,
|
|
&wifi_event_handler, NULL, NULL));
|
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP,
|
|
&wifi_event_handler, NULL, NULL));
|
|
|
|
wifi_config_t wifi_config = {
|
|
.sta = {
|
|
.ssid = CONFIG_WIFI_STA_SSID,
|
|
.password = CONFIG_WIFI_STA_PASS,
|
|
.threshold.authmode = WIFI_AUTH_OPEN, // Accept any auth mode
|
|
.sae_pwe_h2e = WPA3_SAE_PWE_BOTH,
|
|
},
|
|
};
|
|
|
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
|
ESP_ERROR_CHECK(esp_wifi_start());
|
|
|
|
ESP_LOGI(TAG, "WiFi Station mode initialized. Connecting to: %s", CONFIG_WIFI_STA_SSID);
|
|
}
|
|
#else
|
|
/**
|
|
* @brief Initialize WiFi in SoftAP mode
|
|
*/
|
|
static void wifi_init_softap(void)
|
|
{
|
|
ESP_ERROR_CHECK(esp_netif_init());
|
|
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
|
esp_netif_create_default_wifi_ap();
|
|
|
|
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
|
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
|
|
|
wifi_config_t wifi_config = {
|
|
.ap = {
|
|
.ssid = CONFIG_WIFI_SOFTAP_SSID,
|
|
.ssid_len = strlen(CONFIG_WIFI_SOFTAP_SSID),
|
|
.channel = 1,
|
|
.password = CONFIG_WIFI_SOFTAP_PASS,
|
|
.max_connection = 4,
|
|
.authmode = WIFI_AUTH_WPA_WPA2_PSK
|
|
},
|
|
};
|
|
|
|
if (strlen(CONFIG_WIFI_SOFTAP_PASS) == 0) {
|
|
wifi_config.ap.authmode = WIFI_AUTH_OPEN;
|
|
}
|
|
|
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
|
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config));
|
|
ESP_ERROR_CHECK(esp_wifi_start());
|
|
|
|
wifi_connected = true;
|
|
snprintf(wifi_ip_str, sizeof(wifi_ip_str), "192.168.4.1");
|
|
ESP_LOGI(TAG, "WiFi SoftAP initialized. SSID: %s", CONFIG_WIFI_SOFTAP_SSID);
|
|
}
|
|
#endif
|
|
|
|
/**
|
|
* @brief Application entry point
|
|
*/
|
|
void app_main(void)
|
|
{
|
|
ESP_LOGI(TAG, "=================================");
|
|
ESP_LOGI(TAG, " Channel3 ESP32 - RF Broadcast ");
|
|
ESP_LOGI(TAG, "=================================");
|
|
|
|
// Initialize 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);
|
|
|
|
// Load calibration margins from NVS
|
|
load_margins();
|
|
|
|
// Load uploaded image from NVS if available
|
|
load_uploaded_image();
|
|
|
|
// Initialize WiFi
|
|
#ifdef CONFIG_WIFI_MODE_STATION
|
|
wifi_init_station();
|
|
|
|
// Wait for WiFi connection before starting video
|
|
// This avoids the cache conflict during WiFi handshake
|
|
ESP_LOGI(TAG, "Waiting for WiFi connection before starting video...");
|
|
int wait_count = 0;
|
|
while (!wifi_connected && wait_count < 30) { // Wait up to 30 seconds
|
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
|
wait_count++;
|
|
ESP_LOGI(TAG, "Waiting for WiFi... (%d/30)", wait_count);
|
|
}
|
|
|
|
if (wifi_connected) {
|
|
ESP_LOGI(TAG, "WiFi connected! IP: %s", wifi_ip_str);
|
|
start_webserver();
|
|
// Start video streaming server
|
|
xTaskCreate(stream_server_task, "stream_server", 8192, NULL, 4, NULL);
|
|
// Initial weather fetch
|
|
ESP_LOGI(TAG, "Fetching initial weather data...");
|
|
fetch_weather();
|
|
} else {
|
|
ESP_LOGW(TAG, "WiFi connection timeout, starting video anyway");
|
|
}
|
|
#else
|
|
wifi_init_softap();
|
|
start_webserver();
|
|
// Start video streaming server
|
|
xTaskCreate(stream_server_task, "stream_server", 8192, NULL, 4, NULL);
|
|
#endif
|
|
|
|
// Setup 3D matrices
|
|
SetupMatrix();
|
|
|
|
// Initialize video broadcast
|
|
video_broadcast_init();
|
|
video_running = true;
|
|
|
|
// Create video rendering task on core 1
|
|
// Core 0 is used for WiFi, so we use core 1 for video
|
|
xTaskCreatePinnedToCore(video_task, "video_task", 8192, NULL, 5, NULL, 1);
|
|
|
|
ESP_LOGI(TAG, "Channel3 ESP32 initialized");
|
|
#ifdef CONFIG_WIFI_MODE_STATION
|
|
ESP_LOGI(TAG, "Connected to WiFi network '%s'", CONFIG_WIFI_STA_SSID);
|
|
#else
|
|
ESP_LOGI(TAG, "Connect to WiFi AP '%s' to access web interface", CONFIG_WIFI_SOFTAP_SSID);
|
|
#endif
|
|
ESP_LOGI(TAG, "Tune analog TV to Channel 3 to view broadcast");
|
|
ESP_LOGI(TAG, "Video stream server on port %d", STREAM_PORT);
|
|
|
|
// Main loop - monitor status and refresh weather
|
|
while (1) {
|
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
|
ESP_LOGI(TAG, "Frame: %d, Frame time: %lu us", gframe, (unsigned long)last_internal_frametime);
|
|
|
|
// Check if MQTT needs restart (set by web config handler)
|
|
if (mqtt_needs_restart) {
|
|
mqtt_needs_restart = false;
|
|
ESP_LOGI(TAG, "Restarting MQTT client with new config...");
|
|
if (mqtt_client) {
|
|
esp_mqtt_client_stop(mqtt_client);
|
|
esp_mqtt_client_destroy(mqtt_client);
|
|
mqtt_client = NULL;
|
|
}
|
|
start_mqtt_client();
|
|
}
|
|
|
|
// Periodic weather refresh
|
|
uint32_t now = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
|
if (wifi_connected && (now - last_weather_fetch) > WEATHER_FETCH_INTERVAL_MS) {
|
|
ESP_LOGI(TAG, "Refreshing weather data...");
|
|
fetch_weather();
|
|
}
|
|
|
|
// Periodic Home Assistant sensor refresh
|
|
if (wifi_connected && ha_url[0] && ha_token[0] &&
|
|
(now - last_ha_fetch) > ha_poll_interval_ms) {
|
|
ESP_LOGI(TAG, "Refreshing HA sensor data...");
|
|
fetch_all_ha_sensors();
|
|
}
|
|
}
|
|
}
|