diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f2ee5e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Channel3 is an ESP8266 firmware that broadcasts analog NTSC/PAL television signals. It modulates RF through GPIO3/RX at 80 MHz using the I2S bus with DMA, allowing an analog TV tuned to Channel 3 to display graphics, text, and 3D content. + +## Build Commands + +```bash +make # Build firmware (outputs image.elf-0x00000.bin) +make showvars # Debug: display all build variables +``` + +Git submodules auto-initialize on first build if missing. + +## Configuration + +Edit `user.cfg` for: +- `PORT` - Serial port for flashing (default: `/dev/ttyUSB0`) +- `OPTS += -DPAL` - Uncomment to enable PAL mode (default is NTSC) +- `FWBURNFLAGS` - Flash baud rate + +## Architecture + +### Video Signal Generation + +The core innovation is using I2S DMA at 80 MHz to generate TV signals: + +1. **Premodulation tables** (`tablemaker/broadcast_tables.c`) contain 1408-bit patterns per color, chosen as an exact harmonic of both NTSC chroma (3.579545 MHz) and Channel 3 luma (61.25 MHz) + +2. **Line state machine** (`tablemaker/CbTable.c`) defines behavior for each scanline (263 lines NTSC, 313 lines PAL) - sync pulses, blanking, colorburst, active video + +3. **DMA engine** (`user/video_broadcast.c`) fills buffers via interrupt on each line completion, using `CbTable` to select the appropriate line handler + +### Framebuffer + +- Double-buffered: 232x220 pixels (NTSC) or 232x264 (PAL) +- 4 bits per pixel (16 colors) +- Front/back buffer swapping on frame completion + +### Key Source Files + +- `user/video_broadcast.c` - DMA setup, interrupt handlers, modulation +- `user/3d.c` - Fixed-point 3D engine (256 = 1.0, 8-bit fractional) +- `user/user_main.c` - Demo screens, main loop, initialization +- `tablemaker/CbTable.c` - NTSC/PAL line type definitions +- `tablemaker/broadcast_tables.c` - Premodulated waveform lookup table +- `common/` - HTTP server, mDNS, WiFi, flash filesystem + +### Web Interface + +Connect to `http://192.168.4.1` when device is in SoftAP mode. The NTSC control panel allows: +- Screen selection and demo freeze +- Color jamming for RF testing +- Interactive JavaScript shader for custom color waveforms +- DFT visualization + +## PAL vs NTSC + +Controlled by `-DPAL` compile flag. PAL mode broadcasts PAL-compliant B/W timing with NTSC color encoding (NTSC50-like). The main differences are in `CbTable.c` line counts and timing. + +## ESP32 Port (esp32_channel3) + +The ESP32 port is located in `esp32_channel3/` directory. + +### Building and Flashing + +**Claude should run these commands directly** - do not ask the user to run them manually. + +Build command (from bash): +```bash +/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -ExecutionPolicy Bypass -Command "Set-Location 'C:\git\channel3\esp32_channel3'; .\build.ps1" +``` + +Flash command (COM5): +```bash +/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -ExecutionPolicy Bypass -Command "Set-Location 'C:\git\channel3\esp32_channel3'; .\flash.ps1" +``` + +### Technical Notes + +The ESP-IDF tools have MSYSTEM checks that block builds from MSYS2. These were patched in `C:\Espressif\frameworks\esp-idf-v5.5.2\tools\`: +- `idf.py` line ~914: Added `main()` call after MSYSTEM warning +- `idf_tools.py` line ~3600: Changed `fatal()` to `warn()` and removed `SystemExit` diff --git a/main/user_main.c b/main/user_main.c index 6f90932..55f280a 100644 --- a/main/user_main.c +++ b/main/user_main.c @@ -69,6 +69,21 @@ static httpd_handle_t http_server = NULL; static uint8_t uploaded_image[IMG_BUFFER_SIZE]; static bool has_uploaded_image = false; +// OBJ model storage +#define MAX_OBJ_VERTICES 500 +#define MAX_OBJ_EDGES 1000 + +static int16_t obj_vertices[MAX_OBJ_VERTICES * 3]; // x,y,z per vertex +static uint16_t obj_edges[MAX_OBJ_EDGES * 2]; // v1,v2 per edge +static uint16_t obj_vertex_count = 0; +static uint16_t obj_edge_count = 0; +static bool has_obj_model = false; +static int16_t obj_zoom = 500; // Z distance (100-1500) +static uint8_t obj_rot_x = 0; // X rotation (0-255) +static uint8_t obj_rot_y = 0; // Y rotation (0-255) +static uint8_t obj_rot_z = 0; // Z rotation (0-255) +static uint8_t obj_thickness = 1; // Line thickness (1-5) + // Video streaming server #define STREAM_PORT 5000 static bool streaming_active = false; @@ -111,6 +126,10 @@ static int8_t margin_bottom = 0; #define NVS_KEY_MARGIN_T "margin_t" #define NVS_KEY_MARGIN_R "margin_r" #define NVS_KEY_MARGIN_B "margin_b" +#define NVS_KEY_OBJ_VERTS "obj_verts" +#define NVS_KEY_OBJ_EDGES "obj_edges" +#define NVS_KEY_OBJ_VCNT "obj_vcnt" +#define NVS_KEY_OBJ_ECNT "obj_ecnt" // MQTT Configuration (stored in NVS) #define ALERT_DURATION_MS 5000 @@ -202,6 +221,7 @@ static uint32_t last_ha_fetch = 0; #define SCREEN_TYPE_CLOCK 1 #define SCREEN_TYPE_HA_SENSOR 2 #define SCREEN_TYPE_IMAGE 3 +#define SCREEN_TYPE_OBJ_MODEL 4 typedef struct { uint8_t screen_type; // 0=Weather, 1=Clock, 2=HA Sensor, 3=Image @@ -342,6 +362,280 @@ static void load_uploaded_image(void) nvs_close(nvs); } +/** + * @brief Save OBJ model to NVS + */ +static void save_obj_model(void) +{ + nvs_handle_t nvs; + video_broadcast_pause(); + + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to open NVS for OBJ save: %s", esp_err_to_name(err)); + video_broadcast_resume(); + return; + } + + // Save vertex and edge counts + nvs_set_u16(nvs, NVS_KEY_OBJ_VCNT, obj_vertex_count); + nvs_set_u16(nvs, NVS_KEY_OBJ_ECNT, obj_edge_count); + + // Save vertex data + size_t verts_size = obj_vertex_count * 3 * sizeof(int16_t); + err = nvs_set_blob(nvs, NVS_KEY_OBJ_VERTS, obj_vertices, verts_size); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to save OBJ vertices: %s", esp_err_to_name(err)); + } + + // Save edge data + size_t edges_size = obj_edge_count * 2 * sizeof(uint16_t); + err = nvs_set_blob(nvs, NVS_KEY_OBJ_EDGES, obj_edges, edges_size); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to save OBJ edges: %s", esp_err_to_name(err)); + } + + nvs_commit(nvs); + nvs_close(nvs); + video_broadcast_resume(); + ESP_LOGI(TAG, "Saved OBJ model: %d vertices, %d edges", obj_vertex_count, obj_edge_count); +} + +/** + * @brief Load OBJ model from NVS + */ +static void load_obj_model(void) +{ + nvs_handle_t nvs; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &nvs); + if (err != ESP_OK) { + return; + } + + uint16_t vcnt = 0, ecnt = 0; + nvs_get_u16(nvs, NVS_KEY_OBJ_VCNT, &vcnt); + nvs_get_u16(nvs, NVS_KEY_OBJ_ECNT, &ecnt); + + if (vcnt > 0 && vcnt <= MAX_OBJ_VERTICES && ecnt > 0 && ecnt <= MAX_OBJ_EDGES) { + size_t verts_size = vcnt * 3 * sizeof(int16_t); + size_t edges_size = ecnt * 2 * sizeof(uint16_t); + + err = nvs_get_blob(nvs, NVS_KEY_OBJ_VERTS, obj_vertices, &verts_size); + if (err == ESP_OK) { + err = nvs_get_blob(nvs, NVS_KEY_OBJ_EDGES, obj_edges, &edges_size); + if (err == ESP_OK) { + obj_vertex_count = vcnt; + obj_edge_count = ecnt; + has_obj_model = true; + ESP_LOGI(TAG, "Loaded OBJ model: %d vertices, %d edges", vcnt, ecnt); + } + } + } + + nvs_close(nvs); +} + +/** + * @brief Clear OBJ model from memory and NVS + */ +static void clear_obj_model(void) +{ + obj_vertex_count = 0; + obj_edge_count = 0; + has_obj_model = false; + + nvs_handle_t nvs; + video_broadcast_pause(); + if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &nvs) == ESP_OK) { + nvs_erase_key(nvs, NVS_KEY_OBJ_VERTS); + nvs_erase_key(nvs, NVS_KEY_OBJ_EDGES); + nvs_erase_key(nvs, NVS_KEY_OBJ_VCNT); + nvs_erase_key(nvs, NVS_KEY_OBJ_ECNT); + nvs_commit(nvs); + nvs_close(nvs); + } + video_broadcast_resume(); + ESP_LOGI(TAG, "Cleared OBJ model"); +} + +/** + * @brief Check if edge already exists (avoid duplicates) + */ +static bool edge_exists(uint16_t v1, uint16_t v2, uint16_t count) +{ + for (uint16_t i = 0; i < count; i++) { + uint16_t e1 = obj_edges[i * 2]; + uint16_t e2 = obj_edges[i * 2 + 1]; + if ((e1 == v1 && e2 == v2) || (e1 == v2 && e2 == v1)) { + return true; + } + } + return false; +} + +/** + * @brief Parse OBJ file data and populate vertex/edge arrays + */ +static bool parse_obj_data(const char *data, size_t len) +{ + // Reset counts + obj_vertex_count = 0; + obj_edge_count = 0; + has_obj_model = false; + + // First pass: count vertices and find bounds + float min_x = 1e9f, max_x = -1e9f; + float min_y = 1e9f, max_y = -1e9f; + float min_z = 1e9f, max_z = -1e9f; + + // Temporary storage for float vertices (we'll convert after finding bounds) + float *temp_verts = malloc(MAX_OBJ_VERTICES * 3 * sizeof(float)); + if (!temp_verts) { + ESP_LOGE(TAG, "Failed to allocate temp vertex buffer"); + return false; + } + + uint16_t vert_count = 0; + const char *p = data; + const char *end = data + len; + + while (p < end) { + // Skip whitespace + while (p < end && (*p == ' ' || *p == '\t')) p++; + + if (p >= end) break; + + // Parse vertex line: "v x y z" + if (*p == 'v' && p + 1 < end && p[1] == ' ') { + if (vert_count >= MAX_OBJ_VERTICES) { + ESP_LOGW(TAG, "OBJ vertex limit reached (%d)", MAX_OBJ_VERTICES); + break; + } + + p += 2; // Skip "v " + float x = 0, y = 0, z = 0; + + // Parse x + while (p < end && (*p == ' ' || *p == '\t')) p++; + x = strtof(p, (char**)&p); + + // Parse y + while (p < end && (*p == ' ' || *p == '\t')) p++; + y = strtof(p, (char**)&p); + + // Parse z + while (p < end && (*p == ' ' || *p == '\t')) p++; + z = strtof(p, (char**)&p); + + temp_verts[vert_count * 3 + 0] = x; + temp_verts[vert_count * 3 + 1] = y; + temp_verts[vert_count * 3 + 2] = z; + + if (x < min_x) min_x = x; + if (x > max_x) max_x = x; + if (y < min_y) min_y = y; + if (y > max_y) max_y = y; + if (z < min_z) min_z = z; + if (z > max_z) max_z = z; + + vert_count++; + } + + // Skip to end of line + while (p < end && *p != '\n' && *p != '\r') p++; + while (p < end && (*p == '\n' || *p == '\r')) p++; + } + + if (vert_count == 0) { + ESP_LOGE(TAG, "No vertices found in OBJ"); + free(temp_verts); + return false; + } + + // Calculate scale to fit in [-200, 200] range + float width = max_x - min_x; + float height = max_y - min_y; + float depth = max_z - min_z; + float max_dim = width > height ? width : height; + if (depth > max_dim) max_dim = depth; + + float scale = (max_dim > 0) ? (400.0f / max_dim) : 1.0f; + + // Center offsets + float cx = (min_x + max_x) / 2.0f; + float cy = (min_y + max_y) / 2.0f; + float cz = (min_z + max_z) / 2.0f; + + // Convert to fixed-point centered vertices + for (uint16_t i = 0; i < vert_count; i++) { + obj_vertices[i * 3 + 0] = (int16_t)((temp_verts[i * 3 + 0] - cx) * scale); + obj_vertices[i * 3 + 1] = (int16_t)((temp_verts[i * 3 + 1] - cy) * scale); + obj_vertices[i * 3 + 2] = (int16_t)((temp_verts[i * 3 + 2] - cz) * scale); + } + + free(temp_verts); + obj_vertex_count = vert_count; + + // Second pass: parse faces and extract edges + p = data; + uint16_t edge_count = 0; + + while (p < end) { + // Skip whitespace + while (p < end && (*p == ' ' || *p == '\t')) p++; + + if (p >= end) break; + + // Parse face line: "f v1 v2 v3 ..." or "f v1/vt1/vn1 v2/vt2/vn2 ..." + if (*p == 'f' && p + 1 < end && (p[1] == ' ' || p[1] == '\t')) { + p += 2; // Skip "f " + + uint16_t face_verts[16]; + int face_vert_count = 0; + + while (p < end && *p != '\n' && *p != '\r' && face_vert_count < 16) { + // Skip whitespace + while (p < end && (*p == ' ' || *p == '\t')) p++; + if (p >= end || *p == '\n' || *p == '\r') break; + + // Parse vertex index (1-based in OBJ) + int v = strtol(p, (char**)&p, 10); + if (v > 0 && v <= vert_count) { + face_verts[face_vert_count++] = (uint16_t)(v - 1); // Convert to 0-based + } + + // Skip texture/normal indices (e.g., "/vt/vn") + while (p < end && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++; + } + + // Create edges from face (connect consecutive vertices + last to first) + for (int i = 0; i < face_vert_count && edge_count < MAX_OBJ_EDGES; i++) { + uint16_t v1 = face_verts[i]; + uint16_t v2 = face_verts[(i + 1) % face_vert_count]; + + // Check for duplicate edges + if (!edge_exists(v1, v2, edge_count)) { + obj_edges[edge_count * 2 + 0] = v1; + obj_edges[edge_count * 2 + 1] = v2; + edge_count++; + } + } + } + + // Skip to end of line + while (p < end && *p != '\n' && *p != '\r') p++; + while (p < end && (*p == '\n' || *p == '\r')) p++; + } + + obj_edge_count = edge_count; + has_obj_model = (vert_count > 0 && edge_count > 0); + + ESP_LOGI(TAG, "Parsed OBJ: %d vertices, %d edges, scale=%.2f", + vert_count, edge_count, scale); + + return has_obj_model; +} + /** * @brief Load MQTT configuration from NVS */ @@ -618,6 +912,7 @@ static int rotation_type_to_state(uint8_t screen_type) case SCREEN_TYPE_CLOCK: return 16; case SCREEN_TYPE_HA_SENSOR: return 17; case SCREEN_TYPE_IMAGE: return 12; + case SCREEN_TYPE_OBJ_MODEL: return 18; default: return 13; } } @@ -1288,6 +1583,21 @@ static const char *html_page = "" "
" "" +"