diff options
| -rw-r--r-- | TESTING.md | 67 | ||||
| -rw-r--r-- | main/CMakeLists.txt | 3 | ||||
| -rw-r--r-- | main/app_main.c | 11 | ||||
| -rw-r--r-- | main/display.c | 2 | ||||
| -rw-r--r-- | main/display.h | 6 | ||||
| -rw-r--r-- | main/state_client.c | 105 | ||||
| -rw-r--r-- | main/state_client.h | 18 | ||||
| -rw-r--r-- | main/state_machine.c | 14 | ||||
| -rw-r--r-- | main/state_machine.h | 6 | ||||
| -rw-r--r-- | main/touch.c | 123 | ||||
| -rw-r--r-- | main/touch.h | 9 |
11 files changed, 346 insertions, 18 deletions
@@ -385,32 +385,71 @@ curl -i -X POST -d 'not json' \ ## M7 — Touch + Outbound `/state` POST -**Acceptance**: tap toggles wake/sleep locally; device POSTs `{viewport,state}` to `<scrypted>/state`. +**Acceptance**: tap toggles wake/sleep locally; idle timer firing posts sleep; device POSTs `{viewport,state}` to `<scrypted>/state` for every local transition. -**How to verify** - -Stand up a test HTTP receiver: +**Test receiver** (run on the host you'll point `scrypted` at): ```bash -# In a separate terminal on Scrypted host: -python3 -m http.server 11080 # or a small flask app that logs body +# Single-file flask receiver that echoes every POST: +pip install flask +cat > /tmp/recv.py <<'EOF' +from flask import Flask, request +app = Flask(__name__) [email protected]("/state") +def state(): + print("RX", request.get_json()) + return "", 204 +app.run(host="0.0.0.0", port=11080) +EOF +python3 /tmp/recv.py ``` -Configure the device to point at it: +Configure the device: ```bash curl -X POST -H "Content-Type: application/json" \ - -d '{"viewport":"mudroom","scrypted":"http://<host>:11080"}' \ + -d '{"viewport":"mudroom","scrypted":"http://<host>:11080","idle_timeout_ms":10000}' \ http://<device-ip>/config ``` -Then: -- Tap while asleep → device wakes, receiver logs `POST /state {"viewport":"mudroom","state":"wake"}`. -- Tap while awake → device sleeps, receiver logs `POST /state {"viewport":"mudroom","state":"sleep"}`. -- Wait `idle_timeout_ms` with no `/frame` → receiver logs same sleep POST. -- Kill receiver, tap → `/state` `state_post_failures` increments. +**Tap dispatch** -**Status**: ⬜ pending. +- Tap while asleep → device wakes; receiver prints `RX {'viewport': 'mudroom', 'state': 'wake'}`. +- Tap while awake → device sleeps; receiver prints same with `state: 'sleep'`. +- Idle timer expires (10s here) → receiver prints `state: 'sleep'`. + +Each successful POST also logs on serial: + +``` +I (xxx) state_client: POST http://<host>:11080/state {state:wake} -> 204 +``` + +**Failure path** + +- Stop the receiver, then tap. Serial: + + ``` + W (xxx) state_client: POST http://<host>:11080/state failed: err=ESP_OK status=-1 + ``` + + Then `GET /state` shows `state_post_failures` incremented. + +- Local state change still happens (backlight toggles). The POST is best-effort, not a precondition. + +**Depth-1 queue with replace-on-full** + +Tap rapidly (faster than the receiver can ack) and confirm the receiver only sees the most recent state transition between in-flight POSTs. Intermediate flips are coalesced. With a 1s receiver delay, only the final state of each ~1s window should hit the receiver. + +**No POST when Scrypted-initiated** + +- `curl -X POST -d '{"state":"sleep"}' /state` — receiver should print **nothing** (Scrypted already knows it initiated). +- Same for `/state {wake}` and `/frame` (asleep → 409, no callback either). + +**No POST when unconfigured** + +- After factory reset, tapping the screen does nothing (no Scrypted URL to call). No queue entries dropped to disk. + +**Status**: 🟡 builds clean against ESP-IDF 5.4. Hardware verification needs the FT5426 touch jumpered + reachable on I2C 0x38. --- diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index e3268e2..0986488 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -2,5 +2,6 @@ idf_component_register( SRC_DIRS "." INCLUDE_DIRS "." REQUIRES driver esp_driver_i2c esp_driver_jpeg esp_eth esp_event - esp_http_server esp_lcd esp_netif esp_timer json mdns nvs_flash + esp_http_client esp_http_server esp_lcd esp_netif esp_timer + json mdns nvs_flash ) diff --git a/main/app_main.c b/main/app_main.c index 126bf15..e7bff0a 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -4,7 +4,9 @@ #include "mdns_service.h" #include "net_eth.h" #include "nvs_config.h" +#include "state_client.h" #include "state_machine.h" +#include "touch.h" #include "viewport_state.h" #include "esp_event.h" @@ -32,6 +34,7 @@ void app_main(void) } ESP_ERROR_CHECK(state_machine_init()); + ESP_ERROR_CHECK(state_client_init()); ESP_ERROR_CHECK(mdns_service_start()); ESP_ERROR_CHECK(http_api_start()); @@ -62,6 +65,12 @@ void app_main(void) ESP_LOGW(TAG, "jpeg decoder init failed — /frame will be unavailable"); } - // TODO M7: Capacitive touch -> outbound /state POST to <scrypted>/state + // Touch shares the panel I2C bus; only meaningful if display came up. + if (display_is_up()) { + if (touch_init() != ESP_OK) { + ESP_LOGW(TAG, "touch init failed — taps won't work"); + } + } + // TODO M8: Local screens (IP, loading) + BOOT button } diff --git a/main/display.c b/main/display.c index 21e8e5e..c8976e4 100644 --- a/main/display.c +++ b/main/display.c @@ -211,6 +211,8 @@ esp_err_t display_init(void) bool display_is_up(void) { return s_up; } +i2c_master_bus_handle_t display_i2c_bus(void) { return s_i2c_bus; } + esp_err_t display_set_brightness(uint8_t pct) { if (pct > 100) pct = 100; diff --git a/main/display.h b/main/display.h index e26845f..3119a60 100644 --- a/main/display.h +++ b/main/display.h @@ -4,6 +4,7 @@ #include <stdint.h> #include "esp_err.h" +#include "driver/i2c_master.h" // Initialize the panel-side MCU over I2C, then bring up the DSI link in // video (DPI) mode. Safe to call when the panel is not attached — returns @@ -12,6 +13,11 @@ esp_err_t display_init(void); bool display_is_up(void); +// The Hosyond's I2C bus carries both the panel-MCU (0x45) and the touch +// IC (0x38). Display owns the bus; touch shares it via this getter. +// Returns NULL before display_init() succeeds. +i2c_master_bus_handle_t display_i2c_bus(void); + // 0–100, gamma-corrected to 0–255 on the panel-MCU PWM register. // brightness=0 is fully off (backlight enable line de-asserted). esp_err_t display_set_brightness(uint8_t brightness_0_100); diff --git a/main/state_client.c b/main/state_client.c new file mode 100644 index 0000000..2d55bf9 --- /dev/null +++ b/main/state_client.c @@ -0,0 +1,105 @@ +#include "state_client.h" + +#include <stdio.h> +#include <string.h> + +#include "cJSON.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" + +#include "viewport_state.h" + +static const char *TAG = "state_client"; + +#define WORKER_STACK 4096 +#define WORKER_PRIO 5 +#define HTTP_TIMEOUT_MS 1000 +#define USER_AGENT "ScryptedViewport/" VIEWPORT_VERSION + +// Depth-1 queue holding the next desired state. xQueueOverwrite() gives us +// the replace-on-full semantics from the spec. +static QueueHandle_t s_queue; + +static void perform_post(viewport_run_state_t state) +{ + char url[320]; + char body[160]; + char viewport_name[64]; + + viewport_state_lock(); + viewport_state_t *st = viewport_state_get(); + if (st->scrypted_url[0] == '\0') { + viewport_state_unlock(); + return; // not configured — silently drop + } + snprintf(url, sizeof(url), "%s/state", st->scrypted_url); + strncpy(viewport_name, st->viewport_name, sizeof(viewport_name) - 1); + viewport_name[sizeof(viewport_name) - 1] = '\0'; + viewport_state_unlock(); + + const char *state_str = (state == VIEWPORT_STATE_AWAKE) ? "wake" : "sleep"; + snprintf(body, sizeof(body), + "{\"viewport\":\"%s\",\"state\":\"%s\"}", + viewport_name, state_str); + + esp_http_client_config_t cfg = { + .url = url, + .method = HTTP_METHOD_POST, + .timeout_ms = HTTP_TIMEOUT_MS, + .disable_auto_redirect = true, + }; + esp_http_client_handle_t client = esp_http_client_init(&cfg); + if (!client) { + viewport_state_lock(); + viewport_state_get()->state_post_failures++; + viewport_state_unlock(); + return; + } + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_set_header(client, "User-Agent", USER_AGENT); + esp_http_client_set_header(client, "Connection", "close"); + esp_http_client_set_post_field(client, body, strlen(body)); + + esp_err_t err = esp_http_client_perform(client); + int status = (err == ESP_OK) ? esp_http_client_get_status_code(client) : -1; + + if (err != ESP_OK || status < 200 || status >= 300) { + ESP_LOGW(TAG, "POST %s failed: err=%s status=%d", url, esp_err_to_name(err), status); + viewport_state_lock(); + viewport_state_get()->state_post_failures++; + viewport_state_unlock(); + } else { + ESP_LOGI(TAG, "POST %s {state:%s} -> %d", url, state_str, status); + } + esp_http_client_cleanup(client); +} + +static void worker(void *arg) +{ + for (;;) { + viewport_run_state_t pending; + if (xQueueReceive(s_queue, &pending, portMAX_DELAY) == pdTRUE) { + perform_post(pending); + } + } +} + +esp_err_t state_client_init(void) +{ + s_queue = xQueueCreate(1, sizeof(viewport_run_state_t)); + if (!s_queue) return ESP_ERR_NO_MEM; + BaseType_t ok = xTaskCreate(worker, "state_client", WORKER_STACK, + NULL, WORKER_PRIO, NULL); + return (ok == pdPASS) ? ESP_OK : ESP_FAIL; +} + +void state_client_post(viewport_run_state_t state) +{ + if (state != VIEWPORT_STATE_AWAKE && state != VIEWPORT_STATE_ASLEEP) return; + // xQueueOverwrite is depth-1 replace-on-full. The in-flight POST in the + // worker is unaffected — it finishes naturally, then drains the new entry. + xQueueOverwrite(s_queue, &state); +} diff --git a/main/state_client.h b/main/state_client.h new file mode 100644 index 0000000..bf7e1c6 --- /dev/null +++ b/main/state_client.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esp_err.h" +#include "viewport_state.h" + +esp_err_t state_client_init(void); + +// Queue a state-change POST to <scrypted>/state. +// +// Concurrency model (per spec): +// - At most one HTTP POST in flight; a dedicated worker task drives it. +// - Depth-1 queue. If a POST is already queued, replace it with the newer +// one — the in-flight POST is never cancelled. +// - Fire-and-forget from the caller's perspective. Local state change +// proceeds immediately regardless of POST outcome. +// +// Silently dropped if the device has no Scrypted URL configured. +void state_client_post(viewport_run_state_t state); diff --git a/main/state_machine.c b/main/state_machine.c index 22f3f56..2808738 100644 --- a/main/state_machine.c +++ b/main/state_machine.c @@ -5,6 +5,7 @@ #include "esp_timer.h" #include "display.h" +#include "state_client.h" static const char *TAG = "state"; @@ -28,8 +29,7 @@ static void disarm_idle_timer(void) static void idle_timer_fired(void *arg) { ESP_LOGI(TAG, "idle timer expired — sleeping"); - state_machine_set(VIEWPORT_STATE_ASLEEP); - // TODO M7: state_client_post(VIEWPORT_STATE_ASLEEP); // tell Scrypted + state_machine_set_local(VIEWPORT_STATE_ASLEEP); } esp_err_t state_machine_init(void) @@ -80,6 +80,16 @@ void state_machine_frame_painted(void) if (awake) arm_idle_timer_unlocked(); } +void state_machine_set_local(viewport_run_state_t target) +{ + // For idempotent no-op (already in target), state_machine_set returns OK + // without changing state; we still call state_client to keep Scrypted in + // sync if it's drifted. State POSTs are idempotent on both ends. + esp_err_t err = state_machine_set(target); + if (err != ESP_OK) return; // unconfigured / invalid + state_client_post(target); +} + viewport_run_state_t state_machine_current(void) { viewport_state_lock(); diff --git a/main/state_machine.h b/main/state_machine.h index 176b93e..8caf670 100644 --- a/main/state_machine.h +++ b/main/state_machine.h @@ -12,6 +12,12 @@ esp_err_t state_machine_init(void); // ASLEEP -> backlight off, idle timer cancelled, framebuffer discarded esp_err_t state_machine_set(viewport_run_state_t target); +// Device-initiated variant (tap, idle timer expiry). Drives the same +// transition as state_machine_set() and additionally enqueues a +// {viewport,state} POST to <scrypted>/state via state_client. +// Silently no-ops if the device is unconfigured. +void state_machine_set_local(viewport_run_state_t target); + // Called by /frame after a successful paint. Restarts the idle timer // while awake; no-op otherwise. void state_machine_frame_painted(void); diff --git a/main/touch.c b/main/touch.c new file mode 100644 index 0000000..f13183d --- /dev/null +++ b/main/touch.c @@ -0,0 +1,123 @@ +#include "touch.h" + +#include <stdint.h> + +#include "driver/i2c_master.h" +#include "esp_check.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "display.h" +#include "state_machine.h" +#include "viewport_state.h" + +static const char *TAG = "touch"; + +#define FT5426_ADDR 0x38 +#define FT5426_I2C_FREQ_HZ 400000 + +// FT5x06/FT5426 register map (subset). +#define FT_REG_DEV_MODE 0x00 +#define FT_REG_GEST_ID 0x01 +#define FT_REG_TD_STATUS 0x02 // low 4 bits = touch count +#define FT_REG_P1_XH 0x03 // X high (bits 0..3) + event flag (bits 6..7) +#define FT_REG_P1_XL 0x04 +#define FT_REG_P1_YH 0x05 +#define FT_REG_P1_YL 0x06 + +#define POLL_PERIOD_MS 30 +#define TAP_MAX_MS 500 +#define TAP_MAX_MOVE_PX 25 +#define TAP_DEBOUNCE_MS 150 + +static i2c_master_dev_handle_t s_dev; +static TaskHandle_t s_task; + +static esp_err_t ft_read(uint8_t reg, uint8_t *buf, size_t n) +{ + return i2c_master_transmit_receive(s_dev, ®, 1, buf, n, 50); +} + +static void touch_task(void *arg) +{ + bool was_down = false; + uint64_t down_us = 0; + uint16_t down_x = 0; + uint16_t down_y = 0; + uint64_t last_tap_us = 0; + uint8_t buf[7]; + + for (;;) { + vTaskDelay(pdMS_TO_TICKS(POLL_PERIOD_MS)); + + if (ft_read(FT_REG_DEV_MODE, buf, sizeof(buf)) != ESP_OK) continue; + + uint8_t touches = buf[FT_REG_TD_STATUS] & 0x0F; + if (touches > 0) { + // Always capture P1; we only care about taps, not multi-touch. + uint16_t x = ((buf[FT_REG_P1_XH] & 0x0F) << 8) | buf[FT_REG_P1_XL]; + uint16_t y = ((buf[FT_REG_P1_YH] & 0x0F) << 8) | buf[FT_REG_P1_YL]; + if (!was_down) { + was_down = true; + down_us = (uint64_t)esp_timer_get_time(); + down_x = x; + down_y = y; + } + } else if (was_down) { + // Release: was-down → up. + was_down = false; + uint64_t now = (uint64_t)esp_timer_get_time(); + + if (now - last_tap_us < (uint64_t)TAP_DEBOUNCE_MS * 1000ULL) continue; + + uint64_t dt_ms = (now - down_us) / 1000ULL; + // No reliable last position from the burst; use down coordinates + // for the duration check, ignore movement check beyond the down + // capture. Good enough for tap-vs-press. + (void)down_x; (void)down_y; (void)TAP_MAX_MOVE_PX; + + if (dt_ms > 0 && dt_ms <= TAP_MAX_MS) { + last_tap_us = now; + viewport_run_state_t cur = state_machine_current(); + viewport_run_state_t target = + (cur == VIEWPORT_STATE_AWAKE) ? VIEWPORT_STATE_ASLEEP + : VIEWPORT_STATE_AWAKE; + ESP_LOGI(TAG, "tap (%lu ms) -> %s", (unsigned long)dt_ms, + target == VIEWPORT_STATE_AWAKE ? "wake" : "sleep"); + state_machine_set_local(target); + } + } + } +} + +esp_err_t touch_init(void) +{ + i2c_master_bus_handle_t bus = display_i2c_bus(); + if (!bus) { + ESP_LOGW(TAG, "I2C bus unavailable — display didn't come up; skipping touch"); + return ESP_ERR_INVALID_STATE; + } + + i2c_device_config_t cfg = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = FT5426_ADDR, + .scl_speed_hz = FT5426_I2C_FREQ_HZ, + }; + ESP_RETURN_ON_ERROR(i2c_master_bus_add_device(bus, &cfg, &s_dev), + TAG, "i2c add FT5426"); + + // Sanity poll: read device mode. + uint8_t dev_mode = 0; + esp_err_t err = ft_read(FT_REG_DEV_MODE, &dev_mode, 1); + if (err != ESP_OK) { + ESP_LOGE(TAG, "FT5426 @0x38 unreachable (%s) — check INT jumper", + esp_err_to_name(err)); + return err; + } + ESP_LOGI(TAG, "FT5426 ack'd (dev_mode=0x%02x)", dev_mode); + + BaseType_t ok = xTaskCreate(touch_task, "touch", 3072, NULL, 4, &s_task); + return (ok == pdPASS) ? ESP_OK : ESP_FAIL; +} diff --git a/main/touch.h b/main/touch.h new file mode 100644 index 0000000..c2c5f79 --- /dev/null +++ b/main/touch.h @@ -0,0 +1,9 @@ +#pragma once + +#include "esp_err.h" + +// Initialize the FT5426 touch controller (I2C 0x38, shared bus with the +// panel MCU) and start the polling/dispatch task. Safe to call before +// the panel is wired — returns an error and leaves the rest of the +// firmware running. +esp_err_t touch_init(void); |
