diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 216e61f..00d1e79 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -315,7 +315,7 @@ Alle Master-Sends laufen über `send_message()` / `send_message_ex()`: | Nachricht | Task / Trigger | Master-Ziel | |-----------|----------------|-------------| -| `ESPNOW_ACCEL_SAMPLE` | `slave_accel_stream_task` 10 ms (best-effort) | `client_registry_update_accel()` | +| `ESPNOW_ACCEL_SAMPLE` | `slave_accel_stream_task` 10 ms, 1 ms slots via Discover `master_time_us` | `client_registry_update_accel()` | | `ESPNOW_TAP_EVENT` | BMA456-IRQ → `on_bma456_tap` | `client_registry_update_tap()` | | `ESPNOW_BATTERY_REPORT` | Heartbeat + nach Join | `client_registry_update_battery()` | | `ESPNOW_SLAVE_INFO` / `HEARTBEAT` | `slave_tx_task` / Heartbeat-Task | `client_registry_heartbeat()` | @@ -366,7 +366,7 @@ flowchart TB | `espnow_mon` | Master | 1 s Timeout + Master-Battery | | `espnow_stx` | Slave | Queue: SLAVE_INFO, Battery nach Join | | `espnow_hb` | Slave | 1 s Heartbeat + 30 s Battery | -| `espnow_accel` | Slave | 10 ms Accel best-effort wenn Stream an | +| `espnow_accel` | Slave | 10 ms Accel in ID-Slot (1 ms) wenn Stream an | | `bma456_poll` | Beide | 10 Hz (`bosch456.c`) | | `vTaskLedRing` | Beide | LED-Ring-Befehle | diff --git a/main/README.md b/main/README.md index 57ea8c5..386de21 100644 --- a/main/README.md +++ b/main/README.md @@ -116,7 +116,7 @@ Schema: `proto/esp_now_messages.proto`. Encode/decode: `esp_now_proto.c`. The ES | `EspNowMessageType` | Direction | `oneof` payload | |---------------------|-----------|-----------------| -| `ESPNOW_DISCOVER` | Master → broadcast `FF:FF:FF:FF:FF:FF` | `EspNowDiscover` (`network`) | +| `ESPNOW_DISCOVER` | Master → broadcast `FF:FF:FF:FF:FF:FF` | `EspNowDiscover` (`network`, `master_time_us` for accel TX slot sync) | | `ESPNOW_SLAVE_INFO` | Slave → master | `EspNowSlavePresence` | | `ESPNOW_HEARTBEAT` | Slave → master | `EspNowSlavePresence` (same fields) | | `ESPNOW_SET_ACCEL_DEADZONE` | Master → slave | `EspNowAccelDeadzone` (`deadzone` LSB) | @@ -124,7 +124,7 @@ Schema: `proto/esp_now_messages.proto`. Encode/decode: `esp_now_proto.c`. The ES | `ESPNOW_UNICAST_TEST` | Master → slave | `EspNowUnicastTest` (`seq`) | | `ESPNOW_FIND_ME` | Master → slave | `EspNowFindMe` (`client_id` filter) — LED locate sequence | | `ESPNOW_RESTART` | Master → slave | `EspNowRestart` (`client_id` filter) — reboot slave | -| `ESPNOW_ACCEL_SAMPLE` | Slave → master | `EspNowAccelSample` (`slave_id`, `x`, `y`, `z` raw LSB) — ~every 10 ms; TX path via `INPUT_SEND_MODE` (default best-effort) | +| `ESPNOW_ACCEL_SAMPLE` | Slave → master | `EspNowAccelSample` (`slave_id`, `x`, `y`, `z` raw LSB) — ~every 10 ms in 1 ms slots `offset_ms=(id-1)%8` via Discover sync; TX path via `INPUT_SEND_MODE` | | `ESPNOW_SET_INPUT_SEND_MODE` | Master → slave | `EspNowInputSendMode` (`mode` 0..3 = best_effort/fast/wait/reliable) | | `ESPNOW_SET_TAP_NOTIFY` | Master → slave | `EspNowTapNotify` (`client_id`, `single`, `double_tap`, `triple`) — which tap kinds to forward | | `ESPNOW_TAP_EVENT` | Slave → master | `EspNowTapEvent` (`client_id`, `kind`) — on BMA456 tap interrupt if notify enabled | diff --git a/main/esp_now_master.c b/main/esp_now_master.c index dc0cbb1..e5f4f7a 100644 --- a/main/esp_now_master.c +++ b/main/esp_now_master.c @@ -730,6 +730,7 @@ static void discover_task(void *param) { while (1) { msg.payload.discover.master_ota_pending = ota_session_busy(); + msg.payload.discover.master_time_us = (uint64_t)esp_timer_get_time(); if (!ota_espnow_distribution_active()) { esp_now_core_send_fast(ESPNOW_BCAST, &msg); } diff --git a/main/esp_now_slave.c b/main/esp_now_slave.c index a81bd8b..7716b29 100644 --- a/main/esp_now_slave.c +++ b/main/esp_now_slave.c @@ -11,8 +11,10 @@ #include "pod_reboot.h" #include "pod_settings.h" #include "esp_log.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/idf_additions.h" +#include "freertos/semphr.h" #include #ifndef POWERPOD_FW_VERSION @@ -24,11 +26,15 @@ /** While master or slave OTA is in progress (discover may be sparse). */ #define SLAVE_MASTER_OTA_GRACE_MS 300000u /** - * Accel stream cadence. TX path is selectable (INPUT_SEND_MODE; default - * best_effort). Must be >= one FreeRTOS tick (CONFIG_FREERTOS_HZ=100 → 10 ms); - * shorter values become 0 ticks and assert in vTaskDelayUntil. + * Accel stream cadence (10 ms frame). TX path is selectable (INPUT_SEND_MODE). + * With master Discover.master_time_us, slaves phase into 1 ms slots: + * offset_ms = (assigned_id - 1) % 8. Slot wait uses esp_timer (FreeRTOS tick + * is 10 ms — too coarse for intra-frame offsets). */ #define ESPNOW_ACCEL_INTERVAL_MS 10 +#define ESPNOW_ACCEL_INTERVAL_US (ESPNOW_ACCEL_INTERVAL_MS * 1000ull) +#define ESPNOW_ACCEL_SLOT_COUNT 8u +#define ESPNOW_ACCEL_SLOT_US 1000ull #define ESPNOW_BATTERY_INTERVAL_MS 30000 #define SLAVE_BATTERY_AFTER_JOIN_MS 150 @@ -50,6 +56,11 @@ static uint32_t s_assigned_id; /** Master MAC from NVS binding (may differ from current s_master_mac). */ static uint8_t s_bound_master_mac[ESP_NOW_ETH_ALEN]; static bool s_has_binding; +/** estimated_master_us = esp_timer_get_time() + s_master_offset_us */ +static int64_t s_master_offset_us; +static bool s_master_time_synced; +static SemaphoreHandle_t s_accel_slot_sem; +static esp_timer_handle_t s_accel_slot_timer; typedef enum { SLAVE_TX_SLAVE_INFO = 1, @@ -59,6 +70,7 @@ typedef enum { static QueueHandle_t s_tx_queue; static void queue_tx(slave_tx_op_t op); +static uint32_t accel_slot_offset_us(void); static bool from_joined_master(const uint8_t *master_mac) { return s_joined && esp_now_core_mac_equal(master_mac, s_master_mac); @@ -117,7 +129,9 @@ static void apply_assigned_id(uint32_t assigned_id) { ESP_LOGW(TAG, "assigned id=%lu but NVS save failed", (unsigned long)assigned_id); } else { - ESP_LOGI(TAG, "assigned id=%lu (persisted)", (unsigned long)assigned_id); + ESP_LOGI(TAG, "assigned id=%lu (persisted, accel slot %lu us)", + (unsigned long)assigned_id, + (unsigned long)accel_slot_offset_us()); } queue_tx(SLAVE_TX_BATTERY); } @@ -226,6 +240,8 @@ static void reset_join(void) { s_joined = false; s_master_ota_grace = false; s_accel_stream_enabled = false; + s_master_time_synced = false; + s_master_offset_us = 0; memset(s_master_mac, 0, sizeof(s_master_mac)); s_last_discover_ms = 0; if (s_tx_queue != NULL) { @@ -233,6 +249,66 @@ static void reset_join(void) { } } +static void apply_master_time_sync(uint64_t master_time_us) { + if (master_time_us == 0) { + return; + } + s_master_offset_us = (int64_t)master_time_us - esp_timer_get_time(); + s_master_time_synced = true; +} + +static uint32_t accel_slot_offset_us(void) { + if (s_assigned_id == 0) { + return 0; + } + return (uint32_t)(((s_assigned_id - 1u) % ESPNOW_ACCEL_SLOT_COUNT) * + ESPNOW_ACCEL_SLOT_US); +} + +static void accel_slot_timer_cb(void *arg) { + (void)arg; + if (s_accel_slot_sem != NULL) { + xSemaphoreGive(s_accel_slot_sem); + } +} + +/** Sleep until this slave's 1 ms slot in the shared 10 ms master frame. */ +static void wait_next_accel_slot(void) { + if (!s_master_time_synced || s_accel_slot_sem == NULL || + s_accel_slot_timer == NULL) { + const TickType_t period_ticks = pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS); + vTaskDelay(period_ticks > 0 ? period_ticks : 1); + return; + } + + const int64_t period_us = (int64_t)ESPNOW_ACCEL_INTERVAL_US; + const int64_t slot_us = (int64_t)accel_slot_offset_us(); + const int64_t master_now = esp_timer_get_time() + s_master_offset_us; + int64_t phase = master_now % period_us; + if (phase < 0) { + phase += period_us; + } + int64_t wait_us = slot_us - phase; + if (wait_us < 200) { + /* Too close / already past — take the next frame. */ + wait_us += period_us; + } + + (void)xSemaphoreTake(s_accel_slot_sem, 0); + (void)esp_timer_stop(s_accel_slot_timer); + esp_err_t err = esp_timer_start_once(s_accel_slot_timer, (uint64_t)wait_us); + if (err != ESP_OK) { + vTaskDelay(pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS) > 0 + ? pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS) + : 1); + return; + } + if (xSemaphoreTake(s_accel_slot_sem, + pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS * 3)) != pdTRUE) { + (void)esp_timer_stop(s_accel_slot_timer); + } +} + static void queue_tx(slave_tx_op_t op) { if (s_tx_queue == NULL) { return; @@ -592,6 +668,7 @@ static void handle_discover(const uint8_t *sender_mac, return; } s_master_ota_grace = discover->master_ota_pending; + apply_master_time_sync(discover->master_time_us); if ((now - s_last_discover_ms) <= slave_master_lost_ms()) { touch_master_presence(now); return; @@ -607,6 +684,7 @@ static void handle_discover(const uint8_t *sender_mac, memcpy(s_master_mac, sender_mac, ESP_NOW_ETH_ALEN); s_joined = true; s_master_ota_grace = discover->master_ota_pending; + apply_master_time_sync(discover->master_time_us); touch_master_presence(now); esp_now_core_ensure_peer(sender_mac); @@ -639,15 +717,13 @@ static void check_master_timeout(void) { static void accel_stream_task(void *param) { (void)param; - ESP_LOGI(TAG, "accel stream task (interval %u ms, mode selectable)", - (unsigned)ESPNOW_ACCEL_INTERVAL_MS); + ESP_LOGI(TAG, + "accel stream task (interval %u ms, %u×%llu us slots, mode selectable)", + (unsigned)ESPNOW_ACCEL_INTERVAL_MS, (unsigned)ESPNOW_ACCEL_SLOT_COUNT, + (unsigned long long)ESPNOW_ACCEL_SLOT_US); - TickType_t last_wake = xTaskGetTickCount(); - const TickType_t period_ticks = pdMS_TO_TICKS(ESPNOW_ACCEL_INTERVAL_MS); - /* pdMS_TO_TICKS can be 0 when ms < tick period — DelayUntil asserts. */ - const TickType_t delay_ticks = period_ticks > 0 ? period_ticks : 1; while (1) { - vTaskDelayUntil(&last_wake, delay_ticks); + wait_next_accel_slot(); if (!s_joined || s_assigned_id == 0 || !s_accel_stream_enabled || !bma456_is_ready()) { continue; @@ -834,6 +910,21 @@ esp_err_t esp_now_slave_start(void) { reset_join(); load_id_binding(); + s_accel_slot_sem = xSemaphoreCreateBinary(); + if (s_accel_slot_sem == NULL) { + ESP_LOGE(TAG, "failed to create accel slot semaphore"); + return ESP_ERR_NO_MEM; + } + const esp_timer_create_args_t slot_timer_args = { + .callback = &accel_slot_timer_cb, + .name = "accel_slot", + }; + esp_err_t terr = esp_timer_create(&slot_timer_args, &s_accel_slot_timer); + if (terr != ESP_OK) { + ESP_LOGE(TAG, "failed to create accel slot timer: %s", esp_err_to_name(terr)); + return terr; + } + s_tx_queue = xQueueCreate(4, sizeof(slave_tx_op_t)); if (s_tx_queue == NULL) { ESP_LOGE(TAG, "failed to create tx queue"); diff --git a/main/proto/esp_now_messages.pb.h b/main/proto/esp_now_messages.pb.h index 8919f7f..ed15cec 100644 --- a/main/proto/esp_now_messages.pb.h +++ b/main/proto/esp_now_messages.pb.h @@ -74,6 +74,10 @@ typedef struct _alox_EspNowDiscover { uint32_t network; /* * Master is in an OTA session (UART upload or ESP-NOW distribution). */ bool master_ota_pending; + /* * + Master esp_timer_get_time() (µs) at broadcast. Slaves use this to phase + accel TX into 1 ms slots: offset_ms = (assigned_id - 1) % 8. */ + uint64_t master_time_us; } alox_EspNowDiscover; typedef struct _alox_EspNowSlavePresence { @@ -277,7 +281,7 @@ extern "C" { #define alox_EspNowEchoPong_init_default {0, 0} #define alox_EspNowFindMe_init_default {0} #define alox_EspNowRestart_init_default {0} -#define alox_EspNowDiscover_init_default {0, 0} +#define alox_EspNowDiscover_init_default {0, 0, 0} #define alox_EspNowSlavePresence_init_default {0, {{NULL}, NULL}, 0, 0, 0, 0} #define alox_EspNowAssignId_init_default {0} #define alox_EspNowClearIdBinding_init_default {0} @@ -301,7 +305,7 @@ extern "C" { #define alox_EspNowEchoPong_init_zero {0, 0} #define alox_EspNowFindMe_init_zero {0} #define alox_EspNowRestart_init_zero {0} -#define alox_EspNowDiscover_init_zero {0, 0} +#define alox_EspNowDiscover_init_zero {0, 0, 0} #define alox_EspNowSlavePresence_init_zero {0, {{NULL}, NULL}, 0, 0, 0, 0} #define alox_EspNowAssignId_init_zero {0} #define alox_EspNowClearIdBinding_init_zero {0} @@ -331,6 +335,7 @@ extern "C" { #define alox_EspNowRestart_client_id_tag 1 #define alox_EspNowDiscover_network_tag 1 #define alox_EspNowDiscover_master_ota_pending_tag 2 +#define alox_EspNowDiscover_master_time_us_tag 3 #define alox_EspNowSlavePresence_network_tag 1 #define alox_EspNowSlavePresence_mac_tag 2 #define alox_EspNowSlavePresence_version_tag 3 @@ -442,7 +447,8 @@ X(a, STATIC, SINGULAR, UINT32, client_id, 1) #define alox_EspNowDiscover_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, network, 1) \ -X(a, STATIC, SINGULAR, BOOL, master_ota_pending, 2) +X(a, STATIC, SINGULAR, BOOL, master_ota_pending, 2) \ +X(a, STATIC, SINGULAR, UINT64, master_time_us, 3) #define alox_EspNowDiscover_CALLBACK NULL #define alox_EspNowDiscover_DEFAULT NULL @@ -686,7 +692,7 @@ extern const pb_msgdesc_t alox_EspNowMessage_msg; #define alox_EspNowBatteryQuery_size 6 #define alox_EspNowBatteryReport_size 22 #define alox_EspNowClearIdBinding_size 0 -#define alox_EspNowDiscover_size 8 +#define alox_EspNowDiscover_size 19 #define alox_EspNowEchoPing_size 22 #define alox_EspNowEchoPong_size 22 #define alox_EspNowFindMe_size 6 diff --git a/main/proto/esp_now_messages.proto b/main/proto/esp_now_messages.proto index bc8b1ef..ce04cab 100644 --- a/main/proto/esp_now_messages.proto +++ b/main/proto/esp_now_messages.proto @@ -67,6 +67,11 @@ message EspNowDiscover { uint32 network = 1; /** Master is in an OTA session (UART upload or ESP-NOW distribution). */ bool master_ota_pending = 2; + /** + * Master esp_timer_get_time() (µs) at broadcast. Slaves use this to phase + * accel TX into 1 ms slots: offset_ms = (assigned_id - 1) % 8. + */ + uint64 master_time_us = 3; } message EspNowSlavePresence {