diff options
| author | Luke Hoersten <[email protected]> | 2026-06-13 23:01:23 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-13 23:01:23 -0500 |
| commit | 1d6cbb222ced226fcb482c0b19130774d181c8f8 (patch) | |
| tree | 768d964dc962e111c8c596e10e2da95748bc738a /main | |
| parent | 57c93fd2978ed5b5e64f4620803b7a2cd7767ed2 (diff) | |
M7: touch + outbound /state POST to Scrypted
state_client.{h,c}:
- Worker task drains a depth-1 queue (xQueueOverwrite gives the
replace-on-full semantics from the spec — in-flight POST is never
cancelled; the next queued entry is overwritten by newer state).
- esp_http_client POST to <scrypted>/state with Content-Type application/
json, User-Agent ScryptedViewport/<version>, Connection: close, 1s
timeout. Body: {"viewport":"<name>","state":"wake"|"sleep"}.
- Any non-2xx or transport error increments state_post_failures and is
otherwise ignored. Silently drops if no Scrypted URL is configured.
touch.{h,c}:
- FT5426 capacitive touch on the shared I2C bus at 0x38.
- 30ms polling task; tracks down/up transitions, detects taps as
down-then-up within 500ms with a 150ms debounce.
- On tap, toggles wake/sleep via state_machine_set_local(), which drives
the local transition AND fires state_client_post() at Scrypted.
state_machine adds state_machine_set_local(): runs the same transition
as state_machine_set() then enqueues an outbound POST. Idle-timer
expiry now uses this path so Scrypted sees idle-driven sleeps too
(the spec's "tighten the race with /frame 409" path stays in place as
the fallback).
display.h exposes display_i2c_bus(); touch.c uses it instead of
re-initializing the same I2C port.
app_main starts state_client right after the state machine and starts
touch after display init (touch is skipped if display isn't up since
they share the bus).
CMakeLists.txt: add esp_http_client to REQUIRES.
Build clean against ESP-IDF 5.4 (binary ~860 KB; jumped ~210 KB from
M6 because esp_http_client + cJSON path pulls in tcp_transport, mbedtls,
http_parser).
TESTING.md M7: flask-based test receiver, tap dispatch verification,
failure-path check (kill receiver, confirm counter increments),
queue-coalescing behavior, no-POST-on-Scrypted-initiated, no-POST-
when-unconfigured.
Diffstat (limited to 'main')
| -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 |
10 files changed, 293 insertions, 4 deletions
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); |
