/** * @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 #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/event_groups.h" #include "esp_system.h" #include "esp_wifi.h" #include "esp_event.h" #include "esp_log.h" #include "nvs_flash.h" #include "esp_netif.h" #include "esp_timer.h" #include "esp_http_server.h" #include "esp_http_client.h" #include "lwip/sockets.h" #include "lwip/netdb.h" #include "esp_sntp.h" #include "esp_crt_bundle.h" #include "cJSON.h" #include "mqtt_client.h" #include "video_broadcast.h" #include "3d.h" static const char *TAG = "channel3"; // From video_broadcast.c - for jam_color control extern int8_t jam_color; // Video broadcast state static bool video_running = false; // WiFi connection state static bool wifi_connected = false; static char wifi_ip_str[16] = "0.0.0.0"; // Demo state machine #define INITIAL_SHOW_STATE 13 // Start with weather display extern int gframe; extern uint32_t last_internal_frametime; static char lastct[256]; static uint8_t showstate = INITIAL_SHOW_STATE; static uint8_t showallowadvance = 1; static int framessostate = 0; static int showtemp = 0; // HTTP Server handle static httpd_handle_t http_server = NULL; // Uploaded image buffer (116x220 at 4bpp = 12760 bytes) #define IMG_WIDTH 116 #define IMG_HEIGHT 220 #define IMG_BUFFER_SIZE ((IMG_WIDTH * IMG_HEIGHT) / 2) static uint8_t uploaded_image[IMG_BUFFER_SIZE]; static bool has_uploaded_image = false; // Video streaming server #define STREAM_PORT 5000 static bool streaming_active = false; // Weather data static char weather_city[24] = "Loading..."; static char weather_condition[24] = ""; static char weather_temp[8] = "--"; static char weather_humidity[8] = "--"; static char weather_wind[16] = "--"; static uint32_t last_weather_fetch = 0; static uint32_t last_weather_screen_toggle = 0; static int weather_screen_page = 0; // 0 = current conditions, 1 = forecast #define WEATHER_FETCH_INTERVAL_MS 300000 // 5 minutes #define WEATHER_SCREEN_TOGGLE_MS 5000 // 5 seconds per screen // Cedar Park, TX location (78613) #define WEATHER_CITY_NAME "Cedar Park" #define WEATHER_LAT "30.51" #define WEATHER_LON "-97.82" // Hourly forecast (6 entries, every 2 hours) #define FORECAST_ENTRIES 6 typedef struct { char time[8]; // "2pm", "4pm", etc. char temp[8]; // "56F" char cond[12]; // "Rain", "Clear", etc. char humidity[8]; // "100%" char wind[12]; // "3mph" } forecast_entry_t; static forecast_entry_t hourly_forecast[FORECAST_ENTRIES]; // Screen calibration margins (stored in NVS) static int8_t margin_left = 0; static int8_t margin_top = 0; static int8_t margin_right = 0; static int8_t margin_bottom = 0; #define NVS_NAMESPACE "channel3" #define NVS_KEY_MARGIN_L "margin_l" #define NVS_KEY_MARGIN_T "margin_t" #define NVS_KEY_MARGIN_R "margin_r" #define NVS_KEY_MARGIN_B "margin_b" // MQTT Configuration (stored in NVS) #define ALERT_DURATION_MS 5000 #define MAX_ALERTS 4 #define NVS_KEY_MQTT_BROKER "mqtt_broker" #define NVS_KEY_MQTT_PORT "mqtt_port" #define NVS_KEY_MQTT_USER "mqtt_user" #define NVS_KEY_MQTT_PASS "mqtt_pass" #define NVS_KEY_ALERT_TOPIC "alert_topic" #define NVS_KEY_ALERT_MSG "alert_msg" static char mqtt_broker[64] = "10.0.0.18"; static uint16_t mqtt_port = 1883; static char mqtt_username[64] = "homeassistant"; static char mqtt_password[128] = "oes5gohng9gau1Quei2ohpixashi4Thidoon1shohGai2mae0ru2zaph2vooshai"; static esp_mqtt_client_handle_t mqtt_client = NULL; // Alert configurations (topic -> message mapping) typedef struct { char topic[64]; char message[24]; // Short message for TV display } alert_config_t; static alert_config_t alerts[MAX_ALERTS] = { { "channel3/intruder", "INTRUDER!" }, { "channel3/door", "DOOR OPENED!" }, { "", "" }, { "", "" } }; // Alert state static volatile bool alert_active = false; static uint32_t alert_start_time = 0; static char current_alert_message[64] = "ALERT!"; // MQTT debug state static volatile bool mqtt_connected = false; static volatile bool mqtt_needs_restart = false; static int mqtt_subscribe_msg_ids[MAX_ALERTS] = {0}; static int mqtt_subscribed_count = 0; static char mqtt_last_topic[64] = ""; static char mqtt_last_data[32] = ""; static uint32_t mqtt_last_msg_time = 0; static uint32_t mqtt_connect_count = 0; // HTTP client response buffer (Open-Meteo JSON is ~1.5KB) static char http_response_buffer[2048]; static int http_response_len = 0; // ============================================================================ // Home Assistant Integration // ============================================================================ // HA Connection Config (stored in NVS) #define NVS_KEY_HA_URL "ha_url" #define NVS_KEY_HA_TOKEN "ha_token" #define NVS_KEY_HA_INTERVAL "ha_interval" #define NVS_KEY_IMAGE "uploaded_img" static char ha_url[96] = ""; // e.g., "http://10.0.0.5:8123" static char ha_token[256] = ""; // Long-lived access token static uint32_t ha_poll_interval_ms = 60000; // Sensor Config (max 8 sensors) #define MAX_HA_SENSORS 8 #define HA_DISPLAY_TEXT 0 #define HA_DISPLAY_GAUGE 1 typedef struct { char entity_id[64]; // e.g., "sensor.outdoor_temperature" char attribute[32]; // e.g., "humidity" or empty for state char name[16]; // User-friendly display name uint8_t display_type; // HA_DISPLAY_TEXT or HA_DISPLAY_GAUGE int16_t min_value; // Gauge minimum int16_t max_value; // Gauge maximum uint8_t enabled; // Include in rotation char cached_value[16]; // Runtime: cached display value uint32_t last_update; // Runtime: last fetch timestamp } ha_sensor_config_t; static ha_sensor_config_t ha_sensors[MAX_HA_SENSORS] = {0}; static int ha_sensor_count = 0; static int ha_current_sensor = 0; static uint32_t last_ha_fetch = 0; // Screen Rotation Configuration #define MAX_ROTATION_SLOTS 12 #define SCREEN_TYPE_WEATHER 0 #define SCREEN_TYPE_CLOCK 1 #define SCREEN_TYPE_HA_SENSOR 2 #define SCREEN_TYPE_IMAGE 3 typedef struct { uint8_t screen_type; // 0=Weather, 1=Clock, 2=HA Sensor, 3=Image uint8_t sensor_idx; // For HA sensors: which sensor (0-7) uint8_t enabled; // Include in rotation uint16_t duration_sec; // How long to display (5-300 seconds) } rotation_slot_t; static rotation_slot_t rotation_slots[MAX_ROTATION_SLOTS] = { {SCREEN_TYPE_WEATHER, 0, 1, 30}, // Weather, 30 sec {SCREEN_TYPE_CLOCK, 0, 1, 15}, // Clock, 15 sec {0, 0, 0, 0}, // Empty slots }; static uint8_t rotation_count = 2; static uint8_t current_rotation_idx = 0; static uint32_t rotation_slot_start_time = 0; // Screen Transition Configuration #define TRANS_NONE 0 #define TRANS_FADE 1 #define TRANS_WIPE_L 2 // New slides in from right #define TRANS_WIPE_R 3 // New slides in from left #define TRANS_WIPE_D 4 // New slides in from top #define TRANS_WIPE_U 5 // New slides in from bottom #define TRANS_DISSOLVE 6 #define NVS_KEY_TRANS_TYPE "trans_type" #define NVS_KEY_TRANS_SPEED "trans_speed" // Transition state static uint8_t transition_active = 0; static uint8_t transition_type = TRANS_NONE; static uint8_t transition_progress = 0; // 0-255 (0=start, 255=complete) static uint8_t transition_speed = 12; // Progress increment per frame (~20 frames) static uint8_t prev_frame[(FBW2/2) * FBH]; // Store previous frame for blending // Global default transition (NVS persisted) static uint8_t default_transition = TRANS_FADE; static uint8_t default_trans_speed = 12; /** * @brief Load screen margins from NVS */ static void load_margins(void) { nvs_handle_t nvs; if (nvs_open(NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) { nvs_get_i8(nvs, NVS_KEY_MARGIN_L, &margin_left); nvs_get_i8(nvs, NVS_KEY_MARGIN_T, &margin_top); nvs_get_i8(nvs, NVS_KEY_MARGIN_R, &margin_right); nvs_get_i8(nvs, NVS_KEY_MARGIN_B, &margin_bottom); nvs_close(nvs); ESP_LOGI(TAG, "Loaded margins: L=%d T=%d R=%d B=%d", margin_left, margin_top, margin_right, margin_bottom); } } /** * @brief Save screen margins to NVS */ static void save_margins(void) { nvs_handle_t nvs; video_broadcast_pause(); if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) { nvs_set_i8(nvs, NVS_KEY_MARGIN_L, margin_left); nvs_set_i8(nvs, NVS_KEY_MARGIN_T, margin_top); nvs_set_i8(nvs, NVS_KEY_MARGIN_R, margin_right); nvs_set_i8(nvs, NVS_KEY_MARGIN_B, margin_bottom); nvs_commit(nvs); nvs_close(nvs); ESP_LOGI(TAG, "Saved margins: L=%d T=%d R=%d B=%d", margin_left, margin_top, margin_right, margin_bottom); } video_broadcast_resume(); } /** * @brief Save uploaded image to NVS */ static void save_uploaded_image(void) { nvs_handle_t nvs; video_broadcast_pause(); esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to open NVS for image save: %s", esp_err_to_name(err)); video_broadcast_resume(); return; } // Get NVS stats to check available space nvs_stats_t nvs_stats; if (nvs_get_stats(NULL, &nvs_stats) == ESP_OK) { ESP_LOGI(TAG, "NVS stats: used=%d free=%d total=%d", nvs_stats.used_entries, nvs_stats.free_entries, nvs_stats.total_entries); } err = nvs_set_blob(nvs, NVS_KEY_IMAGE, uploaded_image, IMG_BUFFER_SIZE); if (err == ESP_OK) { err = nvs_commit(nvs); if (err == ESP_OK) { ESP_LOGI(TAG, "Saved uploaded image to NVS (%d bytes)", IMG_BUFFER_SIZE); } else { ESP_LOGE(TAG, "Failed to commit image to NVS: %s", esp_err_to_name(err)); } } else { ESP_LOGE(TAG, "Failed to save image to NVS: %s (size=%d)", esp_err_to_name(err), IMG_BUFFER_SIZE); } nvs_close(nvs); video_broadcast_resume(); } /** * @brief Load uploaded image from NVS */ static void load_uploaded_image(void) { nvs_handle_t nvs; esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &nvs); if (err != ESP_OK) { ESP_LOGW(TAG, "Failed to open NVS for image load: %s", esp_err_to_name(err)); return; } size_t len = IMG_BUFFER_SIZE; err = nvs_get_blob(nvs, NVS_KEY_IMAGE, uploaded_image, &len); if (err == ESP_OK && len == IMG_BUFFER_SIZE) { has_uploaded_image = true; ESP_LOGI(TAG, "Loaded uploaded image from NVS (%d bytes)", len); } else if (err == ESP_ERR_NVS_NOT_FOUND) { ESP_LOGI(TAG, "No saved image found in NVS"); } else { ESP_LOGE(TAG, "Failed to load image from NVS: %s (len=%d, expected=%d)", esp_err_to_name(err), len, IMG_BUFFER_SIZE); } nvs_close(nvs); } /** * @brief Load MQTT configuration from NVS */ static void load_mqtt_config(void) { nvs_handle_t nvs; if (nvs_open(NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) { size_t len = sizeof(mqtt_broker); nvs_get_str(nvs, NVS_KEY_MQTT_BROKER, mqtt_broker, &len); nvs_get_u16(nvs, NVS_KEY_MQTT_PORT, &mqtt_port); len = sizeof(mqtt_username); nvs_get_str(nvs, NVS_KEY_MQTT_USER, mqtt_username, &len); len = sizeof(mqtt_password); nvs_get_str(nvs, NVS_KEY_MQTT_PASS, mqtt_password, &len); // Load alert configurations (only overwrite if NVS has non-empty value) for (int i = 0; i < MAX_ALERTS; i++) { char key[16]; char temp[64]; snprintf(key, sizeof(key), "%s%d", NVS_KEY_ALERT_TOPIC, i); len = sizeof(temp); if (nvs_get_str(nvs, key, temp, &len) == ESP_OK && temp[0]) { strncpy(alerts[i].topic, temp, sizeof(alerts[i].topic) - 1); alerts[i].topic[sizeof(alerts[i].topic) - 1] = '\0'; } snprintf(key, sizeof(key), "%s%d", NVS_KEY_ALERT_MSG, i); len = sizeof(temp); if (nvs_get_str(nvs, key, temp, &len) == ESP_OK && temp[0]) { strncpy(alerts[i].message, temp, sizeof(alerts[i].message) - 1); alerts[i].message[sizeof(alerts[i].message) - 1] = '\0'; } } nvs_close(nvs); ESP_LOGI(TAG, "MQTT config: %s:%d user=%s", mqtt_broker, mqtt_port, mqtt_username); for (int i = 0; i < MAX_ALERTS; i++) { if (alerts[i].topic[0]) { ESP_LOGI(TAG, "Alert %d: %s -> %s", i, alerts[i].topic, alerts[i].message); } } } } /** * @brief Save MQTT configuration to NVS */ static void save_mqtt_config(void) { nvs_handle_t nvs; if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) { nvs_set_str(nvs, NVS_KEY_MQTT_BROKER, mqtt_broker); nvs_set_u16(nvs, NVS_KEY_MQTT_PORT, mqtt_port); nvs_set_str(nvs, NVS_KEY_MQTT_USER, mqtt_username); nvs_set_str(nvs, NVS_KEY_MQTT_PASS, mqtt_password); // Save alert configurations for (int i = 0; i < MAX_ALERTS; i++) { char key[16]; snprintf(key, sizeof(key), "%s%d", NVS_KEY_ALERT_TOPIC, i); nvs_set_str(nvs, key, alerts[i].topic); snprintf(key, sizeof(key), "%s%d", NVS_KEY_ALERT_MSG, i); nvs_set_str(nvs, key, alerts[i].message); } nvs_commit(nvs); nvs_close(nvs); } } /** * @brief Load Home Assistant configuration from NVS */ static void load_ha_config(void) { nvs_handle_t nvs; if (nvs_open(NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) { size_t len = sizeof(ha_url); nvs_get_str(nvs, NVS_KEY_HA_URL, ha_url, &len); len = sizeof(ha_token); nvs_get_str(nvs, NVS_KEY_HA_TOKEN, ha_token, &len); nvs_get_u32(nvs, NVS_KEY_HA_INTERVAL, &ha_poll_interval_ms); // Load sensor configurations ha_sensor_count = 0; for (int i = 0; i < MAX_HA_SENSORS; i++) { char key[16]; char temp[64]; // Load entity_id snprintf(key, sizeof(key), "ha_ent%d", i); len = sizeof(temp); if (nvs_get_str(nvs, key, temp, &len) == ESP_OK && temp[0]) { strncpy(ha_sensors[i].entity_id, temp, sizeof(ha_sensors[i].entity_id) - 1); // Load attribute snprintf(key, sizeof(key), "ha_attr%d", i); len = sizeof(ha_sensors[i].attribute); nvs_get_str(nvs, key, ha_sensors[i].attribute, &len); // Load display name snprintf(key, sizeof(key), "ha_name%d", i); len = sizeof(ha_sensors[i].name); nvs_get_str(nvs, key, ha_sensors[i].name, &len); // Load display type snprintf(key, sizeof(key), "ha_type%d", i); uint8_t dtype = 0; nvs_get_u8(nvs, key, &dtype); ha_sensors[i].display_type = dtype; // Load gauge range snprintf(key, sizeof(key), "ha_min%d", i); nvs_get_i16(nvs, key, &ha_sensors[i].min_value); snprintf(key, sizeof(key), "ha_max%d", i); nvs_get_i16(nvs, key, &ha_sensors[i].max_value); // Load enabled flag snprintf(key, sizeof(key), "ha_en%d", i); uint8_t en = 1; nvs_get_u8(nvs, key, &en); ha_sensors[i].enabled = en; ha_sensor_count++; ESP_LOGI(TAG, "HA Sensor %d: %s (attr=%s, name=%s, type=%d)", i, ha_sensors[i].entity_id, ha_sensors[i].attribute, ha_sensors[i].name, ha_sensors[i].display_type); } } // Load rotation configuration uint8_t rot_count = 0; if (nvs_get_u8(nvs, "rot_count", &rot_count) == ESP_OK && rot_count > 0) { rotation_count = (rot_count > MAX_ROTATION_SLOTS) ? MAX_ROTATION_SLOTS : rot_count; for (int i = 0; i < rotation_count; i++) { char key[16]; snprintf(key, sizeof(key), "rot_type%d", i); nvs_get_u8(nvs, key, &rotation_slots[i].screen_type); snprintf(key, sizeof(key), "rot_sens%d", i); nvs_get_u8(nvs, key, &rotation_slots[i].sensor_idx); snprintf(key, sizeof(key), "rot_en%d", i); nvs_get_u8(nvs, key, &rotation_slots[i].enabled); snprintf(key, sizeof(key), "rot_dur%d", i); nvs_get_u16(nvs, key, &rotation_slots[i].duration_sec); } } // Load transition configuration nvs_get_u8(nvs, NVS_KEY_TRANS_TYPE, &default_transition); nvs_get_u8(nvs, NVS_KEY_TRANS_SPEED, &default_trans_speed); if (default_trans_speed < 1) default_trans_speed = 1; if (default_trans_speed > 50) default_trans_speed = 50; ESP_LOGI(TAG, "Transition config: type=%d, speed=%d", default_transition, default_trans_speed); nvs_close(nvs); ESP_LOGI(TAG, "HA config loaded: URL=%s, interval=%lums, %d sensors, %d rotation slots", ha_url, (unsigned long)ha_poll_interval_ms, ha_sensor_count, rotation_count); } // Initialize rotation timer rotation_slot_start_time = xTaskGetTickCount() * portTICK_PERIOD_MS; current_rotation_idx = 0; } /** * @brief Save Home Assistant configuration to NVS */ static void save_ha_config(void) { nvs_handle_t nvs; video_broadcast_pause(); // Pause video to prevent flash cache conflicts if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) { nvs_set_str(nvs, NVS_KEY_HA_URL, ha_url); nvs_set_str(nvs, NVS_KEY_HA_TOKEN, ha_token); nvs_set_u32(nvs, NVS_KEY_HA_INTERVAL, ha_poll_interval_ms); nvs_commit(nvs); nvs_close(nvs); ESP_LOGI(TAG, "HA config saved"); } video_broadcast_resume(); } /** * @brief Save a single HA sensor configuration to NVS */ static void save_ha_sensor(int idx) { if (idx < 0 || idx >= MAX_HA_SENSORS) return; nvs_handle_t nvs; video_broadcast_pause(); if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) { char key[16]; snprintf(key, sizeof(key), "ha_ent%d", idx); nvs_set_str(nvs, key, ha_sensors[idx].entity_id); snprintf(key, sizeof(key), "ha_attr%d", idx); nvs_set_str(nvs, key, ha_sensors[idx].attribute); snprintf(key, sizeof(key), "ha_name%d", idx); nvs_set_str(nvs, key, ha_sensors[idx].name); snprintf(key, sizeof(key), "ha_type%d", idx); nvs_set_u8(nvs, key, ha_sensors[idx].display_type); snprintf(key, sizeof(key), "ha_min%d", idx); nvs_set_i16(nvs, key, ha_sensors[idx].min_value); snprintf(key, sizeof(key), "ha_max%d", idx); nvs_set_i16(nvs, key, ha_sensors[idx].max_value); snprintf(key, sizeof(key), "ha_en%d", idx); nvs_set_u8(nvs, key, ha_sensors[idx].enabled); nvs_commit(nvs); nvs_close(nvs); } video_broadcast_resume(); } /** * @brief Save rotation configuration to NVS */ static void save_rotation_config(void) { nvs_handle_t nvs; video_broadcast_pause(); if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) { nvs_set_u8(nvs, "rot_count", rotation_count); for (int i = 0; i < rotation_count; i++) { char key[16]; snprintf(key, sizeof(key), "rot_type%d", i); nvs_set_u8(nvs, key, rotation_slots[i].screen_type); snprintf(key, sizeof(key), "rot_sens%d", i); nvs_set_u8(nvs, key, rotation_slots[i].sensor_idx); snprintf(key, sizeof(key), "rot_en%d", i); nvs_set_u8(nvs, key, rotation_slots[i].enabled); snprintf(key, sizeof(key), "rot_dur%d", i); nvs_set_u16(nvs, key, rotation_slots[i].duration_sec); } nvs_commit(nvs); nvs_close(nvs); ESP_LOGI(TAG, "Rotation config saved: %d slots", rotation_count); } video_broadcast_resume(); } /** * @brief Save transition configuration to NVS */ static void save_transition_config(void) { nvs_handle_t nvs; video_broadcast_pause(); if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) { nvs_set_u8(nvs, NVS_KEY_TRANS_TYPE, default_transition); nvs_set_u8(nvs, NVS_KEY_TRANS_SPEED, default_trans_speed); nvs_commit(nvs); nvs_close(nvs); ESP_LOGI(TAG, "Transition config saved: type=%d, speed=%d", default_transition, default_trans_speed); } video_broadcast_resume(); } /** * @brief Get showstate value for a rotation screen type */ static int rotation_type_to_state(uint8_t screen_type) { switch (screen_type) { case SCREEN_TYPE_WEATHER: return 13; case SCREEN_TYPE_CLOCK: return 16; case SCREEN_TYPE_HA_SENSOR: return 17; case SCREEN_TYPE_IMAGE: return 12; default: return 13; } } /** * @brief Advance to next enabled rotation slot * @return The showstate for the new slot */ static int advance_rotation(void) { if (rotation_count == 0) return 13; // Default to weather // Find next enabled slot for (int tries = 0; tries < rotation_count; tries++) { current_rotation_idx = (current_rotation_idx + 1) % rotation_count; if (rotation_slots[current_rotation_idx].enabled) { rotation_slot_start_time = xTaskGetTickCount() * portTICK_PERIOD_MS; // For HA sensor type, set which sensor to display if (rotation_slots[current_rotation_idx].screen_type == SCREEN_TYPE_HA_SENSOR) { ha_current_sensor = rotation_slots[current_rotation_idx].sensor_idx; } return rotation_type_to_state(rotation_slots[current_rotation_idx].screen_type); } } return 13; // Default to weather if nothing enabled } /** * @brief Check if current rotation slot duration has expired */ static bool rotation_duration_expired(void) { if (rotation_count == 0) return false; uint32_t now = xTaskGetTickCount() * portTICK_PERIOD_MS; uint32_t duration_ms = rotation_slots[current_rotation_idx].duration_sec * 1000; return (now - rotation_slot_start_time) >= duration_ms; } /** * @brief MQTT event handler */ static void mqtt_event_handler(void *args, esp_event_base_t base, int32_t event_id, void *event_data) { esp_mqtt_event_handle_t event = event_data; switch ((esp_mqtt_event_id_t)event_id) { case MQTT_EVENT_CONNECTED: ESP_LOGI(TAG, "MQTT connected to broker"); mqtt_connected = true; mqtt_connect_count++; mqtt_subscribed_count = 0; // Subscribe to channel3/# wildcard - any message payload becomes the alert text { int msg_id = esp_mqtt_client_subscribe(event->client, "channel3/#", 0); mqtt_subscribe_msg_ids[0] = msg_id; ESP_LOGI(TAG, "Subscribed to 'channel3/#' msg_id=%d", msg_id); } break; case MQTT_EVENT_SUBSCRIBED: ESP_LOGI(TAG, "MQTT subscribed, msg_id=%d", event->msg_id); mqtt_subscribed_count++; break; case MQTT_EVENT_DATA: ESP_LOGI(TAG, "MQTT data: topic_len=%d data_len=%d", event->topic_len, event->data_len); mqtt_last_msg_time = xTaskGetTickCount() * portTICK_PERIOD_MS; // Store last received topic/data for debug if (event->topic_len > 0) { int len = event->topic_len < 63 ? event->topic_len : 63; memcpy(mqtt_last_topic, event->topic, len); mqtt_last_topic[len] = '\0'; } if (event->data_len > 0) { int len = event->data_len < 31 ? event->data_len : 31; memcpy(mqtt_last_data, event->data, len); mqtt_last_data[len] = '\0'; } // Use the message payload as the alert text if (event->data_len > 0) { int len = event->data_len < sizeof(current_alert_message) - 1 ? event->data_len : sizeof(current_alert_message) - 1; memcpy(current_alert_message, event->data, len); current_alert_message[len] = '\0'; alert_active = true; framessostate = 0; // Reset frame counter for alert duration showstate = 15; // Switch to alert screen showallowadvance = 0; ESP_LOGI(TAG, "ALERT triggered: %s", current_alert_message); } break; case MQTT_EVENT_DISCONNECTED: ESP_LOGW(TAG, "MQTT disconnected"); mqtt_connected = false; break; default: break; } } /** * @brief Start MQTT client with current configuration */ static void start_mqtt_client(void) { char uri[128]; snprintf(uri, sizeof(uri), "mqtt://%s:%d", mqtt_broker, mqtt_port); esp_mqtt_client_config_t mqtt_cfg = { .broker.address.uri = uri, }; // Add credentials if configured if (mqtt_username[0]) { mqtt_cfg.credentials.username = mqtt_username; } if (mqtt_password[0]) { mqtt_cfg.credentials.authentication.password = mqtt_password; } mqtt_client = esp_mqtt_client_init(&mqtt_cfg); esp_mqtt_client_register_event(mqtt_client, ESP_EVENT_ANY_ID, mqtt_event_handler, NULL); esp_mqtt_client_start(mqtt_client); ESP_LOGI(TAG, "MQTT client started: %s user=%s", uri, mqtt_username[0] ? mqtt_username : "(none)"); } /** * @brief HTTP event handler - accumulates response data */ static esp_err_t http_event_handler(esp_http_client_event_t *evt) { switch (evt->event_id) { case HTTP_EVENT_ON_DATA: if (http_response_len + evt->data_len < sizeof(http_response_buffer) - 1) { memcpy(http_response_buffer + http_response_len, evt->data, evt->data_len); http_response_len += evt->data_len; http_response_buffer[http_response_len] = '\0'; } else { ESP_LOGW(TAG, "HTTP response buffer overflow! Current: %d, incoming: %d, max: %d", http_response_len, evt->data_len, sizeof(http_response_buffer)); } break; default: break; } return ESP_OK; } /** * @brief Helper to make HTTP request and get response */ static bool http_get(const char *url) { http_response_len = 0; http_response_buffer[0] = '\0'; esp_http_client_config_t config = { .url = url, .event_handler = http_event_handler, .timeout_ms = 15000, .crt_bundle_attach = esp_crt_bundle_attach, // Enable HTTPS with CA bundle }; esp_http_client_handle_t client = esp_http_client_init(&config); if (!client) return false; esp_http_client_set_header(client, "User-Agent", "curl/7.0"); esp_err_t err = esp_http_client_perform(client); int status = esp_http_client_get_status_code(client); esp_http_client_cleanup(client); return (err == ESP_OK && status == 200 && http_response_len > 0); } /** * @brief Map WMO weather code to short condition string * WMO codes: https://open-meteo.com/en/docs (weather_code description) */ static const char* wmo_to_condition(int code) { switch (code) { case 0: return "Clear"; case 1: return "Mostly Clr"; case 2: return "PtCloud"; case 3: return "Cloudy"; case 45: case 48: return "Fog"; case 51: case 53: case 55: return "Drizzle"; case 56: case 57: return "FrzDrzl"; case 61: case 63: case 65: return "Rain"; case 66: case 67: return "FrzRain"; case 71: case 73: case 75: return "Snow"; case 77: return "SnowGrn"; case 80: case 81: case 82: return "Showers"; case 85: case 86: return "SnowShwr"; case 95: return "TStorm"; case 96: case 99: return "TStorm+"; default: return "Unknown"; } } /** * @brief Format hour from ISO timestamp to 12-hour format * Input: "2025-01-21T14:00" -> Output: "2pm" */ static void format_hour_from_iso(const char *iso_time, char *out, size_t out_size) { // Extract hour from "YYYY-MM-DDTHH:MM" const char *t = strchr(iso_time, 'T'); if (!t || strlen(t) < 3) { strncpy(out, "??", out_size); return; } int hour = atoi(t + 1); // Ensure valid hour range 0-23 if (hour < 0) hour = 0; if (hour > 23) hour = 23; if (hour == 0) { snprintf(out, out_size, "12am"); } else if (hour < 12) { snprintf(out, out_size, "%dam", hour); // max "11am" = 4 chars } else if (hour == 12) { snprintf(out, out_size, "12pm"); } else { snprintf(out, out_size, "%dpm", hour - 12); // max "11pm" = 4 chars } } /** * @brief Fetch weather data from Open-Meteo API * Uses ~1.5KB JSON with proper hourly forecasts */ static void fetch_weather(void) { if (!wifi_connected) { ESP_LOGW(TAG, "WiFi not connected, skipping weather fetch"); return; } // Open-Meteo API URL for Cedar Park, TX const char *url = "https://api.open-meteo.com/v1/forecast?" "latitude=" WEATHER_LAT "&longitude=" WEATHER_LON "¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m" "&hourly=temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m" "&temperature_unit=fahrenheit&wind_speed_unit=mph" "&timezone=America/Chicago&forecast_hours=12"; ESP_LOGI(TAG, "Fetching weather from Open-Meteo..."); if (!http_get(url)) { ESP_LOGE(TAG, "Failed to fetch weather"); strcpy(weather_city, "Fetch fail"); return; } ESP_LOGI(TAG, "Response length: %d bytes", http_response_len); // Parse JSON cJSON *root = cJSON_Parse(http_response_buffer); if (!root) { ESP_LOGE(TAG, "JSON parse failed"); strcpy(weather_city, "Parse fail"); return; } // Set city name (static - Open-Meteo doesn't return location name) strncpy(weather_city, WEATHER_CITY_NAME, sizeof(weather_city) - 1); weather_city[sizeof(weather_city) - 1] = '\0'; // Parse current conditions cJSON *current = cJSON_GetObjectItem(root, "current"); if (current) { cJSON *temp = cJSON_GetObjectItem(current, "temperature_2m"); cJSON *humidity = cJSON_GetObjectItem(current, "relative_humidity_2m"); cJSON *weather_code = cJSON_GetObjectItem(current, "weather_code"); cJSON *wind = cJSON_GetObjectItem(current, "wind_speed_10m"); if (temp && cJSON_IsNumber(temp)) { snprintf(weather_temp, sizeof(weather_temp), "%.0fF", temp->valuedouble); } if (humidity && cJSON_IsNumber(humidity)) { snprintf(weather_humidity, sizeof(weather_humidity), "%.0f%%", humidity->valuedouble); } if (weather_code && cJSON_IsNumber(weather_code)) { strncpy(weather_condition, wmo_to_condition((int)weather_code->valuedouble), sizeof(weather_condition) - 1); weather_condition[sizeof(weather_condition) - 1] = '\0'; } if (wind && cJSON_IsNumber(wind)) { snprintf(weather_wind, sizeof(weather_wind), "%.0fmph", wind->valuedouble); } } // Parse hourly forecast cJSON *hourly = cJSON_GetObjectItem(root, "hourly"); if (hourly) { cJSON *times = cJSON_GetObjectItem(hourly, "time"); cJSON *temps = cJSON_GetObjectItem(hourly, "temperature_2m"); cJSON *codes = cJSON_GetObjectItem(hourly, "weather_code"); cJSON *humids = cJSON_GetObjectItem(hourly, "relative_humidity_2m"); cJSON *winds = cJSON_GetObjectItem(hourly, "wind_speed_10m"); if (times && temps && codes && humids && winds && cJSON_IsArray(times) && cJSON_IsArray(temps)) { int array_size = cJSON_GetArraySize(times); // Get 6 forecasts at 2-hour intervals (indices 0, 2, 4, 6, 8, 10) for (int i = 0; i < FORECAST_ENTRIES && (i * 2) < array_size; i++) { int idx = i * 2; // Every 2 hours cJSON *time_item = cJSON_GetArrayItem(times, idx); cJSON *temp_item = cJSON_GetArrayItem(temps, idx); cJSON *code_item = cJSON_GetArrayItem(codes, idx); cJSON *humid_item = cJSON_GetArrayItem(humids, idx); cJSON *wind_item = cJSON_GetArrayItem(winds, idx); if (time_item && cJSON_IsString(time_item)) { format_hour_from_iso(time_item->valuestring, hourly_forecast[i].time, sizeof(hourly_forecast[i].time)); } if (temp_item && cJSON_IsNumber(temp_item)) { snprintf(hourly_forecast[i].temp, sizeof(hourly_forecast[i].temp), "%.0fF", temp_item->valuedouble); } if (code_item && cJSON_IsNumber(code_item)) { strncpy(hourly_forecast[i].cond, wmo_to_condition((int)code_item->valuedouble), sizeof(hourly_forecast[i].cond) - 1); hourly_forecast[i].cond[sizeof(hourly_forecast[i].cond) - 1] = '\0'; } if (humid_item && cJSON_IsNumber(humid_item)) { snprintf(hourly_forecast[i].humidity, sizeof(hourly_forecast[i].humidity), "%.0f%%", humid_item->valuedouble); } if (wind_item && cJSON_IsNumber(wind_item)) { snprintf(hourly_forecast[i].wind, sizeof(hourly_forecast[i].wind), "%.0fmph", wind_item->valuedouble); } } } } cJSON_Delete(root); last_weather_fetch = xTaskGetTickCount() * portTICK_PERIOD_MS; ESP_LOGI(TAG, "Weather updated: %s - %s %s, Humidity: %s, Wind: %s", weather_city, weather_temp, weather_condition, weather_humidity, weather_wind); } /** * @brief Fetch a single Home Assistant entity state * @param entity_id Entity ID (e.g., "sensor.temperature") * @param attribute Attribute name (empty string for state) * @param out_value Output buffer for the value * @param out_size Size of output buffer * @return true on success */ static bool fetch_ha_entity(const char *entity_id, const char *attribute, char *out_value, size_t out_size) { if (!wifi_connected || !ha_url[0] || !ha_token[0]) { return false; } // Build URL: {ha_url}/api/states/{entity_id} char url[256]; snprintf(url, sizeof(url), "%s/api/states/%s", ha_url, entity_id); http_response_len = 0; http_response_buffer[0] = '\0'; esp_http_client_config_t config = { .url = url, .event_handler = http_event_handler, .timeout_ms = 10000, }; esp_http_client_handle_t client = esp_http_client_init(&config); if (!client) return false; // Add authorization header char auth_header[280]; snprintf(auth_header, sizeof(auth_header), "Bearer %s", ha_token); esp_http_client_set_header(client, "Authorization", auth_header); esp_http_client_set_header(client, "Content-Type", "application/json"); esp_err_t err = esp_http_client_perform(client); int status = esp_http_client_get_status_code(client); esp_http_client_cleanup(client); if (err != ESP_OK || status != 200 || http_response_len == 0) { ESP_LOGW(TAG, "HA fetch failed: %s status=%d", entity_id, status); return false; } // Parse JSON response cJSON *root = cJSON_Parse(http_response_buffer); if (!root) { ESP_LOGW(TAG, "HA JSON parse failed for %s", entity_id); return false; } bool success = false; if (attribute && attribute[0]) { // Get attribute from attributes object cJSON *attrs = cJSON_GetObjectItem(root, "attributes"); if (attrs) { cJSON *attr_val = cJSON_GetObjectItem(attrs, attribute); if (attr_val) { if (cJSON_IsNumber(attr_val)) { snprintf(out_value, out_size, "%.1f", attr_val->valuedouble); success = true; } else if (cJSON_IsString(attr_val)) { strncpy(out_value, attr_val->valuestring, out_size - 1); out_value[out_size - 1] = '\0'; success = true; } } } } else { // Get state value cJSON *state = cJSON_GetObjectItem(root, "state"); if (state && cJSON_IsString(state)) { strncpy(out_value, state->valuestring, out_size - 1); out_value[out_size - 1] = '\0'; success = true; } } cJSON_Delete(root); return success; } /** * @brief Fetch all enabled HA sensors and update cached values */ static void fetch_all_ha_sensors(void) { if (!wifi_connected || !ha_url[0] || !ha_token[0]) { return; } uint32_t now = xTaskGetTickCount() * portTICK_PERIOD_MS; for (int i = 0; i < MAX_HA_SENSORS; i++) { if (ha_sensors[i].entity_id[0] && ha_sensors[i].enabled) { char value[16]; if (fetch_ha_entity(ha_sensors[i].entity_id, ha_sensors[i].attribute, value, sizeof(value))) { strncpy(ha_sensors[i].cached_value, value, sizeof(ha_sensors[i].cached_value) - 1); ha_sensors[i].cached_value[sizeof(ha_sensors[i].cached_value) - 1] = '\0'; ha_sensors[i].last_update = now; ESP_LOGI(TAG, "HA sensor %s = %s", ha_sensors[i].entity_id, value); } } } last_ha_fetch = now; } /** * @brief TCP streaming server task * Receives raw 4bpp frames (12760 bytes each) and displays them */ // Static buffer for streaming to avoid stack overflow static uint8_t stream_frame_buffer[IMG_BUFFER_SIZE]; static void stream_server_task(void *arg) { struct sockaddr_in server_addr, client_addr; socklen_t client_len = sizeof(client_addr); int listen_sock, client_sock; ESP_LOGI(TAG, "Starting video stream server on port %d", STREAM_PORT); // Create socket listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listen_sock < 0) { ESP_LOGE(TAG, "Failed to create stream socket"); vTaskDelete(NULL); return; } // Allow socket reuse int opt = 1; setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // Bind server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = INADDR_ANY; server_addr.sin_port = htons(STREAM_PORT); if (bind(listen_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { ESP_LOGE(TAG, "Failed to bind stream socket"); close(listen_sock); vTaskDelete(NULL); return; } // Listen if (listen(listen_sock, 1) < 0) { ESP_LOGE(TAG, "Failed to listen on stream socket"); close(listen_sock); vTaskDelete(NULL); return; } ESP_LOGI(TAG, "Stream server listening on port %d", STREAM_PORT); while (1) { // Accept connection client_sock = accept(listen_sock, (struct sockaddr *)&client_addr, &client_len); if (client_sock < 0) { ESP_LOGW(TAG, "Accept failed"); continue; } char addr_str[16]; inet_ntoa_r(client_addr.sin_addr, addr_str, sizeof(addr_str)); ESP_LOGI(TAG, "Stream client connected from %s", addr_str); streaming_active = true; showstate = 12; // Switch to uploaded image display showallowadvance = 0; // Disable auto-advance during streaming // Set socket timeout struct timeval timeout = { .tv_sec = 5, .tv_usec = 0 }; setsockopt(client_sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); // Receive frames while (1) { int total_received = 0; int remaining = IMG_BUFFER_SIZE; // Receive one complete frame while (remaining > 0) { int received = recv(client_sock, stream_frame_buffer + total_received, remaining, 0); if (received <= 0) { if (received == 0) { ESP_LOGI(TAG, "Stream client disconnected"); } else if (errno != EAGAIN && errno != EWOULDBLOCK) { ESP_LOGW(TAG, "Stream recv error: %d", errno); } goto disconnect; } total_received += received; remaining -= received; } // Copy frame to display buffer memcpy(uploaded_image, stream_frame_buffer, IMG_BUFFER_SIZE); has_uploaded_image = true; } disconnect: close(client_sock); streaming_active = false; ESP_LOGI(TAG, "Stream ended, received frames displayed"); } close(listen_sock); vTaskDelete(NULL); } // HTML page for web interface static const char *html_page = "Channel3 ESP32" "" "
" "

// CHANNEL3 ESP32

[ RF BROADCAST TERMINAL v1.0 ]
" "
> INITIALIZING...
" "
" "

> TX_CONTROL

" "
" "" "" "
" "
Jam: " "
" "
" "

> CALIBRATION

" "
" "" "" "
" "
" "
" "
Backup/Restore:
" "
" "" "
" "
" "
" "

> IMAGE_UPLOAD

" "
" "
" "
" "
" "

> DEMO_SCREENS

" "" "" "" "" "" "" "" "" "
" "

> ROTATION

" "
" "
" "" "" "
" "
Transition:
" "
" "" "
" "
" "

> MQTT_ALERTS

" "
" "
" "
" "
" "
Topics:
" "
" "
" "
" "
" "
" "
" "
" "

> HOME_ASSISTANT

" "
" "
" "
" "
" "
" "
Sensors:
" "
Add:
" "
" "
" "
" "
" "
" "
" "
" "
" ""; /** * @brief Handler for GET / - main page */ static esp_err_t root_handler(httpd_req_t *req) { httpd_resp_set_type(req, "text/html"); httpd_resp_send(req, html_page, strlen(html_page)); return ESP_OK; } /** * @brief Handler for GET /status - JSON status */ static esp_err_t status_handler(httpd_req_t *req) { char json[420]; snprintf(json, sizeof(json), "{\"frame\":%d,\"frametime\":%lu,\"screen\":%d,\"auto\":%s,\"jam\":%d,\"wifi\":%s,\"ip\":\"%s\"," "\"video\":%s,\"margins\":{\"left\":%d,\"top\":%d,\"right\":%d,\"bottom\":%d}}", gframe, (unsigned long)last_internal_frametime, showstate, showallowadvance ? "true" : "false", (int)jam_color, wifi_connected ? "true" : "false", wifi_ip_str, video_running ? "true" : "false", margin_left, margin_top, margin_right, margin_bottom); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json, strlen(json)); return ESP_OK; } /** * @brief Handler for GET /screen?n=X - change demo screen (legacy) */ static esp_err_t screen_handler(httpd_req_t *req) { char buf[32]; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { char param[8]; if (httpd_query_key_value(buf, "n", param, sizeof(param)) == ESP_OK) { int screen = atoi(param); if (screen >= 0 && screen <= 14) { showstate = screen; showallowadvance = (screen == 7) ? 1 : 0; framessostate = 0; showtemp = 0; ESP_LOGI(TAG, "Screen changed to %d", screen); if (screen == 13) { fetch_weather(); // Refresh weather when switching to weather screen } } } } httpd_resp_send(req, "OK", 2); return ESP_OK; } /** * @brief Handler for GET /control?screen=X&auto=Y - full control */ static esp_err_t control_handler(httpd_req_t *req) { char buf[64]; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { char param[8]; if (httpd_query_key_value(buf, "screen", param, sizeof(param)) == ESP_OK) { int screen = atoi(param); if (screen >= 0 && screen <= 14) { showstate = screen; framessostate = 0; showtemp = 0; ESP_LOGI(TAG, "Screen changed to %d", screen); if (screen == 13) { fetch_weather(); // Refresh weather when switching to weather screen } } } if (httpd_query_key_value(buf, "auto", param, sizeof(param)) == ESP_OK) { showallowadvance = atoi(param) ? 1 : 0; ESP_LOGI(TAG, "Auto-advance: %s", showallowadvance ? "ON" : "OFF"); } } httpd_resp_send(req, "OK", 2); return ESP_OK; } /** * @brief Handler for GET /jam?c=X - set jam color for RF testing * c=-1 disables jam, c=0-15 sets a specific color */ static esp_err_t jam_handler(httpd_req_t *req) { char buf[32]; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { char param[8]; if (httpd_query_key_value(buf, "c", param, sizeof(param)) == ESP_OK) { int color = atoi(param); if (color >= -1 && color <= 15) { jam_color = (int8_t)color; if (color >= 0) { ESP_LOGI(TAG, "Jam color set to %d - RF test mode", color); } else { ESP_LOGI(TAG, "Jam color disabled - normal mode"); } } } } httpd_resp_send(req, "OK", 2); return ESP_OK; } /** * @brief Handler for GET /video?on=0|1 - toggle video broadcast on/off */ static esp_err_t video_toggle_handler(httpd_req_t *req) { char buf[32]; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { char param[8]; if (httpd_query_key_value(buf, "on", param, sizeof(param)) == ESP_OK) { int on = atoi(param); if (on && !video_running) { video_broadcast_init(); video_running = true; ESP_LOGI(TAG, "Video broadcast started"); } else if (!on && video_running) { video_broadcast_stop(); video_running = false; ESP_LOGI(TAG, "Video broadcast stopped"); } } } httpd_resp_send(req, video_running ? "ON" : "OFF", video_running ? 2 : 3); return ESP_OK; } /** * @brief Parse margin values from query string into variables */ static bool parse_margin_params(const char *buf, int8_t *l, int8_t *t, int8_t *r, int8_t *b) { bool changed = false; char param[8]; if (httpd_query_key_value(buf, "l", param, sizeof(param)) == ESP_OK) { int val = atoi(param); if (val >= 0 && val <= 50) { *l = (int8_t)val; changed = true; } } if (httpd_query_key_value(buf, "t", param, sizeof(param)) == ESP_OK) { int val = atoi(param); if (val >= 0 && val <= 50) { *t = (int8_t)val; changed = true; } } if (httpd_query_key_value(buf, "r", param, sizeof(param)) == ESP_OK) { int val = atoi(param); if (val >= 0 && val <= 50) { *r = (int8_t)val; changed = true; } } if (httpd_query_key_value(buf, "b", param, sizeof(param)) == ESP_OK) { int val = atoi(param); if (val >= 0 && val <= 50) { *b = (int8_t)val; changed = true; } } return changed; } /** * @brief Handler for GET /margins/preview - preview margins without saving to NVS * Updates margin values in memory only for real-time preview */ static esp_err_t margins_preview_handler(httpd_req_t *req) { char buf[64]; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { parse_margin_params(buf, &margin_left, &margin_top, &margin_right, &margin_bottom); } // Return current margins as JSON (no save) char json[128]; snprintf(json, sizeof(json), "{\"left\":%d,\"top\":%d,\"right\":%d,\"bottom\":%d}", margin_left, margin_top, margin_right, margin_bottom); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json, strlen(json)); return ESP_OK; } /** * @brief Handler for GET /margins - set and SAVE screen calibration margins to NVS */ static esp_err_t margins_handler(httpd_req_t *req) { char buf[64]; bool changed = false; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { changed = parse_margin_params(buf, &margin_left, &margin_top, &margin_right, &margin_bottom); } if (changed) { save_margins(); ESP_LOGI(TAG, "Margins saved: L=%d T=%d R=%d B=%d", margin_left, margin_top, margin_right, margin_bottom); } // Return current margins as JSON char json[128]; snprintf(json, sizeof(json), "{\"left\":%d,\"top\":%d,\"right\":%d,\"bottom\":%d}", margin_left, margin_top, margin_right, margin_bottom); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, json, strlen(json)); return ESP_OK; } /** * @brief Handler for POST /upload - receive image data */ static esp_err_t upload_handler(httpd_req_t *req) { if (req->content_len != IMG_BUFFER_SIZE) { ESP_LOGE(TAG, "Invalid image size: %d (expected %d)", req->content_len, IMG_BUFFER_SIZE); httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid image size"); return ESP_FAIL; } int received = 0; while (received < IMG_BUFFER_SIZE) { int ret = httpd_req_recv(req, (char*)&uploaded_image[received], IMG_BUFFER_SIZE - received); if (ret <= 0) { if (ret == HTTPD_SOCK_ERR_TIMEOUT) continue; ESP_LOGE(TAG, "Image receive error"); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Receive failed"); return ESP_FAIL; } received += ret; } has_uploaded_image = true; ESP_LOGI(TAG, "Image uploaded: %d bytes", received); save_uploaded_image(); // Save to NVS for persistence httpd_resp_send(req, "Image saved!", -1); return ESP_OK; } /** * @brief Handler for GET /mqtt/status - Return current MQTT config as JSON */ static esp_err_t mqtt_status_handler(httpd_req_t *req) { char response[512]; int len = snprintf(response, sizeof(response), "{\"broker\":\"%s\",\"port\":%d,\"user\":\"%s\",\"connected\":%s,\"alerts\":[", mqtt_broker, mqtt_port, mqtt_username, mqtt_client ? "true" : "false"); for (int i = 0; i < MAX_ALERTS; i++) { if (i > 0) len += snprintf(response + len, sizeof(response) - len, ","); len += snprintf(response + len, sizeof(response) - len, "{\"topic\":\"%s\",\"message\":\"%s\"}", alerts[i].topic, alerts[i].message); } snprintf(response + len, sizeof(response) - len, "]}"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, response, strlen(response)); return ESP_OK; } /** * @brief Handler for GET /mqtt - Save MQTT config with credentials and alerts */ static esp_err_t mqtt_config_handler(httpd_req_t *req) { char buf[256]; char param[64]; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { // Parse user if (httpd_query_key_value(buf, "user", param, sizeof(param)) == ESP_OK) { strncpy(mqtt_username, param, sizeof(mqtt_username)-1); } // Parse pass if (httpd_query_key_value(buf, "pass", param, sizeof(param)) == ESP_OK) { strncpy(mqtt_password, param, sizeof(mqtt_password)-1); } // Parse broker if (httpd_query_key_value(buf, "broker", param, sizeof(param)) == ESP_OK) { strncpy(mqtt_broker, param, sizeof(mqtt_broker)-1); } // Parse port if (httpd_query_key_value(buf, "port", param, sizeof(param)) == ESP_OK) { mqtt_port = atoi(param); } httpd_resp_send(req, "PARSED", -1); return ESP_OK; } httpd_resp_send(req, "NO QUERY", -1); return ESP_OK; } /** * @brief Handler for GET /mqtt/test - Trigger test alert * Use ?n=0 through ?n=3 to test specific alert, default tests alert 0 */ static esp_err_t mqtt_test_handler(httpd_req_t *req) { int idx = 0; char buf[32]; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { char param[8]; if (httpd_query_key_value(buf, "n", param, sizeof(param)) == ESP_OK) { idx = atoi(param); if (idx < 0 || idx >= MAX_ALERTS) idx = 0; } } // Use message from alerts config, or default const char *msg = alerts[idx].message[0] ? alerts[idx].message : "ALERT!"; strncpy(current_alert_message, msg, sizeof(current_alert_message)-1); current_alert_message[sizeof(current_alert_message)-1] = '\0'; alert_active = true; framessostate = 0; // Reset frame counter for alert duration showstate = 15; showallowadvance = 0; httpd_resp_send(req, "OK", 2); return ESP_OK; } /** * @brief Handler for GET /mqtt/debug - Show MQTT debug info */ static esp_err_t mqtt_debug_handler(httpd_req_t *req) { char response[768]; uint32_t now_ms = xTaskGetTickCount() * portTICK_PERIOD_MS; uint32_t last_msg_ago = mqtt_last_msg_time ? (now_ms - mqtt_last_msg_time) / 1000 : 0; int len = snprintf(response, sizeof(response), "MQTT Debug Info\n" "===============\n" "Broker: %s:%d\n" "Connected: %s\n" "Connect count: %lu\n" "Subscriptions confirmed: %d\n\n" "Subscribe msg_ids: [%d, %d, %d, %d]\n\n" "Configured topics:\n" " 0: '%s' -> '%s'\n" " 1: '%s' -> '%s'\n" " 2: '%s' -> '%s'\n" " 3: '%s' -> '%s'\n\n" "Last message:\n" " Topic: '%s'\n" " Data: '%s'\n" " %lu seconds ago\n", mqtt_broker, mqtt_port, mqtt_connected ? "YES" : "NO", (unsigned long)mqtt_connect_count, mqtt_subscribed_count, mqtt_subscribe_msg_ids[0], mqtt_subscribe_msg_ids[1], mqtt_subscribe_msg_ids[2], mqtt_subscribe_msg_ids[3], alerts[0].topic, alerts[0].message, alerts[1].topic, alerts[1].message, alerts[2].topic, alerts[2].message, alerts[3].topic, alerts[3].message, mqtt_last_topic, mqtt_last_data, (unsigned long)last_msg_ago); httpd_resp_set_type(req, "text/plain"); httpd_resp_send(req, response, len); return ESP_OK; } // ============================================================================ // Home Assistant HTTP Handlers // ============================================================================ /** * @brief Handler for GET /ha/status - Return HA config + all sensor states as JSON */ static esp_err_t ha_status_handler(httpd_req_t *req) { char *response = malloc(2048); if (!response) { httpd_resp_send_500(req); return ESP_FAIL; } int len = snprintf(response, 2048, "{\"url\":\"%s\",\"token_set\":%s,\"interval\":%lu,\"sensors\":[", ha_url, ha_token[0] ? "true" : "false", (unsigned long)ha_poll_interval_ms); for (int i = 0; i < MAX_HA_SENSORS; i++) { if (i > 0) len += snprintf(response + len, 2048 - len, ","); len += snprintf(response + len, 2048 - len, "{\"entity_id\":\"%s\",\"attribute\":\"%s\",\"name\":\"%s\"," "\"type\":%d,\"min\":%d,\"max\":%d,\"enabled\":%d,\"value\":\"%s\"}", ha_sensors[i].entity_id, ha_sensors[i].attribute, ha_sensors[i].name, ha_sensors[i].display_type, ha_sensors[i].min_value, ha_sensors[i].max_value, ha_sensors[i].enabled, ha_sensors[i].cached_value); } snprintf(response + len, 2048 - len, "]}"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, response, strlen(response)); free(response); return ESP_OK; } /** * @brief Simple URL decode (handles %XX sequences) */ static void url_decode(char *dst, const char *src, size_t dst_size) { size_t i = 0, j = 0; while (src[i] && j < dst_size - 1) { if (src[i] == '%' && src[i+1] && src[i+2]) { char hex[3] = {src[i+1], src[i+2], 0}; dst[j++] = (char)strtol(hex, NULL, 16); i += 3; } else if (src[i] == '+') { dst[j++] = ' '; i++; } else { dst[j++] = src[i++]; } } dst[j] = '\0'; } /** * @brief Handler for GET /ha/config - Save HA connection settings * Query params: url, token, interval */ static esp_err_t ha_config_handler(httpd_req_t *req) { // Use heap allocation to avoid stack overflow char *buf = malloc(512); char *param = malloc(300); if (!buf || !param) { free(buf); free(param); httpd_resp_send_500(req); return ESP_FAIL; } ESP_LOGI(TAG, "HA config handler called"); if (httpd_req_get_url_query_str(req, buf, 512) == ESP_OK) { if (httpd_query_key_value(buf, "url", param, 300) == ESP_OK) { url_decode(ha_url, param, sizeof(ha_url)); ESP_LOGI(TAG, "HA URL set: %s", ha_url); } if (httpd_query_key_value(buf, "token", param, 300) == ESP_OK) { url_decode(ha_token, param, sizeof(ha_token)); ESP_LOGI(TAG, "HA Token set (len=%d)", (int)strlen(ha_token)); } if (httpd_query_key_value(buf, "interval", param, 300) == ESP_OK) { int interval = atoi(param); if (interval >= 5000 && interval <= 3600000) { ha_poll_interval_ms = interval; } } save_ha_config(); ESP_LOGI(TAG, "HA config saved: url=%s interval=%lu token_len=%d", ha_url, (unsigned long)ha_poll_interval_ms, (int)strlen(ha_token)); } free(buf); free(param); httpd_resp_send(req, "OK", 2); return ESP_OK; } /** * @brief Handler for GET /ha/entity - Proxy to HA API, return entity state + attributes * Query params: entity_id */ static esp_err_t ha_entity_handler(httpd_req_t *req) { char buf[128]; char entity_id[64] = ""; ESP_LOGI(TAG, "HA entity fetch request received"); if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { httpd_query_key_value(buf, "entity_id", entity_id, sizeof(entity_id)); } ESP_LOGI(TAG, "HA entity_id: %s", entity_id); if (!entity_id[0]) { ESP_LOGW(TAG, "Missing entity_id in request"); httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing entity_id"); return ESP_FAIL; } if (!ha_url[0] || !ha_token[0]) { ESP_LOGW(TAG, "HA not configured: url=%s token_len=%d", ha_url, (int)strlen(ha_token)); httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "HA not configured"); return ESP_FAIL; } // Build URL: {ha_url}/api/states/{entity_id} char url[256]; snprintf(url, sizeof(url), "%s/api/states/%s", ha_url, entity_id); ESP_LOGI(TAG, "Fetching HA entity from: %s", url); http_response_len = 0; http_response_buffer[0] = '\0'; esp_http_client_config_t config = { .url = url, .event_handler = http_event_handler, .timeout_ms = 10000, }; esp_http_client_handle_t client = esp_http_client_init(&config); if (!client) { ESP_LOGE(TAG, "Failed to init HTTP client"); httpd_resp_send_500(req); return ESP_FAIL; } char auth_header[280]; snprintf(auth_header, sizeof(auth_header), "Bearer %s", ha_token); esp_http_client_set_header(client, "Authorization", auth_header); esp_http_client_set_header(client, "Content-Type", "application/json"); ESP_LOGI(TAG, "Performing HA API request..."); esp_err_t err = esp_http_client_perform(client); int status = esp_http_client_get_status_code(client); esp_http_client_cleanup(client); ESP_LOGI(TAG, "HA API response: err=%d status=%d len=%d", err, status, http_response_len); if (err != ESP_OK || status != 200) { char errmsg[64]; snprintf(errmsg, sizeof(errmsg), "HA API error: %d", status); ESP_LOGW(TAG, "%s", errmsg); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, errmsg); return ESP_FAIL; } // Return raw JSON from HA httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, http_response_buffer, http_response_len); return ESP_OK; } /** * @brief Handler for GET /ha/sensor/add - Add/update sensor config * Query params: idx, entity_id, attr, name, type, min, max, enabled */ static esp_err_t ha_sensor_add_handler(httpd_req_t *req) { char buf[384]; char param[64]; int idx = -1; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) != ESP_OK) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing params"); return ESP_FAIL; } if (httpd_query_key_value(buf, "idx", param, sizeof(param)) == ESP_OK) { idx = atoi(param); } // If idx not specified, find first empty slot if (idx < 0) { for (int i = 0; i < MAX_HA_SENSORS; i++) { if (!ha_sensors[i].entity_id[0]) { idx = i; break; } } } if (idx < 0 || idx >= MAX_HA_SENSORS) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "No available slot"); return ESP_FAIL; } // Parse sensor config (URL decode text fields) if (httpd_query_key_value(buf, "entity_id", param, sizeof(param)) == ESP_OK) { url_decode(ha_sensors[idx].entity_id, param, sizeof(ha_sensors[idx].entity_id)); } if (httpd_query_key_value(buf, "attr", param, sizeof(param)) == ESP_OK) { url_decode(ha_sensors[idx].attribute, param, sizeof(ha_sensors[idx].attribute)); } if (httpd_query_key_value(buf, "name", param, sizeof(param)) == ESP_OK) { url_decode(ha_sensors[idx].name, param, sizeof(ha_sensors[idx].name)); } if (httpd_query_key_value(buf, "type", param, sizeof(param)) == ESP_OK) { ha_sensors[idx].display_type = atoi(param); } if (httpd_query_key_value(buf, "min", param, sizeof(param)) == ESP_OK) { ha_sensors[idx].min_value = atoi(param); } if (httpd_query_key_value(buf, "max", param, sizeof(param)) == ESP_OK) { ha_sensors[idx].max_value = atoi(param); } if (httpd_query_key_value(buf, "enabled", param, sizeof(param)) == ESP_OK) { ha_sensors[idx].enabled = atoi(param) ? 1 : 0; } else { ha_sensors[idx].enabled = 1; // Default enabled } // Recount sensors ha_sensor_count = 0; for (int i = 0; i < MAX_HA_SENSORS; i++) { if (ha_sensors[i].entity_id[0]) ha_sensor_count++; } save_ha_sensor(idx); ESP_LOGI(TAG, "HA sensor %d added: %s", idx, ha_sensors[idx].entity_id); char response[32]; snprintf(response, sizeof(response), "{\"idx\":%d}", idx); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, response, strlen(response)); return ESP_OK; } /** * @brief Handler for GET /ha/sensor/remove - Remove sensor config * Query params: idx */ static esp_err_t ha_sensor_remove_handler(httpd_req_t *req) { char buf[32]; char param[8]; int idx = -1; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { if (httpd_query_key_value(buf, "idx", param, sizeof(param)) == ESP_OK) { idx = atoi(param); } } if (idx < 0 || idx >= MAX_HA_SENSORS) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid idx"); return ESP_FAIL; } // Clear sensor memset(&ha_sensors[idx], 0, sizeof(ha_sensor_config_t)); // Recount sensors ha_sensor_count = 0; for (int i = 0; i < MAX_HA_SENSORS; i++) { if (ha_sensors[i].entity_id[0]) ha_sensor_count++; } save_ha_sensor(idx); ESP_LOGI(TAG, "HA sensor %d removed", idx); httpd_resp_send(req, "OK", 2); return ESP_OK; } /** * @brief Handler for GET /ha/sensor/rename - Rename a sensor * Query params: idx, name */ static esp_err_t ha_sensor_rename_handler(httpd_req_t *req) { char buf[128]; char param[64]; int idx = -1; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) != ESP_OK) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing params"); return ESP_FAIL; } if (httpd_query_key_value(buf, "idx", param, sizeof(param)) == ESP_OK) { idx = atoi(param); } if (idx < 0 || idx >= MAX_HA_SENSORS || !ha_sensors[idx].entity_id[0]) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid sensor idx"); return ESP_FAIL; } if (httpd_query_key_value(buf, "name", param, sizeof(param)) == ESP_OK) { url_decode(ha_sensors[idx].name, param, sizeof(ha_sensors[idx].name)); save_ha_sensor(idx); ESP_LOGI(TAG, "HA sensor %d renamed to: %s", idx, ha_sensors[idx].name); } httpd_resp_send(req, "OK", 2); return ESP_OK; } // ============================================================================ // Rotation HTTP Handlers // ============================================================================ /** * @brief Handler for GET /rotation/status - Return all rotation slots as JSON */ static esp_err_t rotation_status_handler(httpd_req_t *req) { char *response = malloc(1024); if (!response) { httpd_resp_send_500(req); return ESP_FAIL; } int len = snprintf(response, 1024, "{\"count\":%d,\"current\":%d,\"slots\":[", rotation_count, current_rotation_idx); for (int i = 0; i < MAX_ROTATION_SLOTS; i++) { if (i > 0) len += snprintf(response + len, 1024 - len, ","); len += snprintf(response + len, 1024 - len, "{\"type\":%d,\"sensor_idx\":%d,\"enabled\":%d,\"duration\":%d}", rotation_slots[i].screen_type, rotation_slots[i].sensor_idx, rotation_slots[i].enabled, rotation_slots[i].duration_sec); } snprintf(response + len, 1024 - len, "]}"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, response, strlen(response)); free(response); return ESP_OK; } /** * @brief Handler for GET /rotation/set - Update a rotation slot * Query params: idx, type, sensor, enabled, duration */ static esp_err_t rotation_set_handler(httpd_req_t *req) { char buf[128]; char param[16]; int idx = -1; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) != ESP_OK) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing params"); return ESP_FAIL; } if (httpd_query_key_value(buf, "idx", param, sizeof(param)) == ESP_OK) { idx = atoi(param); } if (idx < 0 || idx >= MAX_ROTATION_SLOTS) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid idx"); return ESP_FAIL; } if (httpd_query_key_value(buf, "type", param, sizeof(param)) == ESP_OK) { rotation_slots[idx].screen_type = atoi(param); } if (httpd_query_key_value(buf, "sensor", param, sizeof(param)) == ESP_OK) { rotation_slots[idx].sensor_idx = atoi(param); } if (httpd_query_key_value(buf, "enabled", param, sizeof(param)) == ESP_OK) { rotation_slots[idx].enabled = atoi(param) ? 1 : 0; } if (httpd_query_key_value(buf, "duration", param, sizeof(param)) == ESP_OK) { int dur = atoi(param); if (dur >= 5 && dur <= 300) { rotation_slots[idx].duration_sec = dur; } } save_rotation_config(); httpd_resp_send(req, "OK", 2); return ESP_OK; } /** * @brief Handler for GET /rotation/add - Add new rotation slot * Query params: type, sensor, duration */ static esp_err_t rotation_add_handler(httpd_req_t *req) { if (rotation_count >= MAX_ROTATION_SLOTS) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Max slots reached"); return ESP_FAIL; } char buf[64]; char param[16]; int idx = rotation_count; rotation_slots[idx].enabled = 1; rotation_slots[idx].duration_sec = 15; // Default if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { if (httpd_query_key_value(buf, "type", param, sizeof(param)) == ESP_OK) { rotation_slots[idx].screen_type = atoi(param); } if (httpd_query_key_value(buf, "sensor", param, sizeof(param)) == ESP_OK) { rotation_slots[idx].sensor_idx = atoi(param); } if (httpd_query_key_value(buf, "duration", param, sizeof(param)) == ESP_OK) { int dur = atoi(param); if (dur >= 5 && dur <= 300) { rotation_slots[idx].duration_sec = dur; } } } rotation_count++; save_rotation_config(); char response[32]; snprintf(response, sizeof(response), "{\"idx\":%d}", idx); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, response, strlen(response)); return ESP_OK; } /** * @brief Handler for GET /rotation/remove - Remove rotation slot * Query params: idx */ static esp_err_t rotation_remove_handler(httpd_req_t *req) { char buf[32]; char param[8]; int idx = -1; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { if (httpd_query_key_value(buf, "idx", param, sizeof(param)) == ESP_OK) { idx = atoi(param); } } if (idx < 0 || idx >= rotation_count) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid idx"); return ESP_FAIL; } // Shift remaining slots down for (int i = idx; i < rotation_count - 1; i++) { rotation_slots[i] = rotation_slots[i + 1]; } rotation_count--; memset(&rotation_slots[rotation_count], 0, sizeof(rotation_slot_t)); // Adjust current index if needed if (current_rotation_idx >= rotation_count && rotation_count > 0) { current_rotation_idx = 0; } save_rotation_config(); httpd_resp_send(req, "OK", 2); return ESP_OK; } /** * @brief Handler for GET /rotation/reorder - Reorder rotation slots * Query params: order (comma-separated indices, e.g., "0,2,1,3") */ static esp_err_t rotation_reorder_handler(httpd_req_t *req) { char buf[128]; char param[64]; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) != ESP_OK) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing order param"); return ESP_FAIL; } if (httpd_query_key_value(buf, "order", param, sizeof(param)) != ESP_OK) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing order param"); return ESP_FAIL; } // Parse order and create new arrangement rotation_slot_t temp_slots[MAX_ROTATION_SLOTS]; memcpy(temp_slots, rotation_slots, sizeof(temp_slots)); char *token = strtok(param, ","); int new_idx = 0; while (token && new_idx < rotation_count) { int old_idx = atoi(token); if (old_idx >= 0 && old_idx < rotation_count) { rotation_slots[new_idx] = temp_slots[old_idx]; new_idx++; } token = strtok(NULL, ","); } save_rotation_config(); httpd_resp_send(req, "OK", 2); return ESP_OK; } // ============================================================================ // Transition HTTP Handlers // ============================================================================ /** * @brief Handler for GET /transition/status - Return transition settings as JSON */ static esp_err_t transition_status_handler(httpd_req_t *req) { char response[64]; snprintf(response, sizeof(response), "{\"type\":%d,\"speed\":%d,\"active\":%d}", default_transition, default_trans_speed, transition_active); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, response, strlen(response)); return ESP_OK; } /** * @brief Handler for GET /transition/set - Set transition type and/or speed * Query params: type (0-6), speed (1-50) */ static esp_err_t transition_set_handler(httpd_req_t *req) { char buf[64]; char param[8]; if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) { if (httpd_query_key_value(buf, "type", param, sizeof(param)) == ESP_OK) { int t = atoi(param); if (t >= 0 && t <= 6) { default_transition = t; } } if (httpd_query_key_value(buf, "speed", param, sizeof(param)) == ESP_OK) { int s = atoi(param); if (s >= 1 && s <= 50) { default_trans_speed = s; } } } save_transition_config(); httpd_resp_send(req, "OK", 2); return ESP_OK; } /** * @brief Handler for GET /transition/test - Trigger a test transition * Forces a transition by jumping to a different rotation screen */ static esp_err_t transition_test_handler(httpd_req_t *req) { // Only test if we have a transition type set if (default_transition == TRANS_NONE) { httpd_resp_send(req, "No transition set", -1); return ESP_OK; } // Save current frame for transition memcpy(prev_frame, frontframe, sizeof(prev_frame)); transition_active = 1; transition_type = default_transition; transition_speed = default_trans_speed; transition_progress = 0; // Advance to next rotation slot to show the transition int next = advance_rotation(); showstate = next; framessostate = 0; showtemp = 0; httpd_resp_send(req, "OK", 2); return ESP_OK; } // ============================================================================ // Settings Export/Import Handlers // ============================================================================ // Base64 encoding table static const char b64_table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; /** * @brief Encode binary data to base64 */ static size_t base64_encode(const uint8_t *src, size_t src_len, char *dst, size_t dst_len) { size_t i, j; size_t needed = ((src_len + 2) / 3) * 4 + 1; if (dst_len < needed) return 0; for (i = 0, j = 0; i < src_len; i += 3) { uint32_t v = src[i] << 16; if (i + 1 < src_len) v |= src[i + 1] << 8; if (i + 2 < src_len) v |= src[i + 2]; dst[j++] = b64_table[(v >> 18) & 0x3F]; dst[j++] = b64_table[(v >> 12) & 0x3F]; dst[j++] = (i + 1 < src_len) ? b64_table[(v >> 6) & 0x3F] : '='; dst[j++] = (i + 2 < src_len) ? b64_table[v & 0x3F] : '='; } dst[j] = '\0'; return j; } /** * @brief Decode base64 to binary data */ static size_t base64_decode(const char *src, uint8_t *dst, size_t dst_len) { size_t src_len = strlen(src); size_t i, j; int8_t dtable[256]; memset(dtable, -1, sizeof(dtable)); for (i = 0; i < 64; i++) dtable[(uint8_t)b64_table[i]] = i; for (i = 0, j = 0; i < src_len && j < dst_len; i += 4) { int8_t a = dtable[(uint8_t)src[i]]; int8_t b = (i + 1 < src_len) ? dtable[(uint8_t)src[i + 1]] : -1; int8_t c = (i + 2 < src_len) ? dtable[(uint8_t)src[i + 2]] : -1; int8_t d = (i + 3 < src_len) ? dtable[(uint8_t)src[i + 3]] : -1; if (a < 0 || b < 0) break; dst[j++] = (a << 2) | (b >> 4); if (c >= 0 && j < dst_len) dst[j++] = (b << 4) | (c >> 2); if (d >= 0 && j < dst_len) dst[j++] = (c << 6) | d; } return j; } /** * @brief Handler for GET /settings/export - Export all settings as JSON */ static esp_err_t settings_export_handler(httpd_req_t *req) { // Allocate buffer for JSON response (settings + base64 image) // Image is 12760 bytes, base64 is ~17KB, plus ~2KB for other settings size_t buf_size = 22000; char *response = malloc(buf_size); if (!response) { httpd_resp_send_500(req); return ESP_FAIL; } int len = 0; // Start JSON len += snprintf(response + len, buf_size - len, "{"); // Margins len += snprintf(response + len, buf_size - len, "\"margins\":{\"left\":%d,\"top\":%d,\"right\":%d,\"bottom\":%d},", margin_left, margin_top, margin_right, margin_bottom); // Transition len += snprintf(response + len, buf_size - len, "\"transition\":{\"type\":%d,\"speed\":%d},", default_transition, default_trans_speed); // MQTT len += snprintf(response + len, buf_size - len, "\"mqtt\":{\"broker\":\"%s\",\"port\":%d,\"user\":\"%s\",\"pass\":\"%s\",\"alerts\":[", mqtt_broker, mqtt_port, mqtt_username, mqtt_password); for (int i = 0; i < MAX_ALERTS; i++) { if (i > 0) len += snprintf(response + len, buf_size - len, ","); len += snprintf(response + len, buf_size - len, "{\"topic\":\"%s\",\"message\":\"%s\"}", alerts[i].topic, alerts[i].message); } len += snprintf(response + len, buf_size - len, "]},"); // Home Assistant len += snprintf(response + len, buf_size - len, "\"ha\":{\"url\":\"%s\",\"token\":\"%s\",\"interval\":%lu,\"sensors\":[", ha_url, ha_token, (unsigned long)ha_poll_interval_ms); for (int i = 0; i < MAX_HA_SENSORS; i++) { if (i > 0) len += snprintf(response + len, buf_size - len, ","); len += snprintf(response + len, buf_size - len, "{\"entity_id\":\"%s\",\"attribute\":\"%s\",\"name\":\"%s\"," "\"display_type\":%d,\"min_value\":%d,\"max_value\":%d,\"enabled\":%d}", ha_sensors[i].entity_id, ha_sensors[i].attribute, ha_sensors[i].name, ha_sensors[i].display_type, ha_sensors[i].min_value, ha_sensors[i].max_value, ha_sensors[i].enabled); } len += snprintf(response + len, buf_size - len, "]},"); // Rotation len += snprintf(response + len, buf_size - len, "\"rotation\":{\"count\":%d,\"slots\":[", rotation_count); for (int i = 0; i < MAX_ROTATION_SLOTS; i++) { if (i > 0) len += snprintf(response + len, buf_size - len, ","); len += snprintf(response + len, buf_size - len, "{\"type\":%d,\"sensor_idx\":%d,\"enabled\":%d,\"duration\":%d}", rotation_slots[i].screen_type, rotation_slots[i].sensor_idx, rotation_slots[i].enabled, rotation_slots[i].duration_sec); } len += snprintf(response + len, buf_size - len, "]},"); // Image (base64 encoded) len += snprintf(response + len, buf_size - len, "\"image\":{\"has_image\":%s", has_uploaded_image ? "true" : "false"); if (has_uploaded_image) { len += snprintf(response + len, buf_size - len, ",\"data\":\""); len += base64_encode(uploaded_image, IMG_BUFFER_SIZE, response + len, buf_size - len); len += snprintf(response + len, buf_size - len, "\""); } len += snprintf(response + len, buf_size - len, "}"); // End JSON len += snprintf(response + len, buf_size - len, "}"); httpd_resp_set_type(req, "application/json"); httpd_resp_set_hdr(req, "Content-Disposition", "attachment; filename=\"channel3_settings.json\""); httpd_resp_send(req, response, len); free(response); return ESP_OK; } /** * @brief Handler for POST /settings/import - Import settings from JSON */ static esp_err_t settings_import_handler(httpd_req_t *req) { // Allocate buffer for receiving JSON size_t buf_size = 22000; char *buf = malloc(buf_size); if (!buf) { httpd_resp_send_500(req); return ESP_FAIL; } // Receive the JSON data int received = 0; int remaining = req->content_len; while (remaining > 0) { int ret = httpd_req_recv(req, buf + received, remaining); if (ret <= 0) { free(buf); httpd_resp_send_500(req); return ESP_FAIL; } received += ret; remaining -= ret; } buf[received] = '\0'; // Parse JSON using cJSON cJSON *root = cJSON_Parse(buf); if (!root) { free(buf); httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON"); return ESP_FAIL; } // Import margins cJSON *margins = cJSON_GetObjectItem(root, "margins"); if (margins) { cJSON *item; if ((item = cJSON_GetObjectItem(margins, "left"))) margin_left = item->valueint; if ((item = cJSON_GetObjectItem(margins, "top"))) margin_top = item->valueint; if ((item = cJSON_GetObjectItem(margins, "right"))) margin_right = item->valueint; if ((item = cJSON_GetObjectItem(margins, "bottom"))) margin_bottom = item->valueint; save_margins(); } // Import transition cJSON *trans = cJSON_GetObjectItem(root, "transition"); if (trans) { cJSON *item; if ((item = cJSON_GetObjectItem(trans, "type"))) default_transition = item->valueint; if ((item = cJSON_GetObjectItem(trans, "speed"))) default_trans_speed = item->valueint; save_transition_config(); } // Import MQTT cJSON *mqtt = cJSON_GetObjectItem(root, "mqtt"); if (mqtt) { cJSON *item; if ((item = cJSON_GetObjectItem(mqtt, "broker"))) strncpy(mqtt_broker, item->valuestring, sizeof(mqtt_broker) - 1); if ((item = cJSON_GetObjectItem(mqtt, "port"))) mqtt_port = item->valueint; if ((item = cJSON_GetObjectItem(mqtt, "user"))) strncpy(mqtt_username, item->valuestring, sizeof(mqtt_username) - 1); if ((item = cJSON_GetObjectItem(mqtt, "pass"))) strncpy(mqtt_password, item->valuestring, sizeof(mqtt_password) - 1); cJSON *alerts_arr = cJSON_GetObjectItem(mqtt, "alerts"); if (alerts_arr && cJSON_IsArray(alerts_arr)) { int i = 0; cJSON *alert; cJSON_ArrayForEach(alert, alerts_arr) { if (i >= MAX_ALERTS) break; cJSON *topic = cJSON_GetObjectItem(alert, "topic"); cJSON *msg = cJSON_GetObjectItem(alert, "message"); if (topic) strncpy(alerts[i].topic, topic->valuestring, sizeof(alerts[i].topic) - 1); if (msg) strncpy(alerts[i].message, msg->valuestring, sizeof(alerts[i].message) - 1); i++; } } // Note: save_mqtt_config is defined but we need to trigger MQTT restart } // Import Home Assistant cJSON *ha = cJSON_GetObjectItem(root, "ha"); if (ha) { cJSON *item; if ((item = cJSON_GetObjectItem(ha, "url"))) strncpy(ha_url, item->valuestring, sizeof(ha_url) - 1); if ((item = cJSON_GetObjectItem(ha, "token"))) strncpy(ha_token, item->valuestring, sizeof(ha_token) - 1); if ((item = cJSON_GetObjectItem(ha, "interval"))) ha_poll_interval_ms = item->valueint; cJSON *sensors_arr = cJSON_GetObjectItem(ha, "sensors"); if (sensors_arr && cJSON_IsArray(sensors_arr)) { int i = 0; ha_sensor_count = 0; cJSON *sensor; cJSON_ArrayForEach(sensor, sensors_arr) { if (i >= MAX_HA_SENSORS) break; cJSON *eid = cJSON_GetObjectItem(sensor, "entity_id"); if (eid && eid->valuestring[0]) { strncpy(ha_sensors[i].entity_id, eid->valuestring, sizeof(ha_sensors[i].entity_id) - 1); cJSON *attr = cJSON_GetObjectItem(sensor, "attribute"); cJSON *name = cJSON_GetObjectItem(sensor, "name"); cJSON *dtype = cJSON_GetObjectItem(sensor, "display_type"); cJSON *minv = cJSON_GetObjectItem(sensor, "min_value"); cJSON *maxv = cJSON_GetObjectItem(sensor, "max_value"); cJSON *en = cJSON_GetObjectItem(sensor, "enabled"); if (attr) strncpy(ha_sensors[i].attribute, attr->valuestring, sizeof(ha_sensors[i].attribute) - 1); if (name) strncpy(ha_sensors[i].name, name->valuestring, sizeof(ha_sensors[i].name) - 1); if (dtype) ha_sensors[i].display_type = dtype->valueint; if (minv) ha_sensors[i].min_value = minv->valueint; if (maxv) ha_sensors[i].max_value = maxv->valueint; if (en) ha_sensors[i].enabled = en->valueint; ha_sensor_count++; save_ha_sensor(i); } i++; } } save_ha_config(); } // Import rotation cJSON *rotation = cJSON_GetObjectItem(root, "rotation"); if (rotation) { cJSON *count = cJSON_GetObjectItem(rotation, "count"); if (count) rotation_count = count->valueint; cJSON *slots_arr = cJSON_GetObjectItem(rotation, "slots"); if (slots_arr && cJSON_IsArray(slots_arr)) { int i = 0; cJSON *slot; cJSON_ArrayForEach(slot, slots_arr) { if (i >= MAX_ROTATION_SLOTS) break; cJSON *type = cJSON_GetObjectItem(slot, "type"); cJSON *sidx = cJSON_GetObjectItem(slot, "sensor_idx"); cJSON *en = cJSON_GetObjectItem(slot, "enabled"); cJSON *dur = cJSON_GetObjectItem(slot, "duration"); if (type) rotation_slots[i].screen_type = type->valueint; if (sidx) rotation_slots[i].sensor_idx = sidx->valueint; if (en) rotation_slots[i].enabled = en->valueint; if (dur) rotation_slots[i].duration_sec = dur->valueint; i++; } } save_rotation_config(); } // Import image cJSON *image = cJSON_GetObjectItem(root, "image"); if (image) { cJSON *has_img = cJSON_GetObjectItem(image, "has_image"); cJSON *data = cJSON_GetObjectItem(image, "data"); if (has_img && cJSON_IsTrue(has_img) && data && data->valuestring) { size_t decoded = base64_decode(data->valuestring, uploaded_image, IMG_BUFFER_SIZE); if (decoded == IMG_BUFFER_SIZE) { has_uploaded_image = true; save_uploaded_image(); ESP_LOGI(TAG, "Imported image: %d bytes", decoded); } else { ESP_LOGW(TAG, "Image decode size mismatch: %d vs %d", decoded, IMG_BUFFER_SIZE); } } } cJSON_Delete(root); free(buf); httpd_resp_send(req, "Settings imported successfully", -1); return ESP_OK; } /** * @brief Start the HTTP server */ static void start_webserver(void) { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = 16384; config.max_uri_handlers = 40; if (httpd_start(&http_server, &config) == ESP_OK) { // Register URI handlers httpd_uri_t root_uri = { .uri = "/", .method = HTTP_GET, .handler = root_handler }; httpd_register_uri_handler(http_server, &root_uri); httpd_uri_t status_uri = { .uri = "/status", .method = HTTP_GET, .handler = status_handler }; httpd_register_uri_handler(http_server, &status_uri); httpd_uri_t screen_uri = { .uri = "/screen", .method = HTTP_GET, .handler = screen_handler }; httpd_register_uri_handler(http_server, &screen_uri); httpd_uri_t control_uri = { .uri = "/control", .method = HTTP_GET, .handler = control_handler }; httpd_register_uri_handler(http_server, &control_uri); httpd_uri_t jam_uri = { .uri = "/jam", .method = HTTP_GET, .handler = jam_handler }; httpd_register_uri_handler(http_server, &jam_uri); httpd_uri_t video_toggle_uri = { .uri = "/video", .method = HTTP_GET, .handler = video_toggle_handler }; httpd_register_uri_handler(http_server, &video_toggle_uri); httpd_uri_t margins_uri = { .uri = "/margins", .method = HTTP_GET, .handler = margins_handler }; httpd_register_uri_handler(http_server, &margins_uri); httpd_uri_t margins_preview_uri = { .uri = "/margins/preview", .method = HTTP_GET, .handler = margins_preview_handler }; httpd_register_uri_handler(http_server, &margins_preview_uri); httpd_uri_t upload_uri = { .uri = "/upload", .method = HTTP_POST, .handler = upload_handler }; httpd_register_uri_handler(http_server, &upload_uri); httpd_uri_t mqtt_status_uri = { .uri = "/mqtt/status", .method = HTTP_GET, .handler = mqtt_status_handler }; httpd_register_uri_handler(http_server, &mqtt_status_uri); httpd_uri_t mqtt_config_uri = { .uri = "/mqtt/config", .method = HTTP_GET, .handler = mqtt_config_handler }; httpd_register_uri_handler(http_server, &mqtt_config_uri); httpd_uri_t mqtt_test_uri = { .uri = "/mqtt/test", .method = HTTP_GET, .handler = mqtt_test_handler }; httpd_register_uri_handler(http_server, &mqtt_test_uri); httpd_uri_t mqtt_debug_uri = { .uri = "/mqtt/debug", .method = HTTP_GET, .handler = mqtt_debug_handler }; httpd_register_uri_handler(http_server, &mqtt_debug_uri); // Home Assistant endpoints httpd_uri_t ha_status_uri = { .uri = "/ha/status", .method = HTTP_GET, .handler = ha_status_handler }; httpd_register_uri_handler(http_server, &ha_status_uri); httpd_uri_t ha_config_uri = { .uri = "/ha/config", .method = HTTP_GET, .handler = ha_config_handler }; httpd_register_uri_handler(http_server, &ha_config_uri); httpd_uri_t ha_entity_uri = { .uri = "/ha/entity", .method = HTTP_GET, .handler = ha_entity_handler }; httpd_register_uri_handler(http_server, &ha_entity_uri); httpd_uri_t ha_sensor_add_uri = { .uri = "/ha/sensor/add", .method = HTTP_GET, .handler = ha_sensor_add_handler }; httpd_register_uri_handler(http_server, &ha_sensor_add_uri); httpd_uri_t ha_sensor_remove_uri = { .uri = "/ha/sensor/remove", .method = HTTP_GET, .handler = ha_sensor_remove_handler }; httpd_register_uri_handler(http_server, &ha_sensor_remove_uri); httpd_uri_t ha_sensor_rename_uri = { .uri = "/ha/sensor/rename", .method = HTTP_GET, .handler = ha_sensor_rename_handler }; httpd_register_uri_handler(http_server, &ha_sensor_rename_uri); // Rotation endpoints httpd_uri_t rotation_status_uri = { .uri = "/rotation/status", .method = HTTP_GET, .handler = rotation_status_handler }; httpd_register_uri_handler(http_server, &rotation_status_uri); httpd_uri_t rotation_set_uri = { .uri = "/rotation/set", .method = HTTP_GET, .handler = rotation_set_handler }; httpd_register_uri_handler(http_server, &rotation_set_uri); httpd_uri_t rotation_add_uri = { .uri = "/rotation/add", .method = HTTP_GET, .handler = rotation_add_handler }; httpd_register_uri_handler(http_server, &rotation_add_uri); httpd_uri_t rotation_remove_uri = { .uri = "/rotation/remove", .method = HTTP_GET, .handler = rotation_remove_handler }; httpd_register_uri_handler(http_server, &rotation_remove_uri); httpd_uri_t rotation_reorder_uri = { .uri = "/rotation/reorder", .method = HTTP_GET, .handler = rotation_reorder_handler }; httpd_register_uri_handler(http_server, &rotation_reorder_uri); // Transition endpoints httpd_uri_t trans_status_uri = { .uri = "/transition/status", .method = HTTP_GET, .handler = transition_status_handler }; httpd_register_uri_handler(http_server, &trans_status_uri); httpd_uri_t trans_set_uri = { .uri = "/transition/set", .method = HTTP_GET, .handler = transition_set_handler }; httpd_register_uri_handler(http_server, &trans_set_uri); httpd_uri_t trans_test_uri = { .uri = "/transition/test", .method = HTTP_GET, .handler = transition_test_handler }; httpd_register_uri_handler(http_server, &trans_test_uri); // Settings export/import endpoints httpd_uri_t settings_export_uri = { .uri = "/settings/export", .method = HTTP_GET, .handler = settings_export_handler }; httpd_register_uri_handler(http_server, &settings_export_uri); httpd_uri_t settings_import_uri = { .uri = "/settings/import", .method = HTTP_POST, .handler = settings_import_handler }; httpd_register_uri_handler(http_server, &settings_import_uri); ESP_LOGI(TAG, "HTTP server started on port %d", config.server_port); } else { ESP_LOGE(TAG, "Failed to start HTTP server"); } } /** * @brief Render transition effect * Called at end of DrawFrame() when transition_active is true. * Blends prev_frame with current frontframe based on transition_progress. */ static void render_transition(void) { if (!transition_active) return; // Advance progress int new_progress = transition_progress + transition_speed; if (new_progress >= 255) { transition_active = 0; transition_progress = 255; return; } transition_progress = new_progress; uint8_t p = transition_progress; // 0-255 progress switch (transition_type) { case TRANS_FADE: // Blend prev_frame and frontframe for (int i = 0; i < sizeof(prev_frame); i++) { uint8_t old_lo = prev_frame[i] & 0x0F; uint8_t old_hi = (prev_frame[i] >> 4) & 0x0F; uint8_t new_lo = frontframe[i] & 0x0F; uint8_t new_hi = (frontframe[i] >> 4) & 0x0F; // Linear interpolation between old and new colors uint8_t blend_lo = (old_lo * (255 - p) + new_lo * p) / 255; uint8_t blend_hi = (old_hi * (255 - p) + new_hi * p) / 255; frontframe[i] = (blend_hi << 4) | blend_lo; } break; case TRANS_WIPE_L: // New screen slides in from right, boundary moves left { int boundary_x = (FBW2 * (255 - p)) / 255; // pixels from left to keep old for (int y = 0; y < FBH; y++) { for (int x = 0; x < boundary_x; x++) { int byte_idx = y * (FBW2 / 2) + x / 2; if (x % 2 == 0) { // High nibble frontframe[byte_idx] = (frontframe[byte_idx] & 0x0F) | (prev_frame[byte_idx] & 0xF0); } else { // Low nibble frontframe[byte_idx] = (frontframe[byte_idx] & 0xF0) | (prev_frame[byte_idx] & 0x0F); } } } } break; case TRANS_WIPE_R: // New screen slides in from left, boundary moves right { int boundary_x = (FBW2 * p) / 255; // pixels from left showing new for (int y = 0; y < FBH; y++) { for (int x = boundary_x; x < FBW2; x++) { int byte_idx = y * (FBW2 / 2) + x / 2; if (x % 2 == 0) { frontframe[byte_idx] = (frontframe[byte_idx] & 0x0F) | (prev_frame[byte_idx] & 0xF0); } else { frontframe[byte_idx] = (frontframe[byte_idx] & 0xF0) | (prev_frame[byte_idx] & 0x0F); } } } } break; case TRANS_WIPE_D: // New screen slides in from top, boundary moves down { int boundary_y = (FBH * (255 - p)) / 255; // rows from top to keep old for (int y = 0; y < boundary_y; y++) { memcpy(&frontframe[y * (FBW2 / 2)], &prev_frame[y * (FBW2 / 2)], FBW2 / 2); } } break; case TRANS_WIPE_U: // New screen slides in from bottom, boundary moves up { int boundary_y = (FBH * p) / 255; // rows from top showing new for (int y = boundary_y; y < FBH; y++) { memcpy(&frontframe[y * (FBW2 / 2)], &prev_frame[y * (FBW2 / 2)], FBW2 / 2); } } break; case TRANS_DISSOLVE: // Random pixel replacement using LCG { uint32_t seed = 12345; // Fixed seed for deterministic pattern for (int i = 0; i < (int)sizeof(prev_frame); i++) { // Two pixels per byte, handle each nibble seed = seed * 1103515245 + 12345; uint8_t rand1 = (seed >> 16) & 0xFF; seed = seed * 1103515245 + 12345; uint8_t rand2 = (seed >> 16) & 0xFF; uint8_t result = frontframe[i]; // High nibble (first pixel) if (rand1 > p) { result = (result & 0x0F) | (prev_frame[i] & 0xF0); } // Low nibble (second pixel) if (rand2 > p) { result = (result & 0xF0) | (prev_frame[i] & 0x0F); } frontframe[i] = result; } } break; default: // TRANS_NONE or unknown - instant switch, no blending transition_active = 0; break; } } /** * @brief Setup projection and modelview matrices */ static void SetupMatrix(void) { tdIdentity(ProjectionMatrix); tdIdentity(ModelviewMatrix); Perspective(600, 250, 50, 8192, ProjectionMatrix); } /** * @brief Calculate terrain height for mesh demo */ static int16_t Height(int x, int y, int l) { return tdCOS((x * x + y * y) + l); } /** * @brief Draw the current demo frame */ static void DrawFrame(void) { char *ctx = &lastct[0]; int x = 0; int y = 0; int i; int newstate = showstate; // Apply calibration margins to default pen position CNFGPenX = 14 + margin_left; CNFGPenY = 20 + margin_top; memset(frontframe, 0x00, ((FBW / 4) * FBH)); tdIdentity(ModelviewMatrix); tdIdentity(ProjectionMatrix); CNFGColor(17); switch (showstate) { case 14: // Calibration screen { // Draw border rectangle at current margins // This shows the "safe area" that will be visible on the TV int left = margin_left; int top = margin_top; int right = FBW2 - 1 - margin_right; int bottom = FBH - 1 - margin_bottom; // Draw white border rectangle CNFGColor(15); // White // Top edge CNFGTackSegment(left, top, right, top); // Bottom edge CNFGTackSegment(left, bottom, right, bottom); // Left edge CNFGTackSegment(left, top, left, bottom); // Right edge CNFGTackSegment(right, top, right, bottom); // Draw corner markers (more visible) CNFGColor(14); // Yellow // Top-left corner CNFGTackSegment(left, top, left + 10, top); CNFGTackSegment(left, top, left, top + 10); // Top-right corner CNFGTackSegment(right - 10, top, right, top); CNFGTackSegment(right, top, right, top + 10); // Bottom-left corner CNFGTackSegment(left, bottom - 10, left, bottom); CNFGTackSegment(left, bottom, left + 10, bottom); // Bottom-right corner CNFGTackSegment(right - 10, bottom, right, bottom); CNFGTackSegment(right, bottom - 10, right, bottom); // Draw crosshair in center int cx = FBW2 / 2; int cy = FBH / 2; CNFGColor(10); // Green CNFGTackSegment(cx - 15, cy, cx + 15, cy); CNFGTackSegment(cx, cy - 15, cx, cy + 15); // Display margin values char cal_text[64]; CNFGColor(15); // White CNFGPenX = 10; CNFGPenY = 80; CNFGDrawText("CALIBRATION", 3); CNFGColor(11); // Cyan CNFGPenX = 10; CNFGPenY = 115; snprintf(cal_text, sizeof(cal_text), "L:%d R:%d", margin_left, margin_right); CNFGDrawText(cal_text, 2); CNFGPenX = 10; CNFGPenY = 135; snprintf(cal_text, sizeof(cal_text), "T:%d B:%d", margin_top, margin_bottom); CNFGDrawText(cal_text, 2); CNFGColor(8); // Gray CNFGPenX = 10; CNFGPenY = 165; CNFGDrawText("Adjust via", 2); CNFGPenX = 10; CNFGPenY = 183; CNFGDrawText("web interface", 2); break; } case 15: // ALERT - Flashing warning (grayscale friendly) { // Check if alert should expire if (framessostate >= 150) { // ~5 seconds at 30fps alert_active = false; newstate = 13; // Return to weather showallowadvance = 1; // Restore normal rotation break; } // Flash effect: invert colors every 15 frames int inverted = (framessostate / 15) % 2; if (inverted) { // White background, black text CNFGColor(15); // White CNFGTackRectangle(0, 0, FBW - 1, FBH - 1); // FBW for rect (divides by 2 internally) CNFGColor(0); // Black text } else { // Black background (already cleared), white text CNFGColor(15); // White } // Word wrap and center the alert message { const int scale = 3; const int char_width = 3 * scale; // 6 pixels per char const int line_height = 6 * scale; // 12 pixels per line const int margin = 4; const int screen_width = FBW2; // Use FBW2 (116) for actual visible width const int max_width = screen_width - 2 * margin; const int max_chars = max_width / char_width; // ~18 chars per line // Build wrapped lines char lines[8][24]; // Up to 8 lines, 24 chars each int line_lengths[8] = {0}; int num_lines = 0; const char *src = current_alert_message; int line_pos = 0; while (*src && num_lines < 8) { // Skip leading spaces while (*src == ' ') src++; if (!*src) break; // Find word end const char *word_start = src; while (*src && *src != ' ') src++; int word_len = src - word_start; // Check if word fits on current line if (line_pos > 0 && line_pos + 1 + word_len > max_chars) { // Start new line lines[num_lines][line_pos] = '\0'; line_lengths[num_lines] = line_pos; num_lines++; line_pos = 0; if (num_lines >= 8) break; } // Add space if not at line start if (line_pos > 0) { lines[num_lines][line_pos++] = ' '; } // Add word (truncate if too long) for (int i = 0; i < word_len && line_pos < 23; i++) { lines[num_lines][line_pos++] = word_start[i]; } } // Finish last line if (line_pos > 0 && num_lines < 8) { lines[num_lines][line_pos] = '\0'; line_lengths[num_lines] = line_pos; num_lines++; } // Calculate vertical centering int total_height = num_lines * line_height; int start_y = (FBH - total_height) / 2; // Draw each line centered horizontally for (int i = 0; i < num_lines; i++) { int text_width = line_lengths[i] * char_width; int start_x = (screen_width - text_width) / 2; CNFGPenX = start_x; CNFGPenY = start_y + i * line_height; CNFGDrawText(lines[i], scale); } } break; } case 16: // Digital clock display { time_t now_time; struct tm timeinfo; time(&now_time); localtime_r(&now_time, &timeinfo); // Get time components int hour = timeinfo.tm_hour; int minute = timeinfo.tm_min; int second = timeinfo.tm_sec; const char *ampm = (hour >= 12) ? "PM" : "AM"; if (hour == 0) hour = 12; else if (hour > 12) hour -= 12; // Format date as "April 1st 2026" static const char *months[] = { "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" }; // Get ordinal suffix for day const char *suffix; int day = timeinfo.tm_mday; if (day >= 11 && day <= 13) { suffix = "th"; } else { switch (day % 10) { case 1: suffix = "st"; break; case 2: suffix = "nd"; break; case 3: suffix = "rd"; break; default: suffix = "th"; break; } } char date_str[32]; snprintf(date_str, sizeof(date_str), "%s %d%s %d", months[timeinfo.tm_mon], day, suffix, timeinfo.tm_year + 1900); // Draw time with custom kerning - colons are narrower const int time_scale = 4; const int char_w = 3 * time_scale; // 12 pixels for digits const int colon_w = 2 * time_scale; // 8 pixels for colons (tighter) const int space_w = 2 * time_scale; // 8 pixels for space before AM/PM const int ampm_scale = 2; // Smaller AM/PM const int ampm_char_w = 3 * ampm_scale; // 6 pixels for AM/PM chars // Calculate total width: HH:MM:SS AM (2+colon+2+colon+2+space+2 for AM/PM) int time_width = (6 * char_w) + (2 * colon_w) + space_w + (2 * ampm_char_w); int time_x = (FBW2 - time_width) / 2; int time_y = 60 + margin_top; CNFGColor(15); // White // Draw hour char buf[16]; snprintf(buf, sizeof(buf), "%2d", hour); CNFGPenX = time_x; CNFGPenY = time_y; CNFGDrawText(buf, time_scale); time_x += 2 * char_w; // Draw colon CNFGPenX = time_x; CNFGPenY = time_y; CNFGDrawText(":", time_scale); time_x += colon_w; // Draw minute snprintf(buf, sizeof(buf), "%02d", minute); CNFGPenX = time_x; CNFGPenY = time_y; CNFGDrawText(buf, time_scale); time_x += 2 * char_w; // Draw colon CNFGPenX = time_x; CNFGPenY = time_y; CNFGDrawText(":", time_scale); time_x += colon_w; // Draw second snprintf(buf, sizeof(buf), "%02d", second); CNFGPenX = time_x; CNFGPenY = time_y; CNFGDrawText(buf, time_scale); time_x += 2 * char_w + space_w; // Draw AM/PM smaller CNFGColor(15); // White for AM/PM CNFGPenX = time_x; CNFGPenY = time_y + (time_scale - ampm_scale) * 3; // Align to bottom CNFGDrawText(ampm, ampm_scale); // Draw date - smaller centered below const int date_scale = 2; int date_width = strlen(date_str) * 3 * date_scale; int date_x = (FBW2 - date_width) / 2; CNFGColor(15); // White CNFGPenX = date_x; CNFGPenY = 120 + margin_top; CNFGDrawText(date_str, date_scale); // Transition when rotation duration expires if (rotation_duration_expired()) { newstate = advance_rotation(); } break; } case 17: // Home Assistant Sensor Display { // Get the specific sensor for this rotation slot ha_sensor_config_t *sensor = NULL; int enabled_count = 0; // Check if specified sensor is valid and enabled if (ha_current_sensor >= 0 && ha_current_sensor < MAX_HA_SENSORS && ha_sensors[ha_current_sensor].entity_id[0] && ha_sensors[ha_current_sensor].enabled) { sensor = &ha_sensors[ha_current_sensor]; } // Count enabled sensors for the "no sensors" check for (int i = 0; i < MAX_HA_SENSORS; i++) { if (ha_sensors[i].entity_id[0] && ha_sensors[i].enabled) { enabled_count++; } } // If no sensors configured, advance to next rotation slot immediately if (!sensor || enabled_count == 0) { CNFGColor(8); // Gray CNFGPenX = 10 + margin_left; CNFGPenY = 90 + margin_top; CNFGDrawText("No HA Sensors", 2); CNFGPenX = 10 + margin_left; CNFGPenY = 115 + margin_top; CNFGDrawText("Configure via", 2); CNFGPenX = 10 + margin_left; CNFGPenY = 135 + margin_top; CNFGDrawText("web interface", 2); if (framessostate > 150) newstate = advance_rotation(); break; } // Get display name (use entity_id if name not set) const char *display_name = sensor->name[0] ? sensor->name : sensor->entity_id; if (sensor->display_type == HA_DISPLAY_GAUGE) { // GAUGE LAYOUT - Speedometer style (arc curves UP) // Name at top, centered CNFGColor(14); // Yellow int name_width = strlen(display_name) * 3 * 2; CNFGPenX = (FBW2 - name_width) / 2; CNFGPenY = 5 + margin_top; CNFGDrawText(display_name, 2); // Gauge parameters - taller gauge int cx = FBW2 / 2; int cy = 130 + margin_top; // Move center down for upward arc int radius = 50; // Larger radius // Draw gauge arc (semi-circle from 180 to 0 degrees - top half) CNFGColor(8); // Gray for arc for (int angle = 0; angle <= 128; angle += 4) { int x1 = cx + (radius * tdCOS(angle)) / 256; int y1 = cy - (radius * tdSIN(angle)) / 256; int x2 = cx + (radius * tdCOS(angle + 4)) / 256; int y2 = cy - (radius * tdSIN(angle + 4)) / 256; CNFGTackSegment(x1, y1, x2, y2); } // Calculate value and range float value = atof(sensor->cached_value); int range = sensor->max_value - sensor->min_value; if (range <= 0) range = 100; // Draw tick marks and intermediate values (0%, 25%, 50%, 75%, 100%) // For upward arc: min at left (angle 128), max at right (angle 0) CNFGColor(7); // Light gray char tick_label[8]; int tick_radius = radius + 8; for (int i = 0; i <= 4; i++) { int tick_angle = 128 - (i * 32); // 128, 96, 64, 32, 0 int tick_value = sensor->min_value + (range * i) / 4; // Draw tick mark (short line outward from arc) int tx1 = cx + ((radius - 4) * tdCOS(tick_angle)) / 256; int ty1 = cy - ((radius - 4) * tdSIN(tick_angle)) / 256; int tx2 = cx + ((radius + 4) * tdCOS(tick_angle)) / 256; int ty2 = cy - ((radius + 4) * tdSIN(tick_angle)) / 256; CNFGTackSegment(tx1, ty1, tx2, ty2); // Draw tick label snprintf(tick_label, sizeof(tick_label), "%d", tick_value); int label_len = strlen(tick_label); int lx = cx + (tick_radius * tdCOS(tick_angle)) / 256; int ly = cy - (tick_radius * tdSIN(tick_angle)) / 256; // Adjust position based on angle (for upward arc) if (i == 0) { // Left (min) - angle 128 CNFGPenX = lx - (label_len * 3 * 2) - 2; CNFGPenY = ly - 4; } else if (i == 4) { // Right (max) - angle 0 CNFGPenX = lx + 3; CNFGPenY = ly - 4; } else if (i == 2) { // Top (middle) - angle 64 CNFGPenX = lx - (label_len * 3); CNFGPenY = ly - 14; } else { // 25% and 75% CNFGPenX = lx - (label_len * 3) + (i < 2 ? -8 : 8); CNFGPenY = ly - 10; } CNFGDrawText(tick_label, 2); } // Calculate needle angle (min=128/left, max=0/right for upward arc) int needle_angle; if (value <= sensor->min_value) { needle_angle = 128; // Far left } else if (value >= sensor->max_value) { needle_angle = 0; // Far right } else { needle_angle = 128 - (int)(((value - sensor->min_value) * 128) / range); } // Draw thick needle (multiple parallel lines) CNFGColor(12); // Red for needle int nx = cx + ((radius - 5) * tdCOS(needle_angle)) / 256; int ny = cy - ((radius - 5) * tdSIN(needle_angle)) / 256; // Main needle CNFGTackSegment(cx, cy, nx, ny); // Parallel lines for thickness CNFGTackSegment(cx - 1, cy, nx - 1, ny); CNFGTackSegment(cx + 1, cy, nx + 1, ny); CNFGTackSegment(cx, cy - 1, nx, ny - 1); CNFGTackSegment(cx, cy + 1, nx, ny + 1); // Draw current value below gauge - large and centered CNFGColor(15); // White int val_width = strlen(sensor->cached_value) * 3 * 4; CNFGPenX = (FBW2 - val_width) / 2; CNFGPenY = 150 + margin_top; CNFGDrawText(sensor->cached_value, 4); } else { // TEXT LAYOUT (default) // Name centered at top CNFGColor(14); // Yellow int name_width = strlen(display_name) * 3 * 2; CNFGPenX = (FBW2 - name_width) / 2; CNFGPenY = 40 + margin_top; CNFGDrawText(display_name, 2); // Value large and centered CNFGColor(15); // White int val_width = strlen(sensor->cached_value) * 3 * 5; CNFGPenX = (FBW2 - val_width) / 2; CNFGPenY = 90 + margin_top; CNFGDrawText(sensor->cached_value, 5); } // Transition when rotation duration expires if (rotation_duration_expired()) { newstate = advance_rotation(); } break; } case 13: // Weather display - 3 pages: current, forecast 1-3, forecast 4-6 { char weather_text[64]; uint32_t now_ms = xTaskGetTickCount() * portTICK_PERIOD_MS; // Toggle between 3 screens every 5 seconds // Page 0: Current conditions // Page 1: Forecast hours 1-3 // Page 2: Forecast hours 4-6 if (now_ms - last_weather_screen_toggle >= WEATHER_SCREEN_TOGGLE_MS) { weather_screen_page = (weather_screen_page + 1) % 3; last_weather_screen_toggle = now_ms; } // Get current date for display time_t now_time; struct tm timeinfo; time(&now_time); localtime_r(&now_time, &timeinfo); char date_str[16]; strftime(date_str, sizeof(date_str), "%m/%d/%y", &timeinfo); if (weather_screen_page == 0) { // PAGE 0: Current conditions // City name - top left CNFGColor(14); // Yellow CNFGPenX = 2 + margin_left; CNFGPenY = 2 + margin_top; CNFGDrawText(weather_city, 2); // Date - top right CNFGColor(7); // Light gray CNFGPenX = 170 + margin_left; CNFGPenY = 2 + margin_top; CNFGDrawText(date_str, 2); // Current temperature - large CNFGColor(15); // White CNFGPenX = 2 + margin_left; CNFGPenY = 28 + margin_top; CNFGDrawText(weather_temp, 5); // Current condition CNFGColor(11); // Cyan CNFGPenX = 2 + margin_left; CNFGPenY = 80 + margin_top; CNFGDrawText(weather_condition, 3); // Humidity CNFGColor(9); // Light blue CNFGPenX = 2 + margin_left; CNFGPenY = 120 + margin_top; snprintf(weather_text, sizeof(weather_text), "Humidity: %s", weather_humidity); CNFGDrawText(weather_text, 2); // Wind CNFGColor(10); // Green CNFGPenX = 2 + margin_left; CNFGPenY = 150 + margin_top; snprintf(weather_text, sizeof(weather_text), "Wind: %s", weather_wind); CNFGDrawText(weather_text, 2); } else { // PAGE 1 or 2: Hourly Forecast (3 entries per page) // Page 1 shows entries 0-2, Page 2 shows entries 3-5 int start_idx = (weather_screen_page - 1) * 3; int forecast_y = 4 + margin_top; for (int i = 0; i < 3; i++) { int idx = start_idx + i; if (idx >= FORECAST_ENTRIES || hourly_forecast[idx].time[0] == '\0') continue; int base_y = forecast_y + (i * 70); // Line 1: Hour CNFGColor(15); // White CNFGPenX = 2 + margin_left; CNFGPenY = base_y; CNFGDrawText(hourly_forecast[idx].time, 2); // Line 2: Temperature and condition CNFGColor(11); // Cyan CNFGPenX = 2 + margin_left; CNFGPenY = base_y + 20; snprintf(weather_text, sizeof(weather_text), "%s %s", hourly_forecast[idx].temp, hourly_forecast[idx].cond); CNFGDrawText(weather_text, 2); // Line 3: Humidity and wind CNFGColor(10); // Green CNFGPenX = 2 + margin_left; CNFGPenY = base_y + 40; snprintf(weather_text, sizeof(weather_text), "H:%s Wind:%s", hourly_forecast[idx].humidity, hourly_forecast[idx].wind); CNFGDrawText(weather_text, 2); } } // Transition when rotation duration expires if (rotation_duration_expired()) { newstate = advance_rotation(); } break; } case 12: // Uploaded image display { if (has_uploaded_image) { // Copy uploaded image to framebuffer memcpy(frontframe, uploaded_image, IMG_BUFFER_SIZE); } else { CNFGDrawText("No image uploaded.\nUse web interface\nto upload an image.", 2); } // Check rotation if (rotation_duration_expired()) { newstate = advance_rotation(); } break; } case 11: // Color test pattern - 16 colored boxes { // Calculate available area after margins int avail_w = FBW2 - margin_left - margin_right; int avail_h = FBH - margin_top - margin_bottom; int box_w = avail_w / 4; int box_h = avail_h / 4; for (i = 0; i < 16; i++) { x = (i % 4) * box_w + margin_left; y = (i / 4) * box_h + margin_top; CNFGColor(i); CNFGTackRectangle(x, y, x + box_w - 1, y + box_h - 1); } break; } case 10: // Combined demo - text, colors, and 3D spheres { for (i = 0; i < 16; i++) { CNFGPenX = 14 + margin_left; CNFGPenY = (i + 1) * 12 + margin_top; CNFGColor(i); CNFGDrawText("Hello", 3); CNFGTackRectangle(120 + margin_left, (i + 1) * 12 + margin_top, 180 + margin_left, (i + 1) * 12 + 12 + margin_top); } SetupMatrix(); tdRotateEA(ProjectionMatrix, -20, 0, 0); tdRotateEA(ModelviewMatrix, framessostate, 0, 0); for (y = 3; y >= 0; y--) { for (x = 0; x < 4; x++) { CNFGColor(x + y * 4); ModelviewMatrix[11] = 1000 + tdSIN((x + y) * 40 + framessostate * 2); ModelviewMatrix[3] = 600 * x - 850; ModelviewMatrix[7] = 600 * y + 800 - 850; DrawGeoSphere(); } } if (framessostate > 500) newstate = 9; break; } case 9: // Credits text { const char *s = "ESP32 RF Broadcast\nDMA through I2S!\nTry it yourself!\n\ngithub.com/cnlohr/\nchannel3\n"; i = strlen(s); if (i > framessostate) i = framessostate; memcpy(lastct, s, i); lastct[i] = 0; CNFGDrawText(lastct, 3); if (framessostate > 500) newstate = 0; break; } case 8: // Dynamic 3D terrain mesh { CNFGColor(15); // Use white from 16-color palette CNFGDrawText("3D Meshes", 2); SetupMatrix(); tdRotateEA(ProjectionMatrix, -20, 0, 0); tdRotateEA(ModelviewMatrix, 0, 0, framessostate); for (y = -18; y < 18; y++) { for (x = -18; x < 18; x++) { int o = -framessostate * 2; int t = Height(x, y, o) * 2 + 2000; CNFGColor(((t / 100) % 15) + 1); int nx = Height(x + 1, y, o) * 2 + 2000; int ny = Height(x, y + 1, o) * 2 + 2000; int16_t p0[3] = { x * 140, y * 140, t }; int16_t p1[3] = { (x + 1) * 140, y * 140, nx }; int16_t p2[3] = { x * 140, (y + 1) * 140, ny }; Draw3DSegment(p0, p1); Draw3DSegment(p0, p2); } } if (framessostate > 400) newstate = 10; break; } case 7: // Multiple rotating geodesic spheres { CNFGDrawText("Matrix-based 3D engine.", 3); SetupMatrix(); tdRotateEA(ProjectionMatrix, -20, 0, 0); tdRotateEA(ModelviewMatrix, framessostate, 0, 0); int sphereset = (framessostate / 120); if (sphereset > 2) sphereset = 2; if (framessostate > 400) { newstate = 8; } for (y = -sphereset; y <= sphereset; y++) { for (x = -sphereset; x <= sphereset; x++) { if (y == 2) continue; ModelviewMatrix[11] = 1000 + tdSIN((x + y) * 40 + framessostate * 2); ModelviewMatrix[3] = 500 * x; ModelviewMatrix[7] = 500 * y + 800; DrawGeoSphere(); } } break; } case 6: // Random colored lines { CNFGDrawText("Lines on double-buffered 232x220.", 2); if (framessostate > 60) { int avail_w = FBW2 - margin_left - margin_right; int avail_h = FBH - margin_top - margin_bottom - 30; for (i = 0; i < 350; i++) { CNFGColor(rand() % 16); CNFGTackSegment(rand() % avail_w + margin_left, rand() % avail_h + 30 + margin_top, rand() % avail_w + margin_left, rand() % avail_h + 30 + margin_top); } } if (framessostate > 240) { newstate = 7; } break; } case 5: // Memory visualization (simplified for ESP32) CNFGColor(17); CNFGTackRectangle(70 + margin_left, 110 + margin_top, 180 + margin_left, 150 + margin_top); CNFGColor(16); if (framessostate > 160) newstate = 6; // Fall through to case 4 __attribute__((fallthrough)); case 4: // Text mode demo CNFGPenY += 14 * 7; CNFGPenX += 60; CNFGDrawText("38x14 TEXT MODE", 2); CNFGPenY += 14; CNFGPenX -= 5; CNFGDrawText("...on 232x220 gfx", 2); if (framessostate > 60 && showstate == 4) { newstate = 5; } break; case 3: // Scrolling character demo for (y = 0; y < 14; y++) { for (x = 0; x < 38; x++) { i = x + y + 1; if (i < framessostate && i > framessostate - 60) lastct[x] = (i != 10 && i != 9) ? i : ' '; else lastct[x] = ' '; } if (y == 7) { memcpy(lastct + 10, "36x12 TEXT MODE", 15); } lastct[x] = 0; CNFGDrawText(lastct, 2); CNFGPenY += 14; if (framessostate > 120) newstate = 4; } break; case 2: // ESP32 features list ctx += sprintf(ctx, "ESP32 Features:\n 240 MHz Xtensa LX6\n 520kB SRAM\n WiFi 802.11 b/g/n\n Bluetooth 4.2\n" " GPIO, SPI, I2C, UART\n PWM, ADC, DAC\n I2S with DMA\n\n Analog Broadcast TV\n"); { int il = ctx - lastct; if (framessostate / 2 < il) lastct[framessostate / 2] = 0; else showtemp++; } CNFGDrawText(lastct, 2); if (showtemp == 60) newstate = 3; break; case 1: // Transition out i = strlen(lastct); lastct[i - framessostate] = 0; if (i - framessostate == 1) newstate = 2; // Fall through to case 0 __attribute__((fallthrough)); case 0: // Main status display { CNFGDrawText(lastct, 2); ctx += sprintf(ctx, "Channel 3 Broadcasting.\nframe: %d\n", gframe); #ifdef CONFIG_WIFI_MODE_STATION // Get WiFi status in station mode wifi_ap_record_t ap_info; esp_err_t err = esp_wifi_sta_get_ap_info(&ap_info); int rssi = (err == ESP_OK) ? ap_info.rssi : 0; ctx += sprintf(ctx, "rssi: %d\n", rssi); if (wifi_connected) { ctx += sprintf(ctx, "IP: %s\n", wifi_ip_str); } else { ctx += sprintf(ctx, "Connecting...\n"); } #else ctx += sprintf(ctx, "AP IP: %s\n", wifi_ip_str); #endif ctx += sprintf(ctx, "ESP32 Online\n"); showtemp++; if (showtemp == 30) newstate = 1; break; } } // Handle state transitions if (showstate != newstate && showallowadvance) { // Start transition for rotation-based screen changes // (Weather=13, Clock=16, HA Sensor=17, Image=12) if (default_transition != TRANS_NONE && !transition_active && (showstate == 13 || showstate == 16 || showstate == 17 || showstate == 12) && (newstate == 13 || newstate == 16 || newstate == 17 || newstate == 12)) { memcpy(prev_frame, frontframe, sizeof(prev_frame)); transition_active = 1; transition_type = default_transition; transition_speed = default_trans_speed; transition_progress = 0; } showstate = newstate; framessostate = 0; showtemp = 0; } else { framessostate++; } // Apply transition effect if active (blends prev_frame with current frontframe) render_transition(); } /** * @brief Video rendering task * * Runs on core 1 to handle frame rendering without interfering with WiFi */ static void video_task(void *arg) { uint8_t lastframe = 0; ESP_LOGI(TAG, "Video task started on core %d", xPortGetCoreID()); while (1) { uint8_t tbuffer = !(gframe & 1); if (lastframe != tbuffer) { frontframe = (uint8_t*)&framebuffer[((FBW2 / 4) * FBH) * tbuffer]; DrawFrame(); lastframe = tbuffer; } // Small delay to prevent watchdog issues vTaskDelay(1); } } #ifdef CONFIG_WIFI_MODE_STATION static int retry_count = 0; /** * @brief WiFi event handler for station mode */ static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) { if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { // Start a scan first, then connect after scan completes wifi_scan_config_t scan_config = { .show_hidden = true }; esp_wifi_scan_start(&scan_config, false); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) { ESP_LOGI(TAG, "Scan complete, connecting..."); esp_wifi_connect(); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_CONNECTED) { ESP_LOGI(TAG, "WiFi connected, waiting for IP..."); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { wifi_connected = false; retry_count++; if (retry_count < 10) { ESP_LOGI(TAG, "Disconnected, retry %d...", retry_count); vTaskDelay(pdMS_TO_TICKS(2000)); // Wait before retry esp_wifi_connect(); } else { ESP_LOGE(TAG, "Failed to connect after %d retries", retry_count); } } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; snprintf(wifi_ip_str, sizeof(wifi_ip_str), IPSTR, IP2STR(&event->ip_info.ip)); wifi_connected = true; retry_count = 0; ESP_LOGI(TAG, "Connected! IP: %s", wifi_ip_str); // Initialize SNTP for time sync if (esp_sntp_enabled()) { esp_sntp_stop(); } esp_sntp_setoperatingmode(SNTP_OPMODE_POLL); esp_sntp_setservername(0, "pool.ntp.org"); esp_sntp_init(); ESP_LOGI(TAG, "SNTP initialized, syncing time..."); // Set timezone to Central Time (Austin, TX) setenv("TZ", "CST6CDT,M3.2.0,M11.1.0", 1); tzset(); // Load MQTT config and start client load_mqtt_config(); start_mqtt_client(); // Load Home Assistant config load_ha_config(); } } /** * @brief Initialize WiFi in Station mode */ static void wifi_init_station(void) { ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); esp_netif_create_default_wifi_sta(); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL)); ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL)); wifi_config_t wifi_config = { .sta = { .ssid = CONFIG_WIFI_STA_SSID, .password = CONFIG_WIFI_STA_PASS, .threshold.authmode = WIFI_AUTH_OPEN, // Accept any auth mode .sae_pwe_h2e = WPA3_SAE_PWE_BOTH, }, }; ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); ESP_ERROR_CHECK(esp_wifi_start()); ESP_LOGI(TAG, "WiFi Station mode initialized. Connecting to: %s", CONFIG_WIFI_STA_SSID); } #else /** * @brief Initialize WiFi in SoftAP mode */ static void wifi_init_softap(void) { ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); esp_netif_create_default_wifi_ap(); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); wifi_config_t wifi_config = { .ap = { .ssid = CONFIG_WIFI_SOFTAP_SSID, .ssid_len = strlen(CONFIG_WIFI_SOFTAP_SSID), .channel = 1, .password = CONFIG_WIFI_SOFTAP_PASS, .max_connection = 4, .authmode = WIFI_AUTH_WPA_WPA2_PSK }, }; if (strlen(CONFIG_WIFI_SOFTAP_PASS) == 0) { wifi_config.ap.authmode = WIFI_AUTH_OPEN; } ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config)); ESP_ERROR_CHECK(esp_wifi_start()); wifi_connected = true; snprintf(wifi_ip_str, sizeof(wifi_ip_str), "192.168.4.1"); ESP_LOGI(TAG, "WiFi SoftAP initialized. SSID: %s", CONFIG_WIFI_SOFTAP_SSID); } #endif /** * @brief Application entry point */ void app_main(void) { ESP_LOGI(TAG, "================================="); ESP_LOGI(TAG, " Channel3 ESP32 - RF Broadcast "); ESP_LOGI(TAG, "================================="); // Initialize NVS esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); ret = nvs_flash_init(); } ESP_ERROR_CHECK(ret); // Load calibration margins from NVS load_margins(); // Load uploaded image from NVS if available load_uploaded_image(); // Initialize WiFi #ifdef CONFIG_WIFI_MODE_STATION wifi_init_station(); // Wait for WiFi connection before starting video // This avoids the cache conflict during WiFi handshake ESP_LOGI(TAG, "Waiting for WiFi connection before starting video..."); int wait_count = 0; while (!wifi_connected && wait_count < 30) { // Wait up to 30 seconds vTaskDelay(pdMS_TO_TICKS(1000)); wait_count++; ESP_LOGI(TAG, "Waiting for WiFi... (%d/30)", wait_count); } if (wifi_connected) { ESP_LOGI(TAG, "WiFi connected! IP: %s", wifi_ip_str); start_webserver(); // Start video streaming server xTaskCreate(stream_server_task, "stream_server", 8192, NULL, 4, NULL); // Initial weather fetch ESP_LOGI(TAG, "Fetching initial weather data..."); fetch_weather(); } else { ESP_LOGW(TAG, "WiFi connection timeout, starting video anyway"); } #else wifi_init_softap(); start_webserver(); // Start video streaming server xTaskCreate(stream_server_task, "stream_server", 8192, NULL, 4, NULL); #endif // Setup 3D matrices SetupMatrix(); // Initialize video broadcast video_broadcast_init(); video_running = true; // Create video rendering task on core 1 // Core 0 is used for WiFi, so we use core 1 for video xTaskCreatePinnedToCore(video_task, "video_task", 8192, NULL, 5, NULL, 1); ESP_LOGI(TAG, "Channel3 ESP32 initialized"); #ifdef CONFIG_WIFI_MODE_STATION ESP_LOGI(TAG, "Connected to WiFi network '%s'", CONFIG_WIFI_STA_SSID); #else ESP_LOGI(TAG, "Connect to WiFi AP '%s' to access web interface", CONFIG_WIFI_SOFTAP_SSID); #endif ESP_LOGI(TAG, "Tune analog TV to Channel 3 to view broadcast"); ESP_LOGI(TAG, "Video stream server on port %d", STREAM_PORT); // Main loop - monitor status and refresh weather while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); ESP_LOGI(TAG, "Frame: %d, Frame time: %lu us", gframe, (unsigned long)last_internal_frametime); // Check if MQTT needs restart (set by web config handler) if (mqtt_needs_restart) { mqtt_needs_restart = false; ESP_LOGI(TAG, "Restarting MQTT client with new config..."); if (mqtt_client) { esp_mqtt_client_stop(mqtt_client); esp_mqtt_client_destroy(mqtt_client); mqtt_client = NULL; } start_mqtt_client(); } // Periodic weather refresh uint32_t now = xTaskGetTickCount() * portTICK_PERIOD_MS; if (wifi_connected && (now - last_weather_fetch) > WEATHER_FETCH_INTERVAL_MS) { ESP_LOGI(TAG, "Refreshing weather data..."); fetch_weather(); } // Periodic Home Assistant sensor refresh if (wifi_connected && ha_url[0] && ha_token[0] && (now - last_ha_fetch) > ha_poll_interval_ms) { ESP_LOGI(TAG, "Refreshing HA sensor data..."); fetch_all_ha_sensors(); } } }