Pirate TV for the esp32
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.
 
 
 
 
 

4952 lines
181 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 "esp_ota_ops.h"
#include "esp_app_format.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;
// OBJ model storage
#define MAX_OBJ_VERTICES 500
#define MAX_OBJ_EDGES 1000
static int16_t obj_vertices[MAX_OBJ_VERTICES * 3]; // x,y,z per vertex
static uint16_t obj_edges[MAX_OBJ_EDGES * 2]; // v1,v2 per edge
static uint16_t obj_vertex_count = 0;
static uint16_t obj_edge_count = 0;
static bool has_obj_model = false;
static int16_t obj_zoom = 500; // Z distance (100-1500)
static uint8_t obj_rot_x = 0; // X rotation (0-255)
static uint8_t obj_rot_y = 0; // Y rotation (0-255)
static uint8_t obj_rot_z = 0; // Z rotation (0-255)
static uint8_t obj_thickness = 1; // Line thickness (1-5)
// 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"
#define NVS_KEY_OBJ_VERTS "obj_verts"
#define NVS_KEY_OBJ_EDGES "obj_edges"
#define NVS_KEY_OBJ_VCNT "obj_vcnt"
#define NVS_KEY_OBJ_ECNT "obj_ecnt"
// 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
#define SCREEN_TYPE_OBJ_MODEL 4
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 Save OBJ model to NVS
*/
static void save_obj_model(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 OBJ save: %s", esp_err_to_name(err));
video_broadcast_resume();
return;
}
// Save vertex and edge counts
nvs_set_u16(nvs, NVS_KEY_OBJ_VCNT, obj_vertex_count);
nvs_set_u16(nvs, NVS_KEY_OBJ_ECNT, obj_edge_count);
// Save vertex data
size_t verts_size = obj_vertex_count * 3 * sizeof(int16_t);
err = nvs_set_blob(nvs, NVS_KEY_OBJ_VERTS, obj_vertices, verts_size);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to save OBJ vertices: %s", esp_err_to_name(err));
}
// Save edge data
size_t edges_size = obj_edge_count * 2 * sizeof(uint16_t);
err = nvs_set_blob(nvs, NVS_KEY_OBJ_EDGES, obj_edges, edges_size);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to save OBJ edges: %s", esp_err_to_name(err));
}
nvs_commit(nvs);
nvs_close(nvs);
video_broadcast_resume();
ESP_LOGI(TAG, "Saved OBJ model: %d vertices, %d edges", obj_vertex_count, obj_edge_count);
}
/**
* @brief Load OBJ model from NVS
*/
static void load_obj_model(void)
{
nvs_handle_t nvs;
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &nvs);
if (err != ESP_OK) {
return;
}
uint16_t vcnt = 0, ecnt = 0;
nvs_get_u16(nvs, NVS_KEY_OBJ_VCNT, &vcnt);
nvs_get_u16(nvs, NVS_KEY_OBJ_ECNT, &ecnt);
if (vcnt > 0 && vcnt <= MAX_OBJ_VERTICES && ecnt > 0 && ecnt <= MAX_OBJ_EDGES) {
size_t verts_size = vcnt * 3 * sizeof(int16_t);
size_t edges_size = ecnt * 2 * sizeof(uint16_t);
err = nvs_get_blob(nvs, NVS_KEY_OBJ_VERTS, obj_vertices, &verts_size);
if (err == ESP_OK) {
err = nvs_get_blob(nvs, NVS_KEY_OBJ_EDGES, obj_edges, &edges_size);
if (err == ESP_OK) {
obj_vertex_count = vcnt;
obj_edge_count = ecnt;
has_obj_model = true;
ESP_LOGI(TAG, "Loaded OBJ model: %d vertices, %d edges", vcnt, ecnt);
}
}
}
nvs_close(nvs);
}
/**
* @brief Clear OBJ model from memory and NVS
*/
static void clear_obj_model(void)
{
obj_vertex_count = 0;
obj_edge_count = 0;
has_obj_model = false;
nvs_handle_t nvs;
video_broadcast_pause();
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) {
nvs_erase_key(nvs, NVS_KEY_OBJ_VERTS);
nvs_erase_key(nvs, NVS_KEY_OBJ_EDGES);
nvs_erase_key(nvs, NVS_KEY_OBJ_VCNT);
nvs_erase_key(nvs, NVS_KEY_OBJ_ECNT);
nvs_commit(nvs);
nvs_close(nvs);
}
video_broadcast_resume();
ESP_LOGI(TAG, "Cleared OBJ model");
}
/**
* @brief Check if edge already exists (avoid duplicates)
*/
static bool edge_exists(uint16_t v1, uint16_t v2, uint16_t count)
{
for (uint16_t i = 0; i < count; i++) {
uint16_t e1 = obj_edges[i * 2];
uint16_t e2 = obj_edges[i * 2 + 1];
if ((e1 == v1 && e2 == v2) || (e1 == v2 && e2 == v1)) {
return true;
}
}
return false;
}
/**
* @brief Parse OBJ file data and populate vertex/edge arrays
*/
static bool parse_obj_data(const char *data, size_t len)
{
// Reset counts
obj_vertex_count = 0;
obj_edge_count = 0;
has_obj_model = false;
// First pass: count vertices and find bounds
float min_x = 1e9f, max_x = -1e9f;
float min_y = 1e9f, max_y = -1e9f;
float min_z = 1e9f, max_z = -1e9f;
// Temporary storage for float vertices (we'll convert after finding bounds)
float *temp_verts = malloc(MAX_OBJ_VERTICES * 3 * sizeof(float));
if (!temp_verts) {
ESP_LOGE(TAG, "Failed to allocate temp vertex buffer");
return false;
}
uint16_t vert_count = 0;
const char *p = data;
const char *end = data + len;
while (p < end) {
// Skip whitespace
while (p < end && (*p == ' ' || *p == '\t')) p++;
if (p >= end) break;
// Parse vertex line: "v x y z"
if (*p == 'v' && p + 1 < end && p[1] == ' ') {
if (vert_count >= MAX_OBJ_VERTICES) {
ESP_LOGW(TAG, "OBJ vertex limit reached (%d)", MAX_OBJ_VERTICES);
break;
}
p += 2; // Skip "v "
float x = 0, y = 0, z = 0;
// Parse x
while (p < end && (*p == ' ' || *p == '\t')) p++;
x = strtof(p, (char**)&p);
// Parse y
while (p < end && (*p == ' ' || *p == '\t')) p++;
y = strtof(p, (char**)&p);
// Parse z
while (p < end && (*p == ' ' || *p == '\t')) p++;
z = strtof(p, (char**)&p);
temp_verts[vert_count * 3 + 0] = x;
temp_verts[vert_count * 3 + 1] = y;
temp_verts[vert_count * 3 + 2] = z;
if (x < min_x) min_x = x;
if (x > max_x) max_x = x;
if (y < min_y) min_y = y;
if (y > max_y) max_y = y;
if (z < min_z) min_z = z;
if (z > max_z) max_z = z;
vert_count++;
}
// Skip to end of line
while (p < end && *p != '\n' && *p != '\r') p++;
while (p < end && (*p == '\n' || *p == '\r')) p++;
}
if (vert_count == 0) {
ESP_LOGE(TAG, "No vertices found in OBJ");
free(temp_verts);
return false;
}
// Calculate scale to fit in [-200, 200] range
float width = max_x - min_x;
float height = max_y - min_y;
float depth = max_z - min_z;
float max_dim = width > height ? width : height;
if (depth > max_dim) max_dim = depth;
float scale = (max_dim > 0) ? (400.0f / max_dim) : 1.0f;
// Center offsets
float cx = (min_x + max_x) / 2.0f;
float cy = (min_y + max_y) / 2.0f;
float cz = (min_z + max_z) / 2.0f;
// Convert to fixed-point centered vertices
for (uint16_t i = 0; i < vert_count; i++) {
obj_vertices[i * 3 + 0] = (int16_t)((temp_verts[i * 3 + 0] - cx) * scale);
obj_vertices[i * 3 + 1] = (int16_t)((temp_verts[i * 3 + 1] - cy) * scale);
obj_vertices[i * 3 + 2] = (int16_t)((temp_verts[i * 3 + 2] - cz) * scale);
}
free(temp_verts);
obj_vertex_count = vert_count;
// Second pass: parse faces and extract edges
p = data;
uint16_t edge_count = 0;
while (p < end) {
// Skip whitespace
while (p < end && (*p == ' ' || *p == '\t')) p++;
if (p >= end) break;
// Parse face line: "f v1 v2 v3 ..." or "f v1/vt1/vn1 v2/vt2/vn2 ..."
if (*p == 'f' && p + 1 < end && (p[1] == ' ' || p[1] == '\t')) {
p += 2; // Skip "f "
uint16_t face_verts[16];
int face_vert_count = 0;
while (p < end && *p != '\n' && *p != '\r' && face_vert_count < 16) {
// Skip whitespace
while (p < end && (*p == ' ' || *p == '\t')) p++;
if (p >= end || *p == '\n' || *p == '\r') break;
// Parse vertex index (1-based in OBJ)
int v = strtol(p, (char**)&p, 10);
if (v > 0 && v <= vert_count) {
face_verts[face_vert_count++] = (uint16_t)(v - 1); // Convert to 0-based
}
// Skip texture/normal indices (e.g., "/vt/vn")
while (p < end && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++;
}
// Create edges from face (connect consecutive vertices + last to first)
for (int i = 0; i < face_vert_count && edge_count < MAX_OBJ_EDGES; i++) {
uint16_t v1 = face_verts[i];
uint16_t v2 = face_verts[(i + 1) % face_vert_count];
// Check for duplicate edges
if (!edge_exists(v1, v2, edge_count)) {
obj_edges[edge_count * 2 + 0] = v1;
obj_edges[edge_count * 2 + 1] = v2;
edge_count++;
}
}
}
// Skip to end of line
while (p < end && *p != '\n' && *p != '\r') p++;
while (p < end && (*p == '\n' || *p == '\r')) p++;
}
obj_edge_count = edge_count;
has_obj_model = (vert_count > 0 && edge_count > 0);
ESP_LOGI(TAG, "Parsed OBJ: %d vertices, %d edges, scale=%.2f",
vert_count, edge_count, scale);
return has_obj_model;
}
/**
* @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;
case SCREEN_TYPE_OBJ_MODEL: return 18;
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
"&current=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 class='sep'><b>Firmware Update (OTA):</b></div>"
"<div class='flex'><input type='file' id='fwFile' accept='.bin' style='width:150px'>"
"<button class='btn' onclick='uploadFirmware()'>UPDATE</button></div>"
"<div id='fwVersion' class='msg' style='margin-top:4px'></div>"
"<div id='otaStatus' 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>> 3D_MODEL</h2><div class='panel'>"
"<div class='flex'><input type='file' id='objFile' accept='.obj' style='width:150px'>"
"<button class='btn' onclick='uploadObj()'>UPLOAD</button>"
"<button class='btn' onclick='clearObj()'>CLEAR</button></div>"
"<div id='objStatus' class='msg'>No model loaded</div>"
"<div class='sep'><b>View:</b></div>"
"<div class='flex'><label>Zoom:<input type='range' id='objZoom' min='100' max='1500' value='500' style='width:80px' oninput='setObjView()'></label>"
"<span id='objZoomVal'>500</span></div>"
"<div class='flex'><label>X:<input type='range' id='objRotX' min='0' max='255' value='0' style='width:60px' oninput='setObjView()'></label>"
"<span id='objRotXVal'>0</span>"
"<label>Y:<input type='range' id='objRotY' min='0' max='255' value='0' style='width:60px' oninput='setObjView()'></label>"
"<span id='objRotYVal'>0</span>"
"<label>Z:<input type='range' id='objRotZ' min='0' max='255' value='0' style='width:60px' oninput='setObjView()'></label>"
"<span id='objRotZVal'>0</span></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>"
"<button class='btn' onclick='quickScreen(18)'>3D MODEL</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><option value='4'>3D Model</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;}"
"function loadObjStatus(){fetch('/obj/status').then(r=>r.json()).then(d=>{"
"document.getElementById('objStatus').innerText=d.loaded?(d.vertices+' vertices, '+d.edges+' edges'):'No model loaded';"
"document.getElementById('objZoom').value=d.zoom;document.getElementById('objZoomVal').innerText=d.zoom;"
"document.getElementById('objRotX').value=d.rx;document.getElementById('objRotXVal').innerText=d.rx;"
"document.getElementById('objRotY').value=d.ry;document.getElementById('objRotYVal').innerText=d.ry;"
"document.getElementById('objRotZ').value=d.rz;document.getElementById('objRotZVal').innerText=d.rz;"
"}).catch(e=>console.log(e));}"
"function setObjView(){var z=document.getElementById('objZoom').value;"
"var rx=document.getElementById('objRotX').value,ry=document.getElementById('objRotY').value,rz=document.getElementById('objRotZ').value;"
"document.getElementById('objZoomVal').innerText=z;"
"document.getElementById('objRotXVal').innerText=rx;document.getElementById('objRotYVal').innerText=ry;document.getElementById('objRotZVal').innerText=rz;"
"fetch('/obj/settings?zoom='+z+'&rx='+rx+'&ry='+ry+'&rz='+rz);}"
"function uploadObj(){var f=document.getElementById('objFile').files[0];"
"if(!f){alert('Select an OBJ file');return;}"
"document.getElementById('objStatus').innerText='Uploading...';"
"var reader=new FileReader();reader.onload=function(e){"
"fetch('/obj/upload',{method:'POST',body:e.target.result}).then(r=>r.text()).then(t=>{"
"document.getElementById('objStatus').innerText=t;loadObjStatus();}).catch(e=>{"
"document.getElementById('objStatus').innerText='Error: '+e;});};reader.readAsText(f);}"
"function clearObj(){fetch('/obj/clear').then(()=>loadObjStatus());}"
"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','3D Model'];"
"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);}"
"function loadOtaStatus(){fetch('/ota/status').then(r=>r.json()).then(d=>{"
"document.getElementById('fwVersion').innerText='v'+d.version+' ('+d.running+') '+d.date;}).catch(e=>{});}"
"function uploadFirmware(){var f=document.getElementById('fwFile').files[0];"
"if(!f){alert('Select a .bin firmware file first');return;}"
"if(!f.name.endsWith('.bin')){alert('File must be a .bin firmware file');return;}"
"if(!confirm('Update firmware to '+f.name+'?\\nDevice will reboot after update.')){return;}"
"document.getElementById('otaStatus').innerText='Uploading firmware...';"
"fetch('/ota',{method:'POST',body:f,headers:{'Content-Type':'application/octet-stream'}}).then(r=>{"
"if(r.ok){document.getElementById('otaStatus').innerText='Update complete! Rebooting...';}"
"else{r.text().then(t=>{document.getElementById('otaStatus').innerText='Update failed: '+t;});}}"
").catch(e=>{document.getElementById('otaStatus').innerText='Upload failed: '+e;});}"
"updateStatus();loadMqtt();loadHaConfig();loadRotation();loadTransition();loadOtaStatus();loadObjStatus();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 POST /obj/upload - Upload OBJ file
*/
static esp_err_t obj_upload_handler(httpd_req_t *req)
{
// Limit to reasonable size (64KB max)
if (req->content_len > 65536) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "File too large (max 64KB)");
return ESP_FAIL;
}
// Allocate temp buffer
char *buf = malloc(req->content_len + 1);
if (!buf) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
// Receive OBJ text
int received = 0;
while (received < req->content_len) {
int ret = httpd_req_recv(req, buf + received, req->content_len - received);
if (ret <= 0) {
if (ret == HTTPD_SOCK_ERR_TIMEOUT) continue;
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Receive failed");
return ESP_FAIL;
}
received += ret;
}
buf[received] = '\0';
// Parse OBJ
if (parse_obj_data(buf, received)) {
save_obj_model(); // Persist to NVS
char resp[64];
snprintf(resp, sizeof(resp), "Loaded: %d vertices, %d edges",
obj_vertex_count, obj_edge_count);
httpd_resp_send(req, resp, -1);
} else {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Parse failed");
}
free(buf);
return ESP_OK;
}
/**
* @brief Handler for GET /obj/status - Return OBJ model status as JSON
*/
static esp_err_t obj_status_handler(httpd_req_t *req)
{
char response[192];
snprintf(response, sizeof(response),
"{\"loaded\":%s,\"vertices\":%d,\"edges\":%d,\"zoom\":%d,\"rx\":%d,\"ry\":%d,\"rz\":%d,\"thick\":%d}",
has_obj_model ? "true" : "false",
obj_vertex_count, obj_edge_count, obj_zoom, obj_rot_x, obj_rot_y, obj_rot_z, obj_thickness);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, response, strlen(response));
return ESP_OK;
}
/**
* @brief Handler for GET /obj/clear - Clear OBJ model
*/
static esp_err_t obj_clear_handler(httpd_req_t *req)
{
clear_obj_model();
httpd_resp_send(req, "Model cleared", -1);
return ESP_OK;
}
/**
* @brief Handler for GET /obj/settings - Set zoom and rotation
*/
static esp_err_t obj_settings_handler(httpd_req_t *req)
{
char buf[128];
char param[16];
if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
if (httpd_query_key_value(buf, "zoom", param, sizeof(param)) == ESP_OK) {
int z = atoi(param);
if (z >= 100 && z <= 1500) obj_zoom = z;
}
if (httpd_query_key_value(buf, "rx", param, sizeof(param)) == ESP_OK) {
int r = atoi(param);
if (r >= 0 && r <= 255) obj_rot_x = r;
}
if (httpd_query_key_value(buf, "ry", param, sizeof(param)) == ESP_OK) {
int r = atoi(param);
if (r >= 0 && r <= 255) obj_rot_y = r;
}
if (httpd_query_key_value(buf, "rz", param, sizeof(param)) == ESP_OK) {
int r = atoi(param);
if (r >= 0 && r <= 255) obj_rot_z = r;
}
if (httpd_query_key_value(buf, "thick", param, sizeof(param)) == ESP_OK) {
int t = atoi(param);
if (t >= 1 && t <= 5) obj_thickness = t;
}
}
char response[128];
snprintf(response, sizeof(response), "{\"zoom\":%d,\"rx\":%d,\"ry\":%d,\"rz\":%d,\"thick\":%d}", obj_zoom, obj_rot_x, obj_rot_y, obj_rot_z, obj_thickness);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, response, strlen(response));
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 Handler for POST /ota - Over-The-Air firmware update
*/
static esp_err_t ota_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "OTA update started, firmware size: %d bytes", req->content_len);
// Get the next OTA partition
const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL);
if (!update_partition) {
ESP_LOGE(TAG, "OTA: No update partition found");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "No OTA partition");
return ESP_FAIL;
}
ESP_LOGI(TAG, "OTA: Writing to partition '%s' at offset 0x%lx",
update_partition->label, update_partition->address);
// Start OTA
esp_ota_handle_t ota_handle;
esp_err_t err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &ota_handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA begin failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed");
return ESP_FAIL;
}
// Receive and write firmware in chunks
char *buf = malloc(4096);
if (!buf) {
esp_ota_abort(ota_handle);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
int total_received = 0;
int remaining = req->content_len;
int last_progress = -1;
while (remaining > 0) {
int to_read = remaining > 4096 ? 4096 : remaining;
int received = httpd_req_recv(req, buf, to_read);
if (received <= 0) {
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
continue;
}
ESP_LOGE(TAG, "OTA receive error at %d bytes", total_received);
free(buf);
esp_ota_abort(ota_handle);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Receive failed");
return ESP_FAIL;
}
err = esp_ota_write(ota_handle, buf, received);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA write failed: %s", esp_err_to_name(err));
free(buf);
esp_ota_abort(ota_handle);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Flash write failed");
return ESP_FAIL;
}
total_received += received;
remaining -= received;
// Log progress every 10%
int progress = (total_received * 100) / req->content_len;
if (progress / 10 != last_progress / 10) {
ESP_LOGI(TAG, "OTA progress: %d%% (%d/%d bytes)", progress, total_received, req->content_len);
last_progress = progress;
}
}
free(buf);
// Finish OTA
err = esp_ota_end(ota_handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA end failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA validation failed");
return ESP_FAIL;
}
// Set boot partition
err = esp_ota_set_boot_partition(update_partition);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Set boot partition failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Set boot failed");
return ESP_FAIL;
}
ESP_LOGI(TAG, "OTA update successful! Rebooting...");
httpd_resp_send(req, "OTA update successful! Rebooting...", -1);
// Delay to allow response to be sent, then reboot
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart();
return ESP_OK;
}
/**
* @brief Handler for GET /ota/status - Return OTA partition info
*/
static esp_err_t ota_status_handler(httpd_req_t *req)
{
const esp_partition_t *running = esp_ota_get_running_partition();
const esp_partition_t *next = esp_ota_get_next_update_partition(NULL);
const esp_app_desc_t *app_desc = esp_app_get_description();
char response[512];
snprintf(response, sizeof(response),
"{\"running\":\"%s\",\"next\":\"%s\",\"version\":\"%s\",\"idf\":\"%s\",\"date\":\"%s\",\"time\":\"%s\"}",
running ? running->label : "unknown",
next ? next->label : "unknown",
app_desc->version,
app_desc->idf_ver,
app_desc->date,
app_desc->time);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, response, strlen(response));
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);
// OBJ model endpoints
httpd_uri_t obj_upload_uri = {
.uri = "/obj/upload",
.method = HTTP_POST,
.handler = obj_upload_handler
};
httpd_register_uri_handler(http_server, &obj_upload_uri);
httpd_uri_t obj_status_uri = {
.uri = "/obj/status",
.method = HTTP_GET,
.handler = obj_status_handler
};
httpd_register_uri_handler(http_server, &obj_status_uri);
httpd_uri_t obj_clear_uri = {
.uri = "/obj/clear",
.method = HTTP_GET,
.handler = obj_clear_handler
};
httpd_register_uri_handler(http_server, &obj_clear_uri);
httpd_uri_t obj_settings_uri = {
.uri = "/obj/settings",
.method = HTTP_GET,
.handler = obj_settings_handler
};
httpd_register_uri_handler(http_server, &obj_settings_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);
// OTA firmware update endpoints
httpd_uri_t ota_uri = {
.uri = "/ota",
.method = HTTP_POST,
.handler = ota_handler
};
httpd_register_uri_handler(http_server, &ota_uri);
httpd_uri_t ota_status_uri = {
.uri = "/ota/status",
.method = HTTP_GET,
.handler = ota_status_handler
};
httpd_register_uri_handler(http_server, &ota_status_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 18: // OBJ Model wireframe
{
if (has_obj_model) {
CNFGColor(15); // White wireframe
SetupMatrix();
// Apply manual rotation
tdRotateEA(ModelviewMatrix, obj_rot_x, obj_rot_y, obj_rot_z);
// Push model back from camera (Z distance)
ModelviewMatrix[11] = obj_zoom;
// Draw all edges (1 pixel thin)
for (int e = 0; e < obj_edge_count; e++) {
int16_t *v1 = &obj_vertices[obj_edges[e * 2] * 3];
int16_t *v2 = &obj_vertices[obj_edges[e * 2 + 1] * 3];
Draw3DSegment(v1, v2);
}
// Display vertex/edge count at bottom
char info[32];
snprintf(info, sizeof(info), "%dv %de", obj_vertex_count, obj_edge_count);
CNFGColor(8); // Gray
CNFGPenX = 2 + margin_left;
CNFGPenY = 200 + margin_top;
CNFGDrawText(info, 1);
} else {
// No model loaded - show message
CNFGColor(7);
int msg_width = 8 * 3 * 2; // "NO MODEL" width
CNFGPenX = (FBW2 - msg_width) / 2;
CNFGPenY = 100 + margin_top;
CNFGDrawText("NO MODEL", 2);
CNFGColor(8);
msg_width = 18 * 3 * 1; // "Upload via web UI" width
CNFGPenX = (FBW2 - msg_width) / 2;
CNFGPenY = 130 + margin_top;
CNFGDrawText("Upload via web UI", 1);
}
// 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();
// Load OBJ model from NVS if available
load_obj_model();
// 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();
}
}
}