src.nth.io/

summaryrefslogtreecommitdiff
path: root/main/state_client.c
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/state_client.c
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/state_client.c')
-rw-r--r--main/state_client.c105
1 files changed, 105 insertions, 0 deletions
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);
+}