feat(plip): /voice/hook client (master REST)

This commit is contained in:
L'électron rare
2026-05-04 01:04:50 +02:00
parent 92ac9e7daa
commit 214d5df443
5 changed files with 375 additions and 12 deletions
+7
View File
@@ -15,6 +15,8 @@
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include "zacus_hook_client.h"
extern void start_phone_task();
extern void start_audio_task();
extern void start_network_task();
@@ -28,6 +30,11 @@ void setup() {
// TODO(bringup): SD.begin() before audio task.
// TODO(bringup): WiFi.begin() inside network_task.
// Hook client must be ready before phone_task starts emitting events.
// master_url=nullptr selects the compile-time default (zacus-master.local
// in slice 12 mDNS, overridable via -DZACUS_MASTER_URL).
zacus_hook_client_init(nullptr);
start_phone_task();
start_audio_task();
start_network_task();
+42 -12
View File
@@ -4,35 +4,65 @@
// and a button (BOOT/KEY1) acting as a stand-in for the off-hook signal.
// On the Si3210 PCB the same task drives ring control over SPI and
// listens to Si3210 INT for off-hook / on-hook transitions.
//
// Wire convention (active-low with INPUT_PULLUP):
// level LOW = handset off-hook (pickup)
// level HIGH = handset on-hook (hangup / idle)
// Each transition is forwarded to the Zacus master via zacus_hook_client.
// The HTTP POST runs in its own worker task so this loop and the ISR stay
// fast.
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include "zacus_hook_client.h"
extern QueueHandle_t audio_command_queue(); // audio_task.cpp
namespace {
volatile bool g_off_hook_pending = false;
// Software debounce window — the ESP32-A1S BOOT button is mechanical and
// bounces ~1020 ms. The Si3210 INT is hardware-debounced, so we keep a
// modest window that covers both.
constexpr uint32_t kDebounceMs = 30;
void IRAM_ATTR on_off_hook_isr() {
g_off_hook_pending = true;
volatile bool g_edge_pending = false;
void IRAM_ATTR on_hook_change_isr() {
g_edge_pending = true;
}
void phone_task(void*) {
pinMode(OFF_HOOK_GPIO, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(OFF_HOOK_GPIO), on_off_hook_isr, FALLING);
Serial.println(F("[phone] task ready, watching off-hook"));
// CHANGE so we observe both pickup (FALLING) and hangup (RISING).
attachInterrupt(digitalPinToInterrupt(OFF_HOOK_GPIO), on_hook_change_isr, CHANGE);
// Read initial level so the first transition is a real edge, not a
// boot-time spurious event. Report it to the master so its state machine
// is in sync from the start.
int last_level = digitalRead(OFF_HOOK_GPIO);
zacus_hook_client_report(last_level == LOW ? "off" : "on", "boot");
Serial.printf("[phone] task ready, watching off-hook (level=%d)\n", last_level);
for (;;) {
if (g_off_hook_pending) {
g_off_hook_pending = false;
Serial.println(F("[phone] off-hook detected"));
// TODO(bringup): debounce in software; SLIC INT will already debounce
// in hardware but the dev kit button needs ~30 ms.
// TODO(bringup): notify network_task so the Zacus master sees the event.
// TODO(bringup): tell audio_task to start the appropriate audio cue.
if (g_edge_pending) {
g_edge_pending = false;
vTaskDelay(pdMS_TO_TICKS(kDebounceMs));
int level = digitalRead(OFF_HOOK_GPIO);
if (level != last_level) {
last_level = level;
if (level == LOW) {
Serial.println(F("[phone] off-hook (pickup) detected"));
zacus_hook_client_report("off", "pickup");
// TODO(bringup): tell audio_task to start the appropriate audio cue.
} else {
Serial.println(F("[phone] on-hook (hangup) detected"));
zacus_hook_client_report("on", "hangup");
// TODO(bringup): tell audio_task to stop any active playback.
}
}
}
vTaskDelay(pdMS_TO_TICKS(20));
}
@@ -0,0 +1,110 @@
# `zacus_hook_client`
Reports handset hook-switch transitions from the PLIP firmware to the
Zacus master ESP32 over HTTP.
| Item | Value |
|------|-------|
| Framework | PlatformIO + Arduino (ESP32) |
| Master endpoint | `POST /voice/hook` (slice 10) |
| Default master URL | `http://zacus-master.local` (slice 12 mDNS) |
| Override (compile-time) | `-DZACUS_MASTER_URL=\"http://192.168.0.42\"` |
| Worker task | `zacus-hook`, prio 5, 8 KB stack |
| Queue depth | 4 events (oldest dropped if full) |
| HTTP timeout | 3000 ms, retry once at +250 ms |
The module never blocks the caller: `zacus_hook_client_report()` only
enqueues an event. A dedicated FreeRTOS worker drains the queue and runs
the actual `HTTPClient::POST`.
## API
```c
bool zacus_hook_client_init(const char *master_url);
bool zacus_hook_client_report(const char *state, const char *reason);
```
- `state` must be `"off"` (handset lifted, pickup) or `"on"` (handset
cradled, hangup).
- `reason` is a free-form short tag (`"pickup"`, `"hangup"`, `"boot"`,
`"manual"`).
JSON body sent to the master:
```json
{ "state": "off", "reason": "pickup" }
```
## Hook switch wiring
Active-low with the ESP32 internal pull-up. The pin is set in
`platformio.ini` build flags as `OFF_HOOK_GPIO` (default `4`, BOOT/KEY1
on the AI-Thinker ESP32-A1S dev kit).
```
+3V3
|
R (internal pull-up, INPUT_PULLUP)
|
GPIO4 -------+------ HOOK_SWITCH ------ GND
|
(ISR on CHANGE)
```
- LOW -> handset off-hook (pickup)
- HIGH -> handset on-hook (hangup, idle)
On the Si3210 PCB, replace the mechanical switch with the SLIC `INT` line
(already hardware-debounced). The default `OFF_HOOK_GPIO=4` build flag
just needs to be repointed at the chosen routing pin.
## Override the master URL
```ini
; platformio.ini
build_flags =
...
-DZACUS_MASTER_URL="\"http://192.168.0.42\""
```
Note the doubled escaping: PlatformIO removes one layer, the C preprocessor
needs the inner `"` to keep the literal a string.
## Test without a PLIP
Simulate any pickup/hangup with `curl` against the master:
```bash
# Pickup
curl -X POST http://zacus-master.local/voice/hook \
-H 'Content-Type: application/json' \
-d '{"state":"off","reason":"manual"}'
# Hangup
curl -X POST http://zacus-master.local/voice/hook \
-H 'Content-Type: application/json' \
-d '{"state":"on","reason":"manual"}'
```
If `zacus-master.local` does not resolve from your laptop yet (slice 12
mDNS not deployed), use the master's static LAN IP.
## Logs
```
[zacus-hook] worker ready, target=http://zacus-master.local/voice/hook
[zacus-hook] POST http://zacus-master.local/voice/hook -> 200 (state=off reason=pickup)
[zacus-hook] Wi-Fi down, dropping event (state=on reason=hangup)
[zacus-hook] queue full, dropping (state=off reason=pickup)
```
## Failure policy
| Outcome | Behaviour |
|---------|-----------|
| Wi-Fi down (waited 1.5 s) | Event dropped, logged. No retry — handset state will be re-synced on the next real edge. |
| HTTP non-2xx / transport error | Single retry at +250 ms, then drop. |
| Queue full | Oldest non-served event keeps its slot, new event dropped. |
The master is expected to be idempotent on `/voice/hook` so duplicate
events from a flaky link are safe.
+180
View File
@@ -0,0 +1,180 @@
// zacus_hook_client — implementation.
//
// Posts handset state changes to the Zacus master REST endpoint without
// ever blocking the caller (phone task / hook ISR). One worker task drains
// a small FreeRTOS queue and performs the HTTP request with esp_http_client
// semantics via Arduino's HTTPClient.
//
// Failure policy:
// - timeout 3 s
// - one retry at +250 ms on non-2xx / transport error
// - second failure logs and drops; caller already moved on
//
// Wi-Fi: we DO NOT initiate the WiFi connection here. network_task owns
// it. We just wait (with a short timeout) for WiFi.status() == WL_CONNECTED
// before each POST and skip the request if still down.
#include "zacus_hook_client.h"
#include <Arduino.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <string.h>
#ifndef ZACUS_MASTER_URL
#define ZACUS_MASTER_URL "http://zacus-master.local"
#endif
#ifndef ZACUS_HOOK_PATH
#define ZACUS_HOOK_PATH "/voice/hook"
#endif
#ifndef ZACUS_HOOK_TIMEOUT_MS
#define ZACUS_HOOK_TIMEOUT_MS 3000
#endif
#ifndef ZACUS_HOOK_RETRY_DELAY_MS
#define ZACUS_HOOK_RETRY_DELAY_MS 250
#endif
#ifndef ZACUS_HOOK_QUEUE_DEPTH
#define ZACUS_HOOK_QUEUE_DEPTH 4
#endif
#ifndef ZACUS_HOOK_WIFI_WAIT_MS
#define ZACUS_HOOK_WIFI_WAIT_MS 1500
#endif
namespace {
struct HookEvent {
char state[8]; // "off" | "on"
char reason[32]; // free-form short tag
};
QueueHandle_t g_queue = nullptr;
char g_url[160] = {0};
bool wifi_ready_within(uint32_t budget_ms) {
if (WiFi.status() == WL_CONNECTED) return true;
const uint32_t step = 50;
uint32_t waited = 0;
while (waited < budget_ms) {
vTaskDelay(pdMS_TO_TICKS(step));
waited += step;
if (WiFi.status() == WL_CONNECTED) return true;
}
return false;
}
bool post_once(const HookEvent &ev) {
HTTPClient http;
http.setTimeout(ZACUS_HOOK_TIMEOUT_MS);
http.setConnectTimeout(ZACUS_HOOK_TIMEOUT_MS);
if (!http.begin(g_url)) {
Serial.printf("[zacus-hook] http.begin failed for %s\n", g_url);
return false;
}
http.addHeader("Content-Type", "application/json");
// Hand-rolled JSON: ArduinoJson is overkill for two short strings and
// we want zero allocation cost on the hot path.
char body[96];
snprintf(body, sizeof(body),
"{\"state\":\"%s\",\"reason\":\"%s\"}",
ev.state, ev.reason);
int code = http.POST(reinterpret_cast<const uint8_t *>(body), strlen(body));
http.end();
if (code >= 200 && code < 300) {
Serial.printf("[zacus-hook] POST %s -> %d (state=%s reason=%s)\n",
g_url, code, ev.state, ev.reason);
return true;
}
Serial.printf("[zacus-hook] POST %s failed: code=%d (state=%s reason=%s)\n",
g_url, code, ev.state, ev.reason);
return false;
}
void worker_task(void *) {
Serial.printf("[zacus-hook] worker ready, target=%s%s\n", g_url, ZACUS_HOOK_PATH);
HookEvent ev;
for (;;) {
if (xQueueReceive(g_queue, &ev, portMAX_DELAY) != pdTRUE) continue;
if (!wifi_ready_within(ZACUS_HOOK_WIFI_WAIT_MS)) {
Serial.printf("[zacus-hook] Wi-Fi down, dropping event (state=%s reason=%s)\n",
ev.state, ev.reason);
continue;
}
if (post_once(ev)) continue;
// One retry at +250 ms.
vTaskDelay(pdMS_TO_TICKS(ZACUS_HOOK_RETRY_DELAY_MS));
if (!post_once(ev)) {
Serial.printf("[zacus-hook] giving up after retry (state=%s reason=%s)\n",
ev.state, ev.reason);
}
}
}
} // namespace
bool zacus_hook_client_init(const char *master_url) {
if (g_queue != nullptr) return true; // idempotent
const char *base = (master_url && *master_url) ? master_url : ZACUS_MASTER_URL;
// Compose full URL once: <base><path>. Strip trailing slash on base to
// avoid "http://host//voice/hook".
size_t blen = strnlen(base, sizeof(g_url) - sizeof(ZACUS_HOOK_PATH) - 1);
if (blen == 0 || blen >= sizeof(g_url) - sizeof(ZACUS_HOOK_PATH) - 1) {
Serial.println(F("[zacus-hook] invalid master_url length"));
return false;
}
memcpy(g_url, base, blen);
if (g_url[blen - 1] == '/') blen--;
g_url[blen] = 0;
strncat(g_url, ZACUS_HOOK_PATH, sizeof(g_url) - strlen(g_url) - 1);
g_queue = xQueueCreate(ZACUS_HOOK_QUEUE_DEPTH, sizeof(HookEvent));
if (g_queue == nullptr) {
Serial.println(F("[zacus-hook] xQueueCreate failed"));
return false;
}
BaseType_t ok = xTaskCreate(worker_task, "zacus-hook", 8192, nullptr, 5, nullptr);
if (ok != pdPASS) {
Serial.println(F("[zacus-hook] xTaskCreate failed"));
vQueueDelete(g_queue);
g_queue = nullptr;
return false;
}
return true;
}
bool zacus_hook_client_report(const char *state, const char *reason) {
if (g_queue == nullptr) {
Serial.println(F("[zacus-hook] report() before init(), dropping"));
return false;
}
if (state == nullptr) state = "";
if (reason == nullptr) reason = "";
HookEvent ev;
strncpy(ev.state, state, sizeof(ev.state) - 1);
ev.state[sizeof(ev.state) - 1] = 0;
strncpy(ev.reason, reason, sizeof(ev.reason) - 1);
ev.reason[sizeof(ev.reason) - 1] = 0;
// Non-blocking enqueue: never wait, never block ISR / hot path.
if (xQueueSend(g_queue, &ev, 0) != pdTRUE) {
Serial.printf("[zacus-hook] queue full, dropping (state=%s reason=%s)\n",
ev.state, ev.reason);
return false;
}
return true;
}
+36
View File
@@ -0,0 +1,36 @@
// zacus_hook_client — POST hook switch state changes to the Zacus master.
//
// The Zacus master (slice 10) exposes:
// POST /voice/hook { "state": "off" | "on", "reason": "<short>" }
//
// "off" = handset off-hook (pickup), "on" = handset on-hook (hangup).
// The master URL defaults to http://zacus-master.local (slice 12 mDNS) and
// is overridable at compile time via -DZACUS_MASTER_URL=\"http://1.2.3.4\".
//
// Thread model: a single worker FreeRTOS task drains an event queue. Calls
// from any context (ISR-safe via xQueueSendFromISR is NOT used here — call
// from a regular task, not the ISR itself; the existing phone_task already
// debounces in software) never block the caller.
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
// Initialise the client (spawns the worker task). Safe to call once from
// setup(). `master_url` may be nullptr to use the compile-time default.
// Returns true on success.
bool zacus_hook_client_init(const char *master_url);
// Enqueue a state report. Non-blocking: drops the event (with a Serial
// warning) if the queue is full. `state` must be "off" or "on"; `reason`
// is a short free-form tag ("pickup", "hangup", "boot", "manual", ...).
// Returns true if the event was enqueued, false otherwise.
bool zacus_hook_client_report(const char *state, const char *reason);
#ifdef __cplusplus
}
#endif