feat(plip): /voice/hook client (master REST)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 ~10–20 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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user