src.nth.io/

summaryrefslogtreecommitdiff
path: root/main
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-13 23:01:23 -0500
committerLuke Hoersten <[email protected]>2026-06-13 23:01:23 -0500
commit1d6cbb222ced226fcb482c0b19130774d181c8f8 (patch)
tree768d964dc962e111c8c596e10e2da95748bc738a /main
parent57c93fd2978ed5b5e64f4620803b7a2cd7767ed2 (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.txt3
-rw-r--r--main/app_main.c11
-rw-r--r--main/display.c2
-rw-r--r--main/display.h6
-rw-r--r--main/state_client.c105
-rw-r--r--main/state_client.h18
-rw-r--r--main/state_machine.c14
-rw-r--r--main/state_machine.h6
-rw-r--r--main/touch.c123
-rw-r--r--main/touch.h9
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, &reg, 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);