From 9918aafe414005ed16e5bd613fd208858cf56683 Mon Sep 17 00:00:00 2001 From: a2nr Date: Tue, 30 Jun 2026 17:35:23 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20Phase=20B=20=E2=80=94=20readValue()=20st?= =?UTF-8?q?ate=20poll=20fallback=20for=20END=20ACK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware: - Add BLE_GATT_CHR_F_READ to flashing characteristic - Read callback returns state_machine_get_current() as 1 byte (4=SERIAL_BRIDGE=success, 5/6=ERROR) Frontend: - Replace ineffective pendingAcks poll with readValue() poll - CCCD refresh fire-and-forget (non-blocking) - Race ackPromise (notify) vs readPoll (readValue) - Clean up ackPromise resolver on readPoll win - Cache v7 → v8 --- frontend/src/lib/services/ble-deployer.ts | 108 +++++-- frontend/static/sw.js | 2 +- velxio-deployer-firmware/main/ble_service.c | 301 ++++++++++++++++++++ 3 files changed, 383 insertions(+), 28 deletions(-) create mode 100644 velxio-deployer-firmware/main/ble_service.c diff --git a/frontend/src/lib/services/ble-deployer.ts b/frontend/src/lib/services/ble-deployer.ts index 4325287..ea766e8 100644 --- a/frontend/src/lib/services/ble-deployer.ts +++ b/frontend/src/lib/services/ble-deployer.ts @@ -260,36 +260,90 @@ export class BLEHardwareDeployer { console.log(`[BLE-CMD] ${cmdName} idx=${index}: write failed, still waiting for ACK`, e); } - /* END (flash ~8s): Android BLE stack sometimes fails to deliver the - * notification through the normal characteristicvaluechanged handler - * while a writeValueWithResponse await is still in progress on some - * Qualcomm chipsets. Race the ackPromise against a poll loop that - * yields to the event loop every 200ms and checks pendingAcks (which - * the notification handler fills if the resolver path was missed). */ + /* END (flash ~8s): Android BLE stack (Qualcomm) drops + * characteristicvaluechanged events after ~8s idle, so ACK + * notification is unreliable. Use readValue() to poll the + * firmware's state machine instead — GATT Read is client- + * initiated and NOT affected by the notify idle-drop bug. + * + * CCCD refresh is fire-and-forget as a belt-and-suspenders: + * it didn't solve the issue on Qualcomm, but may help on + * other devices. */ if (cmd === CMD_END) { - const deadline = Date.now() + ackTimeout; - const pollPromise: Promise = new Promise(resolve => { - (async () => { - while (Date.now() < deadline) { - if (this.pendingAcks.has(index)) { - this.pendingAcks.delete(index); - const entry = this.ackResolvers.get(index); - if (entry) { - clearTimeout(entry.timer); - this.ackResolvers.delete(index); - } - console.log(`[BLE-CMD] END idx=${index}: ACK received via poll (pendingAcks)`); - resolve(); - return; + /* Fire-and-forget CCCD refresh (don't await — non-blocking). */ + this.flashingChar!.stopNotifications().catch(() => {}); + this.flashingChar!.startNotifications().then( + () => console.log('[BLE-CMD] END: CCCD re-subscribed (fire-and-forget)'), + (e) => console.log('[BLE-CMD] END: CCCD refresh failed (non-critical)', e) + ); + + /* Read-based state poll — the RELIABLE path. + * State values from state_machine.h: + * 0=IDLE, 1=RECEIVING, 2=VERIFYING, 3=FLASHING (keep polling), + * 4=SERIAL_BRIDGE (success), 5=ERROR_TARGET, 6=ERROR_CHECKSUM */ + const STATE_SERIAL_BRIDGE = 4; + const STATE_ERROR_TARGET = 5; + const STATE_ERROR_CHECKSUM = 6; + + const readPoll = (async () => { + const deadline = Date.now() + ackTimeout; + let firstPoll = true; + while (Date.now() < deadline) { + try { + const dv = await this.withTimeout( + this.flashingChar!.readValue(), + 3000, + 'readValue(state)' + ); + const state = dv.getUint8(0); + console.log(`[BLE-CMD] END: readValue state=${state}`); + + if (state === STATE_SERIAL_BRIDGE) { + console.log('[BLE-CMD] END: state=SERIAL_BRIDGE — flash success via read poll'); + return 'ok' as const; } - await new Promise(r => setTimeout(r, 200)); + if (state === STATE_ERROR_TARGET || state === STATE_ERROR_CHECKSUM) { + console.log(`[BLE-CMD] END: state=ERROR(${state}) — flash failed via read poll`); + throw new Error('Flash gagal di sisi firmware (state=' + state + ')'); + } + /* state=FLASHING or VERIFYING — keep polling */ + } catch (e) { + /* readValue failed (GATT error or timeout) — keep + * retrying unless the connection itself is dead. */ + if (!this.isConnected) { + throw new Error('Koneksi BLE terputus saat menunggu flash'); + } + console.log('[BLE-CMD] END: readValue retry...', e); } - console.log(`[BLE-CMD] END idx=${index}: poll deadline passed — ackPromise must win`); - resolve(); // resolve poll so the race falls through to ackPromise - })(); - }); - await Promise.race([ackPromise, pollPromise]); - console.log(`[BLE-CMD] END idx=${index}: ackPromise or poll resolved`); + /* First poll at 200ms (fast check if flash already done), + * then every 500ms. ~16 polls during 8s flash. */ + await new Promise(r => setTimeout(r, firstPoll ? 200 : 500)); + firstPoll = false; + } + console.log('[BLE-CMD] END: readPoll deadline passed (30s)'); + return 'timeout' as const; + })(); + + /* Race: ACK notify (fast path — works on some devices) vs + * readValue poll (reliable path — works everywhere). */ + const ackWithLabel = ackPromise.then(() => 'ack' as const); + const result = await Promise.race([ackWithLabel, readPoll]); + console.log(`[BLE-CMD] END idx=${index}: resolved via ${result}`); + + /* Clean up the ackPromise resolver if readPoll won — + * prevents a dangling 30s timer. */ + if (result !== 'ack') { + const entry = this.ackResolvers.get(index); + if (entry) { + clearTimeout(entry.timer); + this.ackResolvers.delete(index); + console.log(`[BLE-CMD] END: cleaned up ackPromise resolver (won via ${result})`); + } + } + + if (result === 'timeout') { + throw new Error(`Timeout menunggu ACK untuk chunk ${index}`); + } return; } diff --git a/frontend/static/sw.js b/frontend/static/sw.js index e59548b..235f539 100644 --- a/frontend/static/sw.js +++ b/frontend/static/sw.js @@ -1,5 +1,5 @@ // static/sw.js -const CACHE_VERSION = 'elemes-v6'; +const CACHE_VERSION = 'elemes-v8'; const STATIC_CACHE = `${CACHE_VERSION}-static`; const API_CACHE = `${CACHE_VERSION}-api`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/velxio-deployer-firmware/main/ble_service.c b/velxio-deployer-firmware/main/ble_service.c new file mode 100644 index 0000000..aaabcc0 --- /dev/null +++ b/velxio-deployer-firmware/main/ble_service.c @@ -0,0 +1,301 @@ +#include +#include "esp_log.h" +#include "esp_nimble_hci.h" +#include "nimble/nimble_port.h" +#include "nimble/nimble_port_freertos.h" +#include "host/ble_hs.h" +#include "host/ble_hs_id.h" +#include "host/ble_uuid.h" +#include "host/ble_gap.h" +#include "host/util/util.h" +#include "host/ble_sm.h" +#include "host/ble_att.h" + +void ble_store_config_init(void); +#include "services/gap/ble_svc_gap.h" +#include "services/gatt/ble_svc_gatt.h" +#include "ble_service.h" +#include "serial_bridge.h" +#include "state_machine.h" + +static const char *TAG = "BLE_SVC"; + +static ble_data_cb_t flashing_callback = NULL; +static ble_serial_cb_t serial_callback = NULL; +static uint16_t flashing_attr_handle; +static uint16_t serial_attr_handle; +static bool ble_connected = false; +static uint16_t conn_handle = 0; +static uint8_t own_addr_type = 0; + +static const ble_uuid128_t service_uuid = BLE_UUID128_INIT( + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x4F, 0x49, 0x58, 0x4C, 0x45, 0x56 +); + +static const ble_uuid128_t flashing_char_uuid = BLE_UUID128_INIT( + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x4F, 0x49, 0x58, 0x4C, 0x45, 0x56 +); + +static const ble_uuid128_t serial_char_uuid = BLE_UUID128_INIT( + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x4F, 0x49, 0x58, 0x4C, 0x45, 0x56 +); + +static int ble_gap_event_cb(struct ble_gap_event *event, void *arg); + +static int flashing_char_access_cb(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg) +{ + if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) { + /* Return current state machine state as 1 byte. + * Webapp polls this after END to detect flash completion: + * 0=IDLE, 1=RECEIVING, 2=VERIFYING, 3=FLASHING, + * 4=SERIAL_BRIDGE (success), 5=ERROR_TARGET, 6=ERROR_CHECKSUM */ + uint8_t state = (uint8_t)state_machine_get_current(); + os_mbuf_append(ctxt->om, &state, 1); + ESP_LOGD(TAG, "Flashing read: state=%d (%s)", + state, state_machine_get_state_name()); + return 0; + } + + if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) { + size_t len = OS_MBUF_PKTLEN(ctxt->om); + uint8_t *data = malloc(len); + if (data) { + os_mbuf_copydata(ctxt->om, 0, len, data); + ESP_LOGI(TAG, "Flashing write: len=%d conn=%d handle=0x%04X cmd=0x%02X", + len, conn_handle, attr_handle, len > 0 ? data[0] : 0); + if (flashing_callback) { + flashing_callback(data, len); + } + free(data); + } + } + return 0; +} + +static int serial_char_access_cb(uint16_t conn_handle, uint16_t attr_handle, + struct ble_gatt_access_ctxt *ctxt, void *arg) +{ + if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) { + size_t len = OS_MBUF_PKTLEN(ctxt->om); + uint8_t *data = malloc(len); + if (data) { + os_mbuf_copydata(ctxt->om, 0, len, data); + ESP_LOGI(TAG, "Serial write: len=%d conn=%d", len, conn_handle); + serial_bridge_on_ble_write(data, len); + free(data); + } + } + return 0; +} + +static const struct ble_gatt_svc_def gatt_svcs[] = { + { + .type = BLE_GATT_SVC_TYPE_PRIMARY, + .uuid = &service_uuid.u, + .characteristics = (struct ble_gatt_chr_def[]) { + { + .uuid = &flashing_char_uuid.u, + .access_cb = flashing_char_access_cb, + .flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_NOTIFY, + .val_handle = &flashing_attr_handle + }, + { + .uuid = &serial_char_uuid.u, + .access_cb = serial_char_access_cb, + .flags = BLE_GATT_CHR_F_WRITE_NO_RSP | BLE_GATT_CHR_F_NOTIFY, + .val_handle = &serial_attr_handle + }, + { + 0 + } + } + }, + { + 0 + } +}; + +static void ble_restart_adv(void) +{ + int rc; + struct ble_gap_adv_params adv_params; + uint8_t adv_data[31]; + uint8_t adv_data_len = 0; + + struct ble_hs_adv_fields fields; + memset(&fields, 0, sizeof(fields)); + fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP; + fields.name = (uint8_t *)"Velxio"; + fields.name_len = 6; + fields.name_is_complete = 0; + fields.uuids16 = NULL; + fields.num_uuids16 = 0; + + rc = ble_hs_adv_set_fields(&fields, adv_data, &adv_data_len, sizeof(adv_data)); + if (rc != 0) { + ESP_LOGE(TAG, "ble_hs_adv_set_fields rc=%d", rc); + return; + } + + rc = ble_gap_adv_set_data(adv_data, adv_data_len); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gap_adv_set_data rc=%d", rc); + return; + } + + memset(&adv_params, 0, sizeof(adv_params)); + adv_params.conn_mode = BLE_GAP_CONN_MODE_UND; + adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN; + + rc = ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER, + &adv_params, ble_gap_event_cb, NULL); + if (rc == 0) { + ESP_LOGI(TAG, "BLE advertising started"); + } else { + ESP_LOGE(TAG, "ble_gap_adv_start rc=%d", rc); + } +} + +static int ble_gap_event_cb(struct ble_gap_event *event, void *arg) +{ + switch (event->type) { + case BLE_GAP_EVENT_CONNECT: + conn_handle = event->connect.conn_handle; + ble_connected = true; + ESP_LOGI(TAG, "BLE connected, handle=%d", conn_handle); + break; + + case BLE_GAP_EVENT_DISCONNECT: + conn_handle = 0; + ble_connected = false; + ESP_LOGI(TAG, "BLE disconnected"); + ble_restart_adv(); + break; + + case BLE_GAP_EVENT_ADV_COMPLETE: + ESP_LOGI(TAG, "ADV_COMPLETE, reason=%d", event->adv_complete.reason); + ble_restart_adv(); + break; + + case BLE_GAP_EVENT_SUBSCRIBE: + ESP_LOGI(TAG, "Subscribe attr_handle=%d notify=%d indicate=%d prev_notify=%d prev_indicate=%d", + event->subscribe.attr_handle, + event->subscribe.cur_notify, + event->subscribe.cur_indicate, + event->subscribe.prev_notify, + event->subscribe.prev_indicate); + break; + + default: + break; + } + return 0; +} + +static void ble_on_reset(int reason) +{ + ESP_LOGE(TAG, "NimBLE host reset, reason=%d", reason); +} + +static void ble_on_sync(void) +{ + int rc; + + rc = ble_hs_util_ensure_addr(1); + if (rc != 0) { + ESP_LOGE(TAG, "ble_hs_util_ensure_addr failed: %d", rc); + return; + } + + rc = ble_hs_id_infer_auto(0, &own_addr_type); + if (rc != 0) { + ESP_LOGE(TAG, "ble_hs_id_infer_auto failed: %d", rc); + return; + } + + ESP_LOGI(TAG, "Address type: %d", own_addr_type); + + rc = ble_att_set_preferred_mtu(255); + if (rc == 0) { + ESP_LOGI(TAG, "Preferred MTU set: 255"); + } else { + ESP_LOGW(TAG, "ble_att_set_preferred_mtu failed: %d", rc); + } + + ble_restart_adv(); +} + +static void host_task(void *param) +{ + nimble_port_run(); +} + +void ble_service_init(void) +{ + esp_nimble_hci_init(); + nimble_port_init(); + + ble_hs_cfg.reset_cb = ble_on_reset; + ble_hs_cfg.sync_cb = ble_on_sync; + ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT; + ble_hs_cfg.sm_sc = 0; + + ble_svc_gap_init(); + ble_svc_gatt_init(); + + ble_gatts_count_cfg(gatt_svcs); + ble_gatts_add_svcs(gatt_svcs); + + ble_svc_gap_device_name_set("Velxio-Deployer"); + ble_svc_gap_device_appearance_set(0x0080); + + ble_store_config_init(); + + nimble_port_freertos_init(host_task); + ESP_LOGI(TAG, "BLE service initialized"); +} + +void ble_service_set_flashing_callback(ble_data_cb_t cb) +{ + flashing_callback = cb; +} + +void ble_service_set_serial_callback(ble_serial_cb_t cb) +{ + serial_callback = cb; +} + +void ble_service_send_notify_flashing(uint8_t *data, size_t len) +{ + if (!ble_connected) return; + struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len); + if (om) { + int rc = ble_gatts_notify_custom(conn_handle, flashing_attr_handle, om); + if (rc != 0) { + ESP_LOGW(TAG, "flashing notify failed: rc=%d handle=0x%04X conn=%d", + rc, flashing_attr_handle, conn_handle); + } + } +} + +void ble_service_send_notify_serial(uint8_t *data, size_t len) +{ + if (!ble_connected) return; + struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len); + if (om) { + int rc = ble_gatts_notify_custom(conn_handle, serial_attr_handle, om); + if (rc != 0) { + ESP_LOGW(TAG, "serial notify failed: rc=%d handle=0x%04X conn=%d", + rc, serial_attr_handle, conn_handle); + } + } +} + +bool ble_service_is_connected(void) +{ + return ble_connected; +}