diff --git a/.gitignore b/.gitignore index 6a4531c..ece2861 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ Thumbs.db __pycache__/ *.py[cod] .Python +.venv-ble-ota/ # Optional local overrides (secrets, machine paths) scripts/env.local.sh diff --git a/app.c b/app.c index 2d7ba78..4424615 100644 --- a/app.c +++ b/app.c @@ -34,8 +34,10 @@ #include "opendisplay_ble.h" #include -// The advertising set handle allocated from Bluetooth stack. +// The advertising set handles allocated from Bluetooth stack. static uint8_t advertising_set_handle = 0xff; +static uint8_t fmdn_advertising_set_handle = 0xff; +static uint8_t net_a_advertising_set_handle = 0xff; // Application Init. void app_init(void) @@ -80,7 +82,19 @@ void sl_bt_on_event(sl_bt_msg_t *evt) printf("[app] advertiser_create_set sc=0x%04lX\r\n", (unsigned long)sc); } app_assert_status(sc); - opendisplay_ble_on_boot(advertising_set_handle); + sc = sl_bt_advertiser_create_set(&fmdn_advertising_set_handle); + if (sc != SL_STATUS_OK) { + printf("[app] advertiser_create_set(fmdn) sc=0x%04lX\r\n", (unsigned long)sc); + } + app_assert_status(sc); + sc = sl_bt_advertiser_create_set(&net_a_advertising_set_handle); + if (sc != SL_STATUS_OK) { + printf("[app] advertiser_create_set(net_a) sc=0x%04lX\r\n", (unsigned long)sc); + } + app_assert_status(sc); + opendisplay_ble_on_boot(advertising_set_handle, + fmdn_advertising_set_handle, + net_a_advertising_set_handle); break; // ------------------------------- @@ -91,7 +105,6 @@ void sl_bt_on_event(sl_bt_msg_t *evt) case sl_bt_evt_connection_closed_id: opendisplay_ble_on_event(evt); - opendisplay_ble_restart_advertising(advertising_set_handle); break; /////////////////////////////////////////////////////////////////////////// diff --git a/build-and-flash.sh b/build-and-flash.sh index 8b06bee..8407e6d 100755 --- a/build-and-flash.sh +++ b/build-and-flash.sh @@ -35,6 +35,13 @@ NFC_WRITE_NDEF=0 NFC_WRITE_NDEF_CLI=0 NFC_NDEF_TEXT="" NFC_NDEF_TEXT_CLI=0 +DO_BLE_OTA=0 +BLE_OTA_NAME="" +BLE_OTA_KEY="" +BLE_OTA_SLOW=0 +BLE_OTA_ALREADY=0 +BLE_OTA_SCAN_TIMEOUT="${BLE_OTA_SCAN_TIMEOUT:-12}" +BLE_OTA_APPLOADER_TIMEOUT="${BLE_OTA_APPLOADER_TIMEOUT:-45}" usage() { cat <&2; usage ;; @@ -560,10 +589,10 @@ elif [[ "$DO_BUILD" -eq 1 ]] && [[ "$DO_FLASH" -eq 0 ]]; then echo "==> Skipping flash (--no-flash)" fi -if [[ "$DO_OTA_IMAGE" -eq 1 ]] && [[ "$DO_BUILD" -eq 1 || "$DO_FLASH" -eq 1 || "$DO_GBL_ONLY" -eq 1 ]]; then +if [[ "$DO_OTA_IMAGE" -eq 1 ]] && [[ "$DO_BUILD" -eq 1 || "$DO_FLASH" -eq 1 || "$DO_GBL_ONLY" -eq 1 || "$DO_BLE_OTA" -eq 1 ]]; then APP_ART=$(find_artifact) || { - if [[ "$DO_GBL_ONLY" -eq 1 ]]; then - echo "ERROR: --gbl-only needs ${TARGET_NAME}.s37 or .hex under $CMAKE_DIR/build (build first)." >&2 + if [[ "$DO_GBL_ONLY" -eq 1 || "$DO_BLE_OTA" -eq 1 ]]; then + echo "ERROR: need ${TARGET_NAME}.s37 or .hex under $CMAKE_DIR/build (build first)." >&2 exit 1 fi APP_ART="" @@ -574,26 +603,73 @@ if [[ "$DO_OTA_IMAGE" -eq 1 ]] && [[ "$DO_BUILD" -eq 1 || "$DO_FLASH" -eq 1 || " APP_ART="" } if [[ -n "${APP_ART}" ]]; then - echo "==> OTA image: $CMD gbl create $OUT_GBL --app $APP_ART" - if "$CMD" gbl create "$OUT_GBL" --app "$APP_ART"; then - echo "==> OTA image generated: $OUT_GBL" + if [[ -f "$OUT_GBL" && "$DO_BUILD" -eq 0 && "$DO_BLE_OTA" -eq 1 ]]; then + echo "==> Reusing existing OTA image: $OUT_GBL" else - echo "WARN: OTA .gbl generation failed; continuing." >&2 - if [[ "$DO_GBL_ONLY" -eq 1 ]]; then - exit 1 + echo "==> OTA image: $CMD gbl create $OUT_GBL --app $APP_ART" + if "$CMD" gbl create "$OUT_GBL" --app "$APP_ART"; then + echo "==> OTA image generated: $OUT_GBL" + else + echo "WARN: OTA .gbl generation failed; continuing." >&2 + if [[ "$DO_GBL_ONLY" -eq 1 || "$DO_BLE_OTA" -eq 1 ]]; then + exit 1 + fi fi fi - elif [[ "$DO_GBL_ONLY" -eq 1 ]]; then + elif [[ "$DO_GBL_ONLY" -eq 1 || "$DO_BLE_OTA" -eq 1 ]]; then exit 1 fi fi -if [[ "$DO_COLLECT_ARTIFACTS" -eq 1 ]] && [[ "$DO_BUILD" -eq 1 || "$DO_FLASH" -eq 1 || "$DO_GBL_ONLY" -eq 1 ]]; then +if [[ "$DO_COLLECT_ARTIFACTS" -eq 1 ]] && [[ "$DO_BUILD" -eq 1 || "$DO_FLASH" -eq 1 || "$DO_GBL_ONLY" -eq 1 || "$DO_BLE_OTA" -eq 1 ]]; then echo "==> Staging artifacts + memory summary" od_collect_artifacts od_print_memory_summary fi +run_ble_ota() { + local py gbl script + if [[ -n "${PYTHON:-}" ]]; then + py="$PYTHON" + elif [[ -x "$ROOT/.venv-ble-ota/bin/python" ]]; then + py="$ROOT/.venv-ble-ota/bin/python" + else + py="python3" + fi + gbl="${OTA_IMAGE_OUT:-$CMAKE_DIR/build/base/${TARGET_NAME}.gbl}" + if [[ ! -f "$gbl" && -f "$ARTIFACTS_DIR/${TARGET_NAME}.gbl" ]]; then + gbl="$ARTIFACTS_DIR/${TARGET_NAME}.gbl" + fi + if [[ ! -f "$gbl" ]]; then + echo "ERROR: no .gbl for BLE OTA at $gbl" >&2 + return 1 + fi + script="$ROOT/scripts/ble_ota.py" + if [[ ! -f "$script" ]]; then + echo "ERROR: missing $script" >&2 + return 1 + fi + if ! "$py" -c "import silabs_ble_ota" 2>/dev/null; then + echo "ERROR: silabs-ble-ota not installed for: $py" >&2 + echo " python3 -m venv $ROOT/.venv-ble-ota" >&2 + echo " $ROOT/.venv-ble-ota/bin/pip install -r $ROOT/scripts/requirements-ble-ota.txt" >&2 + return 1 + fi + echo "==> BLE OTA via silabs-ble-ota → name '$BLE_OTA_NAME' gbl=$gbl" + echo " python: $py" + local args=(--name "$BLE_OTA_NAME" --gbl "$gbl" + --scan-timeout "$BLE_OTA_SCAN_TIMEOUT" + --apploader-timeout "$BLE_OTA_APPLOADER_TIMEOUT") + [[ -n "$BLE_OTA_KEY" ]] && args+=(--key "$BLE_OTA_KEY") + [[ "$BLE_OTA_SLOW" -eq 1 ]] && args+=(--slow) + [[ "$BLE_OTA_ALREADY" -eq 1 ]] && args+=(--already-in-ota) + "$py" "$script" "${args[@]}" +} + +if [[ "$DO_BLE_OTA" -eq 1 ]]; then + run_ble_ota || exit 1 +fi + if [[ "$DO_RTT" -eq 1 ]]; then run_rtt_session fi diff --git a/cmake_gcc/CMakeLists.txt b/cmake_gcc/CMakeLists.txt index 212d82b..d7f67a2 100644 --- a/cmake_gcc/CMakeLists.txt +++ b/cmake_gcc/CMakeLists.txt @@ -29,6 +29,7 @@ target_compile_definitions(slc PUBLIC OD_TNB132M_BOOT_PROBE=$,1,0> OD_TNB132M_WRITE_NDEF=$,1,0> "OD_TNB132M_NDEF_TEXT=\"${OD_TNB132M_NDEF_TEXT}\"" + SL_CATALOG_BLUETOOTH_FEATURE_EXTENDED_ADVERTISER_PRESENT SL_APP_PROPERTIES=1 "OD_APP_VERSION=${OD_APP_VERSION}" "OPENDISPLAY_BUILD_ID=\"${OPENDISPLAY_BUILD_ID}\"" diff --git a/cmake_gcc/opendisplay-bg22.cmake b/cmake_gcc/opendisplay-bg22.cmake index 808bd99..dcd0f82 100644 --- a/cmake_gcc/opendisplay-bg22.cmake +++ b/cmake_gcc/opendisplay-bg22.cmake @@ -173,6 +173,7 @@ add_library(slc OBJECT "../${COPIED_SDK_PATH}/security_se_manager/platform/security/sl_component/se_manager/src/sli_se_manager_mailbox.c" "../app.c" "../opendisplay_ble.c" + "../opendisplay_net_a_adv.c" "../opendisplay_pipe.c" "../opendisplay_config_storage.c" "../opendisplay_config_parser.c" diff --git a/config/sl_bluetooth_advertiser_config.h b/config/sl_bluetooth_advertiser_config.h index 7cac58b..8a24680 100644 --- a/config/sl_bluetooth_advertiser_config.h +++ b/config/sl_bluetooth_advertiser_config.h @@ -39,7 +39,7 @@ // Specifically, if the component "bluetooth_feature_periodic_advertiser" is used, its configuration SL_BT_CONFIG_MAX_PERIODIC_ADVERTISERS specifies how many of the SL_BT_CONFIG_USER_ADVERTISERS advertising sets are capable of periodic advertising. Similarly, if the component bluetooth_feature_pawr_advertiser is used, its configuration SL_BT_CONFIG_MAX_PAWR_ADVERTISERS specifies how many of the periodic advertising sets are capable of Periodic Advertising with Responses. // // The configuration values must satisfy the condition SL_BT_CONFIG_USER_ADVERTISERS >= SL_BT_CONFIG_MAX_PERIODIC_ADVERTISERS >= SL_BT_CONFIG_MAX_PAWR_ADVERTISERS. -#define SL_BT_CONFIG_USER_ADVERTISERS (1) +#define SL_BT_CONFIG_USER_ADVERTISERS (3) // <<< end of configuration section >>> #endif diff --git a/include/opendisplay_structs.h b/include/opendisplay_structs.h index 4293bfb..edb3f33 100644 --- a/include/opendisplay_structs.h +++ b/include/opendisplay_structs.h @@ -348,7 +348,8 @@ enum ConfigPacketType { OD_PKT_BUZZER = 0x29, /**< @doc "buzzer (repeatable, max 4). Canonical name is BUZZER (matches Silabs), resolving the Silabs BUZZER vs NRF54/yaml PASSIVE_BUZZER split; config.yaml's packet name (passive_buzzer) is regenerated to buzzer." */ OD_PKT_NFC = 0x2A, /**< @doc "nfc_config (repeatable, max 2)" */ OD_PKT_FLASH = 0x2B, /**< @doc "flash_config (repeatable, max 2)" */ - OD_PKT_DATA_EXTENDED = 0x2C /**< @doc "data_extended (singleton)" */ + OD_PKT_DATA_EXTENDED = 0x2C, /**< @doc "data_extended (singleton)" */ + OD_PKT_FINDMY = 0x2D /**< @doc "findmy_config (singleton)" */ }; /* ========================================================================== @@ -1074,6 +1075,40 @@ struct DataExtended { } __attribute__((packed)); OD_STATIC_ASSERT(sizeof(struct DataExtended) == 288, "DataExtended wire size"); +/* ----------------------------------------------------------------------- + * 0x2D findmy_config + * ----------------------------------------------------------------------- */ + +/** @enum FindMyCurve @width 1 @doc "FMDN EID curve selector (FindMyConfig.fmdn_curve)." */ +enum FindMyCurve { + OD_FINDMY_CURVE_SECP160R1 = 0, /**< @doc "20-byte static EID (network G / legacy AD). Uses advertisement_key." */ + OD_FINDMY_CURVE_SECP256R1 = 1 /**< @doc "32-byte rotating EID on SECP256R1 (extended AD)." */ +}; + +/* FindMyConfig.flags @bits FindMyFlags (bits 2-7 reserved). */ +#define OD_FINDMY_FLAG_FMDN_ENABLE (1u << 0) /* @doc "enable network G (FMDN) advertising." */ +#define OD_FINDMY_FLAG_NET_A_ENABLE (1u << 1) /* @doc "enable network A (legacy offline-finding) advertising." */ + +/** @struct FindMyConfig @packet 0x2D + * @doc "Proximity-network provisioning blob. Singleton. 128 bytes. For network G use + * fmdn_curve=secp160r1 with advertisement_key=generate_eid(eik,0) (static beacon_counter=0). + * SECP256R1 rotates from eik+time_base on-device. + * For network A set net_a_enable and net_a_adv_key to the 28-byte P-224 advertisement public key X. + * net_a_time_base stays 0 (static mode, no on-device rotation). Reserved bytes must be zero." */ +struct FindMyConfig { + uint8_t flags; /**< @bits FindMyFlags @doc "enable flags; bits 2-7 reserved." */ + uint8_t fmdn_k; /**< @doc "FMDN rotation-period exponent K (typically 10)." */ + uint8_t fmdn_curve; /**< @enum FindMyCurve @doc "FMDN EID curve selector." */ + uint8_t reserved0[5]; /**< @reserved @doc "must be 0; pads the control header to 8 bytes." */ + uint8_t eik[32]; /**< @doc "Network G ephemeral identity key (32 bytes)." */ + uint8_t net_a_adv_key[28]; /**< @doc "Network A 28-byte advertisement public key X (not the private key)." */ + uint32_t time_base_unix; /**< @endian le @unit s @doc "Unix epoch for SECP256R1 rotation; unused for static SECP160 (write 0)." */ + uint32_t net_a_time_base; /**< @endian le @unit s @doc "unused in static network A mode; write 0." */ + uint8_t advertisement_key[20]; /**< @doc "Static 20-byte SECP160 EID for network G (generate_eid(eik,0)); zero when unused." */ + uint8_t reserved1[32]; /**< @reserved @doc "must be 0; reserved for future extensions." */ +} __attribute__((packed)); +OD_STATIC_ASSERT(sizeof(struct FindMyConfig) == 128, "FindMyConfig wire size"); + /* ========================================================================== * SECTION 5 -- MESSAGE PAYLOAD STRUCTS (fixed-size opcode payloads) * ========================================================================== diff --git a/opendisplay-bg22.slcp b/opendisplay-bg22.slcp index 302b7d1..301e0cd 100644 --- a/opendisplay-bg22.slcp +++ b/opendisplay-bg22.slcp @@ -21,6 +21,7 @@ readme: source: - path: app.c - path: opendisplay_ble.c +- path: opendisplay_net_a_adv.c - path: opendisplay_pipe.c - path: opendisplay_config_storage.c - path: opendisplay_config_parser.c diff --git a/opendisplay_ble.c b/opendisplay_ble.c index 68518b2..608af53 100644 --- a/opendisplay_ble.c +++ b/opendisplay_ble.c @@ -1,4 +1,5 @@ #include "opendisplay_ble.h" +#include "opendisplay_net_a_adv.h" #include "opendisplay_config_parser.h" #include "opendisplay_display.h" #include "opendisplay_led.h" @@ -13,6 +14,7 @@ #include "em_gpio.h" #include "em_iadc.h" #include "em_system.h" +#include "psa/crypto.h" #include "sl_gpio.h" #include "sl_sleeptimer.h" #include "sl_udelay.h" @@ -144,11 +146,18 @@ static uint8_t fw_patch_from_build_version(void) #define OD_ADV_INTERVAL_BOOST_MAX 48u #define OD_ADV_BOOST_MS 3000u #define OD_MSD_UPDATE_INTERVAL_MS 30000u +/* FMDN: 1600*0.625ms = 1s — keep off the OD identity address and avoid flooding scanners. */ +#define OD_FMDN_ADV_INTERVAL_SLOTS 1600u #define BUTTON_ID_MASK 0x07u #define PRESS_COUNT_MASK 0x0Fu #define PRESS_COUNT_SHIFT 3u #define BUTTON_STATE_SHIFT 7u #define OD_MAX_CONNECTION_MS 300000u +#define OD_FMDN_EID_LEN_256 32u +#define OD_FMDN_EID_LEN_160 20u +#define OD_FMDN_HASHED_FLAGS_LEN 1u +#define OD_FMDN_FRAME_TYPE_NORMAL 0x40u +#define OD_FMDN_FRAME_TYPE_UTP 0x41u /* When system_config.pwr_pin is 0xFF, drive this pin HIGH as display rail enable (PA0). Set to 0xFF to disable. */ #ifndef OD_FALLBACK_DISPLAY_PWR_PIN @@ -186,6 +195,8 @@ static uint8_t connection_requested = 0u; static uint16_t g_od_pipe_char; static uint8_t g_connection = 0xFFu; static uint8_t s_adv_handle = 0xFFu; +static uint8_t s_fmdn_adv_handle = 0xFFu; +static uint8_t s_net_a_adv_handle = 0xFFu; static char s_dev_name[16]; static struct GlobalConfig s_od_global_config; static uint32_t s_last_msd_refresh_ms; @@ -196,6 +207,12 @@ static uint32_t s_connection_open_ms; static bool s_connection_timeout_close_requested; static uint32_t s_last_batt_measure_ms; static uint16_t s_batt_voltage_mv_cache; +static bool s_fmdn_adv_started; +static bool s_fmdn_supported_curve; +static bool s_fmdn_crypto_ready; +static bool s_fmdn_use_legacy; +static uint32_t s_fmdn_last_window = UINT32_MAX; +static uint8_t s_fmdn_last_batt_code = 0xFFu; static GPIO_Port_TypeDef s_od_flash_mosi_port = gpioPortA; static uint8_t s_od_flash_mosi_pin = 0u; @@ -256,6 +273,7 @@ static volatile bool s_button_msd_dirty; static void build_and_apply_adv(uint8_t adv_set, const char *name, bool quick); static void od_boost_advertising(uint32_t now_ms); static void od_apply_advertising_timing(uint8_t adv_handle, uint32_t now_ms); +static void od_fmdn_sync(uint32_t now_ms); #if defined(__GNUC__) extern uint32_t __ResetReasonStart__; @@ -1612,10 +1630,19 @@ void opendisplay_ble_reload_config_from_nvm(void) if (!loadGlobalConfig(&s_od_global_config)) { printf("[OD] config: reload after save failed\r\n"); } + s_fmdn_last_window = UINT32_MAX; + s_fmdn_last_batt_code = 0xFFu; + s_fmdn_supported_curve = true; od_buttons_init_from_config(); opendisplay_display_park_pins(); opendisplay_led_init(); opendisplay_display_boot_apply(); + od_net_a_stop(s_net_a_adv_handle); + od_fmdn_sync(sl_sleeptimer_tick_to_ms(sl_sleeptimer_get_tick_count())); + if (s_od_global_config.loaded && s_od_global_config.has_findmy_config + && od_net_a_config_active(&s_od_global_config.findmy_config)) { + od_net_a_sync(s_net_a_adv_handle, &s_od_global_config.findmy_config); + } } static void od_apply_advertising_timing(uint8_t adv_handle, uint32_t now_ms) @@ -1668,6 +1695,534 @@ static void od_advertising_boost_tick(uint8_t adv_handle, uint32_t now_ms) } } +static bool od_findmy_bytes_nonzero(const uint8_t *p, size_t n) +{ + size_t i; + for (i = 0; i < n; i++) { + if (p[i] != 0u) { + return true; + } + } + return false; +} + +static bool od_findmy_config_active(const struct GlobalConfig *cfg) +{ + if (cfg == NULL || !cfg->loaded || !cfg->has_findmy_config) { + return false; + } + if ((cfg->findmy_config.flags & OD_FINDMY_FLAG_FMDN_ENABLE) == 0u) { + return false; + } + if (cfg->findmy_config.fmdn_curve == OD_FINDMY_CURVE_SECP160R1) { + /* Network G: static 20-byte advertisement key, no time base. */ + return od_findmy_bytes_nonzero(cfg->findmy_config.advertisement_key, + sizeof(cfg->findmy_config.advertisement_key)); + } + if (cfg->findmy_config.fmdn_curve == OD_FINDMY_CURVE_SECP256R1) { + if (cfg->findmy_config.time_base_unix == 0u) { + return false; + } + return od_findmy_bytes_nonzero(cfg->findmy_config.eik, sizeof(cfg->findmy_config.eik)); + } + return false; +} + +static uint32_t od_fmdn_beacon_counter(const struct FindMyConfig *cfg, uint32_t now_ms) +{ + return cfg->time_base_unix + (now_ms / 1000u); +} + +static uint32_t od_fmdn_rotation_window(const struct FindMyConfig *cfg, uint32_t counter) +{ + uint8_t k = cfg->fmdn_k; + if (k >= 32u) { + return 0u; + } + return counter & ~((1u << k) - 1u); +} + +static bool od_fmdn_crypto_init_once(void) +{ + if (s_fmdn_crypto_ready) { + return true; + } + s_fmdn_crypto_ready = (psa_crypto_init() == PSA_SUCCESS); + return s_fmdn_crypto_ready; +} + +/* FHN battery bits 5-6: 00 unsupported, 01 normal, 10 low, 11 critical. */ +static uint8_t od_fmdn_battery_code(void) +{ + uint16_t mv = s_batt_voltage_mv_cache; + uint16_t low_mv = 3500u; + uint16_t crit_mv = 3300u; + + if (mv == 0u) { + return 0u; + } + switch (s_od_global_config.power_option.capacity_estimator) { + case OD_CAPACITY_EST_LIFEPO4: + low_mv = 3200u; + crit_mv = 3000u; + break; + case OD_CAPACITY_EST_SUPERCAP: + low_mv = 3800u; + crit_mv = 3600u; + break; + case OD_CAPACITY_EST_LITHIUM_PRIMARY: + low_mv = 2700u; + crit_mv = 2400u; + break; + case OD_CAPACITY_EST_LI_ION: + case OD_CAPACITY_EST_SEEED_LI_ION: + default: + break; + } + if (mv < crit_mv) { + return 3u; + } + if (mv < low_mv) { + return 2u; + } + return 1u; +} + +/* SECP160R1 order n (21 bytes, big-endian). */ +static const uint8_t OD_SECP160R1_N[21] = { + 0x01u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, + 0x01u, 0xF4u, 0xC8u, 0xF9u, 0x27u, 0xAEu, 0xD3u, 0xCAu, 0x75u, 0x22u, 0x57u +}; + +static int od_be_cmp32(const uint8_t a[32], const uint8_t b[32]) +{ + unsigned i; + for (i = 0u; i < 32u; i++) { + if (a[i] != b[i]) { + return (a[i] < b[i]) ? -1 : 1; + } + } + return 0; +} + +static void od_be_sub32(uint8_t a[32], const uint8_t b[32]) +{ + int i; + int borrow = 0; + for (i = 31; i >= 0; i--) { + int x = (int)a[i] - (int)b[i] - borrow; + if (x < 0) { + x += 256; + borrow = 1; + } else { + borrow = 0; + } + a[i] = (uint8_t)x; + } +} + +static void od_be_shl_n_into(uint8_t out[32], unsigned shift_bits) +{ + unsigned byte_shift = shift_bits / 8u; + unsigned bit_shift = shift_bits % 8u; + size_t dest = 32u - sizeof(OD_SECP160R1_N) - byte_shift; + unsigned carry = 0u; + size_t i; + + memset(out, 0, 32u); + if (bit_shift == 0u) { + memcpy(&out[dest], OD_SECP160R1_N, sizeof(OD_SECP160R1_N)); + return; + } + for (i = sizeof(OD_SECP160R1_N); i > 0u; i--) { + unsigned val = ((unsigned)OD_SECP160R1_N[i - 1u] << bit_shift) | carry; + out[dest + i - 1u] = (uint8_t)val; + carry = val >> 8; + } + if (dest > 0u && carry != 0u) { + out[dest - 1u] = (uint8_t)carry; + } +} + +/* r' (AES output) -> 20-byte r for SHA256 (mod n, MSB-truncated to 160 bits). */ +static void od_fmdn_secp160_r_bytes(const uint8_t r_dash[32], uint8_t r20[20]) +{ + uint8_t v[32]; + uint8_t t[32]; + unsigned shift; + + memcpy(v, r_dash, 32u); + for (shift = 88u;; shift--) { + od_be_shl_n_into(t, shift); + while (od_be_cmp32(v, t) >= 0) { + od_be_sub32(v, t); + } + if (shift == 0u) { + break; + } + } + memcpy(r20, &v[12], 20u); +} + +/* Hashed flags for static SECP160 (counter 0). Falls back to 0x00 without EIK. */ +static uint8_t od_fmdn_hashed_flags_secp160(const struct FindMyConfig *cfg, uint8_t raw_flags) +{ + uint8_t block[32]; + uint8_t r_dash[32]; + uint8_t r20[20]; + uint8_t digest[32]; + size_t out_len = 0u; + size_t digest_len = 0u; + psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; + mbedtls_svc_key_id_t key_id = MBEDTLS_SVC_KEY_ID_INIT; + psa_status_t ps; + uint8_t k; + + if (!od_findmy_bytes_nonzero(cfg->eik, sizeof(cfg->eik))) { + return 0u; + } + if (!od_fmdn_crypto_init_once()) { + return 0u; + } + + k = cfg->fmdn_k; + if (k == 0u || k >= 32u) { + k = 10u; + } + + memset(block, 0, sizeof(block)); + memset(block, 0xFF, 11u); + block[11] = k; + block[27] = k; + /* window / timestamp = 0 for static GFT EID */ + + psa_set_key_type(&attr, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attr, 256); + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_ENCRYPT); + psa_set_key_algorithm(&attr, PSA_ALG_ECB_NO_PADDING); + ps = psa_import_key(&attr, cfg->eik, sizeof(cfg->eik), &key_id); + psa_reset_key_attributes(&attr); + if (ps != PSA_SUCCESS) { + return 0u; + } + ps = psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, block, sizeof(block), + r_dash, sizeof(r_dash), &out_len); + (void)psa_destroy_key(key_id); + if (ps != PSA_SUCCESS || out_len != sizeof(r_dash)) { + return 0u; + } + + od_fmdn_secp160_r_bytes(r_dash, r20); + if (psa_hash_compute(PSA_ALG_SHA_256, r20, sizeof(r20), digest, sizeof(digest), &digest_len) != PSA_SUCCESS + || digest_len != sizeof(digest)) { + return 0u; + } + return (uint8_t)(raw_flags ^ digest[sizeof(digest) - 1u]); +} + +static bool od_fmdn_compute_eid256(const struct FindMyConfig *cfg, + uint32_t window_counter, + uint8_t eid_out[OD_FMDN_EID_LEN_256], + uint8_t *hashed_flags_out) +{ + uint8_t block[32]; + uint8_t scalar_bytes[32]; + uint8_t pubkey[65]; + uint8_t digest[32]; + size_t out_len = 0u; + size_t pubkey_len = 0u; + size_t digest_len = 0u; + psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; + mbedtls_svc_key_id_t key_id = MBEDTLS_SVC_KEY_ID_INIT; + psa_status_t ps; + bool ok = false; + + memset(block, 0, sizeof(block)); + memset(scalar_bytes, 0, sizeof(scalar_bytes)); + memset(pubkey, 0, sizeof(pubkey)); + memset(digest, 0, sizeof(digest)); + + if (!od_fmdn_crypto_init_once()) { + return false; + } + + memset(block, 0xFF, 11u); + block[11] = cfg->fmdn_k; + block[12] = (uint8_t)(window_counter >> 24); + block[13] = (uint8_t)(window_counter >> 16); + block[14] = (uint8_t)(window_counter >> 8); + block[15] = (uint8_t)window_counter; + block[27] = cfg->fmdn_k; + block[28] = (uint8_t)(window_counter >> 24); + block[29] = (uint8_t)(window_counter >> 16); + block[30] = (uint8_t)(window_counter >> 8); + block[31] = (uint8_t)window_counter; + + psa_set_key_type(&attr, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attr, 256); + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_ENCRYPT); + psa_set_key_algorithm(&attr, PSA_ALG_ECB_NO_PADDING); + ps = psa_import_key(&attr, cfg->eik, sizeof(cfg->eik), &key_id); + psa_reset_key_attributes(&attr); + if (ps != PSA_SUCCESS) { + return false; + } + ps = psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, block, sizeof(block), scalar_bytes, sizeof(scalar_bytes), &out_len); + (void)psa_destroy_key(key_id); + key_id = MBEDTLS_SVC_KEY_ID_INIT; + if (ps != PSA_SUCCESS || out_len != sizeof(scalar_bytes)) { + return false; + } + + psa_set_key_type(&attr, PSA_KEY_TYPE_ECC_KEY_PAIR(PSA_ECC_FAMILY_SECP_R1)); + psa_set_key_bits(&attr, 256); + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_EXPORT); + psa_set_key_algorithm(&attr, PSA_ALG_ECDH); + ps = psa_import_key(&attr, scalar_bytes, sizeof(scalar_bytes), &key_id); + psa_reset_key_attributes(&attr); + if (ps != PSA_SUCCESS) { + return false; + } + ps = psa_export_public_key(key_id, pubkey, sizeof(pubkey), &pubkey_len); + (void)psa_destroy_key(key_id); + key_id = MBEDTLS_SVC_KEY_ID_INIT; + if (ps != PSA_SUCCESS || pubkey_len != 65u || pubkey[0] != 0x04u) { + return false; + } + + ps = psa_hash_compute(PSA_ALG_SHA_256, scalar_bytes, sizeof(scalar_bytes), digest, sizeof(digest), &digest_len); + if (ps != PSA_SUCCESS || digest_len != sizeof(digest)) { + return false; + } + + memcpy(eid_out, &pubkey[1], OD_FMDN_EID_LEN_256); + *hashed_flags_out = digest[sizeof(digest) - 1u]; + ok = true; + return ok; +} + +/* Static random addr from key[0..5]|0xC0 (network A legacy layout). */ +static bool od_fmdn_apply_address_from_key(uint8_t adv_set, const uint8_t key[6]) +{ + bd_addr addr = { { 0 } }; + bd_addr applied = { { 0 } }; + sl_status_t sc; + + addr.addr[5] = (uint8_t)(key[0] | 0xC0u); + addr.addr[4] = key[1]; + addr.addr[3] = key[2]; + addr.addr[2] = key[3]; + addr.addr[1] = key[4]; + addr.addr[0] = key[5]; + + sc = sl_bt_advertiser_set_random_address(adv_set, + sl_bt_gap_static_address, + addr, + &applied); + if (sc != SL_STATUS_OK) { + printf("[OD][FMDN] set_random_address sc=0x%04lX\r\n", (unsigned long)sc); + return false; + } + printf("[OD][FMDN] addr %02X:%02X:%02X:%02X:%02X:%02X (advertisement key)\r\n", + applied.addr[5], applied.addr[4], applied.addr[3], + applied.addr[2], applied.addr[1], applied.addr[0]); + return true; +} + +static bool od_fmdn_build_and_apply_adv(uint8_t adv_set, + const struct FindMyConfig *cfg, + uint32_t now_ms, + uint32_t *window_out) +{ + uint8_t adv[48]; + uint8_t eid[OD_FMDN_EID_LEN_256]; + uint32_t counter; + uint32_t window_counter; + uint8_t hashed_flags = 0u; + size_t ai = 0u; + sl_status_t sc; + + s_fmdn_supported_curve = true; + s_fmdn_use_legacy = false; + memset(adv, 0, sizeof(adv)); + memset(eid, 0, sizeof(eid)); + + if (cfg->fmdn_curve == OD_FINDMY_CURVE_SECP160R1) { + /* Static GFT-compatible frame + FHN hashed flags (battery + UTP). */ + uint8_t batt = od_fmdn_battery_code(); + uint8_t raw_flags = (uint8_t)((batt << 5) | 0x80u); /* bit7=UTP matches frame 0x41 */ + + window_counter = 0u; + memcpy(eid, cfg->advertisement_key, OD_FMDN_EID_LEN_160); + + if (!od_fmdn_apply_address_from_key(adv_set, cfg->advertisement_key)) { + return false; + } + + adv[ai++] = 2u; + adv[ai++] = 0x01u; + adv[ai++] = 0x06u; + adv[ai++] = 0x19u; /* 25 = UUID(2)+type(1)+EID(20)+flags(1) + type byte */ + adv[ai++] = 0x16u; + adv[ai++] = 0xAAu; + adv[ai++] = 0xFEu; + adv[ai++] = OD_FMDN_FRAME_TYPE_UTP; + memcpy(&adv[ai], eid, OD_FMDN_EID_LEN_160); + ai += OD_FMDN_EID_LEN_160; + adv[ai++] = od_fmdn_hashed_flags_secp160(cfg, raw_flags); + s_fmdn_last_batt_code = batt; + + sc = sl_bt_advertiser_set_timing(adv_set, + OD_FMDN_ADV_INTERVAL_SLOTS, + OD_FMDN_ADV_INTERVAL_SLOTS, + 0, + 0); + if (sc != SL_STATUS_OK) { + printf("[OD][FMDN] set_timing sc=0x%04lX\r\n", (unsigned long)sc); + return false; + } + sc = sl_bt_legacy_advertiser_set_data(adv_set, + sl_bt_advertiser_advertising_data_packet, + (uint8_t)ai, + adv); + if (sc != SL_STATUS_OK) { + printf("[OD][FMDN] legacy_set_data sc=0x%04lX len=%u\r\n", + (unsigned long)sc, + (unsigned)ai); + return false; + } + s_fmdn_use_legacy = true; + *window_out = window_counter; + return true; + } + + if (cfg->fmdn_curve != OD_FINDMY_CURVE_SECP256R1) { + s_fmdn_supported_curve = false; + return false; + } + + counter = od_fmdn_beacon_counter(cfg, now_ms); + window_counter = od_fmdn_rotation_window(cfg, counter); + if (!od_fmdn_compute_eid256(cfg, window_counter, eid, &hashed_flags)) { + return false; + } + if (!od_fmdn_apply_address_from_key(adv_set, cfg->eik)) { + return false; + } + + adv[ai++] = 2u; + adv[ai++] = 0x01u; + adv[ai++] = 0x06u; + adv[ai++] = 0x25u; + adv[ai++] = 0x16u; + adv[ai++] = 0xAAu; + adv[ai++] = 0xFEu; + adv[ai++] = OD_FMDN_FRAME_TYPE_NORMAL; + memcpy(&adv[ai], eid, OD_FMDN_EID_LEN_256); + ai += OD_FMDN_EID_LEN_256; + adv[ai++] = hashed_flags; + + sc = sl_bt_extended_advertiser_set_phy(adv_set, sl_bt_gap_phy_1m, sl_bt_gap_phy_1m); + if (sc != SL_STATUS_OK) { + printf("[OD][FMDN] set_phy sc=0x%04lX\r\n", (unsigned long)sc); + return false; + } + sc = sl_bt_extended_advertiser_set_data(adv_set, ai, adv); + if (sc != SL_STATUS_OK) { + printf("[OD][FMDN] set_data sc=0x%04lX len=%u\r\n", (unsigned long)sc, (unsigned)ai); + return false; + } + sc = sl_bt_advertiser_set_timing(adv_set, + OD_FMDN_ADV_INTERVAL_SLOTS, + OD_FMDN_ADV_INTERVAL_SLOTS, + 0, + 0); + if (sc != SL_STATUS_OK) { + printf("[OD][FMDN] set_timing sc=0x%04lX\r\n", (unsigned long)sc); + return false; + } + *window_out = window_counter; + return true; +} + +static void od_fmdn_stop(void) +{ + if (s_fmdn_adv_handle == 0xFFu || !s_fmdn_adv_started) { + return; + } + if (sl_bt_advertiser_stop(s_fmdn_adv_handle) == SL_STATUS_OK) { + s_fmdn_adv_started = false; + } +} + +static void od_fmdn_sync(uint32_t now_ms) +{ + const struct FindMyConfig *cfg = &s_od_global_config.findmy_config; + uint32_t current_window; + uint32_t window_counter; + sl_status_t sc; + + if (s_fmdn_adv_handle == 0xFFu) { + return; + } + if (!od_findmy_config_active(&s_od_global_config)) { + od_fmdn_stop(); + s_fmdn_last_window = UINT32_MAX; + s_fmdn_last_batt_code = 0xFFu; + return; + } + + if (cfg->fmdn_curve == OD_FINDMY_CURVE_SECP160R1) { + current_window = 0u; + } else { + current_window = od_fmdn_rotation_window(cfg, od_fmdn_beacon_counter(cfg, now_ms)); + } + /* Retry after failure; refresh when rotation window or battery band changes. */ + if (current_window == s_fmdn_last_window + && s_fmdn_adv_started + && (cfg->fmdn_curve != OD_FINDMY_CURVE_SECP160R1 + || od_fmdn_battery_code() == s_fmdn_last_batt_code)) { + return; + } + /* Address/data changes require the set to be stopped first. */ + od_fmdn_stop(); + if (!od_fmdn_build_and_apply_adv(s_fmdn_adv_handle, + cfg, + now_ms, + &window_counter)) { + if (!s_fmdn_supported_curve) { + printf("[OD][FMDN] curve=%u unsupported on current advertiser path\r\n", (unsigned)cfg->fmdn_curve); + s_fmdn_last_window = current_window; /* don't spin on unsupported curve */ + } else { + printf("[OD][FMDN] payload generation failed — will retry\r\n"); + } + return; + } + if (s_fmdn_use_legacy) { + sc = sl_bt_legacy_advertiser_start(s_fmdn_adv_handle, + sl_bt_legacy_advertiser_non_connectable); + } else { + sc = sl_bt_extended_advertiser_start(s_fmdn_adv_handle, + sl_bt_extended_advertiser_non_connectable, + 0); + } + if (sc != SL_STATUS_OK) { + printf("[OD][FMDN] advertiser_start sc=0x%04lX legacy=%u\r\n", + (unsigned long)sc, + (unsigned)s_fmdn_use_legacy); + s_fmdn_adv_started = false; + return; + } + s_fmdn_adv_started = true; + s_fmdn_last_window = window_counter; + printf("[OD][FMDN] advertising active curve=%u k=%u window=%lu legacy=%u interval~%ums\r\n", + (unsigned)cfg->fmdn_curve, + (unsigned)cfg->fmdn_k, + (unsigned long)window_counter, + (unsigned)s_fmdn_use_legacy, + (unsigned)((OD_FMDN_ADV_INTERVAL_SLOTS * 5u) / 8u)); +} + static void chip_id_hex6(char out[7]) { uint64_t u = SYSTEM_GetUnique(); @@ -1869,7 +2424,9 @@ static void build_and_apply_adv(uint8_t adv_set, const char *name, bool quick) app_assert_status(sc); } -void opendisplay_ble_on_boot(uint8_t advertising_set_handle) +void opendisplay_ble_on_boot(uint8_t advertising_set_handle, + uint8_t fmdn_advertising_set_handle, + uint8_t net_a_advertising_set_handle) { char hex[7]; sl_status_t sc; @@ -1890,7 +2447,17 @@ void opendisplay_ble_on_boot(uint8_t advertising_set_handle) od_init_aux_peripherals(); s_adv_handle = advertising_set_handle; - printf("[OD] BLE boot: adv_set=%u\r\n", (unsigned)advertising_set_handle); + s_fmdn_adv_handle = fmdn_advertising_set_handle; + s_net_a_adv_handle = net_a_advertising_set_handle; + s_fmdn_adv_started = false; + s_fmdn_supported_curve = true; + s_fmdn_use_legacy = false; + s_fmdn_last_window = UINT32_MAX; + s_fmdn_last_batt_code = 0xFFu; + printf("[OD] BLE boot: adv_set=%u fmdn_adv_set=%u net_a_adv_set=%u\r\n", + (unsigned)advertising_set_handle, + (unsigned)fmdn_advertising_set_handle, + (unsigned)net_a_advertising_set_handle); chip_id_hex6(hex); snprintf(s_dev_name, sizeof(s_dev_name), "%s%s", OD_NAME_PREFIX, hex); @@ -1932,6 +2499,11 @@ void opendisplay_ble_on_boot(uint8_t advertising_set_handle) } app_assert_status(sc); printf("[OD] advertising started (~1 s interval while idle)\r\n"); + od_fmdn_sync(sl_sleeptimer_tick_to_ms(sl_sleeptimer_get_tick_count())); + if (s_od_global_config.loaded && s_od_global_config.has_findmy_config + && od_net_a_config_active(&s_od_global_config.findmy_config)) { + od_net_a_sync(s_net_a_adv_handle, &s_od_global_config.findmy_config); + } } void opendisplay_ble_restart_advertising(uint8_t advertising_set_handle) @@ -1974,6 +2546,7 @@ void opendisplay_ble_on_event(sl_bt_msg_t *evt) opendisplay_ble_restart_advertising(s_adv_handle); printf("[OD] advertising resumed after disconnect\r\n"); } + od_fmdn_sync(sl_sleeptimer_tick_to_ms(sl_sleeptimer_get_tick_count())); break; default: (void)g_od_pipe_char; @@ -2008,6 +2581,10 @@ void opendisplay_ble_process(void) build_and_apply_adv(s_adv_handle, s_dev_name, false); } } + if (s_od_global_config.loaded && s_od_global_config.has_findmy_config) { + od_fmdn_sync(now_ms); + od_net_a_sync(s_net_a_adv_handle, &s_od_global_config.findmy_config); + } if (g_connection != 0xFFu && !s_connection_timeout_close_requested && (now_ms - s_connection_open_ms) >= OD_MAX_CONNECTION_MS) { diff --git a/opendisplay_ble.h b/opendisplay_ble.h index 7d3bfba..e26495b 100644 --- a/opendisplay_ble.h +++ b/opendisplay_ble.h @@ -11,7 +11,9 @@ extern "C" { struct GlobalConfig; -void opendisplay_ble_on_boot(uint8_t advertising_set_handle); +void opendisplay_ble_on_boot(uint8_t advertising_set_handle, + uint8_t fmdn_advertising_set_handle, + uint8_t net_a_advertising_set_handle); const struct GlobalConfig *opendisplay_get_global_config(void); diff --git a/opendisplay_config_parser.c b/opendisplay_config_parser.c index 43d7ea2..8705ef5 100644 --- a/opendisplay_config_parser.c +++ b/opendisplay_config_parser.c @@ -478,6 +478,33 @@ bool parseConfigBytes(uint8_t* configData, uint32_t configLen, struct GlobalConf } break; + case CONFIG_PKT_FINDMY: // findmy_config (0x2D) + if (offset > configLen) { + printf("Offset overflow before findmy_config\r\n"); + globalConfig->loaded = false; + return false; + } + if (offset + sizeof(struct FindMyConfig) <= configLen - 2) { + memcpy(&globalConfig->findmy_config, &configData[offset], sizeof(struct FindMyConfig)); + offset += sizeof(struct FindMyConfig); + if (offset > configLen) { + printf("Offset overflow after findmy_config\r\n"); + globalConfig->loaded = false; + return false; + } + globalConfig->has_findmy_config = true; + printf("FindMy: flags=0x%02X, curve=%u, k=%u\r\n", + globalConfig->findmy_config.flags, + globalConfig->findmy_config.fmdn_curve, + globalConfig->findmy_config.fmdn_k); + } else { + printf("findmy_config: need %zu, have %u\r\n", + sizeof(struct FindMyConfig), (unsigned)(configLen - 2 - offset)); + globalConfig->loaded = false; + return false; + } + break; + default: printf("Unknown pkt 0x%02X @%u\r\n", packetId, (unsigned)(offset - 2)); offset = configLen - 2; // Skip to CRC diff --git a/opendisplay_constants.h b/opendisplay_constants.h index 5e4faa2..f0252b3 100644 --- a/opendisplay_constants.h +++ b/opendisplay_constants.h @@ -22,6 +22,7 @@ #define CONFIG_PKT_NFC 0x2A #define CONFIG_PKT_FLASH 0x2B #define CONFIG_PKT_DATA_EXTENDED 0x2C +#define CONFIG_PKT_FINDMY 0x2D /* Wire sizes of config packets Silabs does not consume itself (touch/buzzer are * handled by the main MCU, data_extended is host-only). They must still be @@ -29,6 +30,7 @@ #define CONFIG_PKT_TOUCH_SIZE 32 #define CONFIG_PKT_BUZZER_SIZE 32 #define CONFIG_PKT_DATA_EXTENDED_SIZE 288 +#define CONFIG_PKT_FINDMY_SIZE 128 #define GPIO_PIN_UNUSED 0xFF diff --git a/opendisplay_net_a_adv.c b/opendisplay_net_a_adv.c new file mode 100644 index 0000000..5ccaf6a --- /dev/null +++ b/opendisplay_net_a_adv.c @@ -0,0 +1,162 @@ +#include "opendisplay_net_a_adv.h" + +#include "sl_bt_api.h" +#include "sl_sleeptimer.h" +#include +#include + +static bool s_net_a_adv_started; +static uint32_t s_net_a_last_fail_log_ms; + +static bool od_net_a_bytes_nonzero(const uint8_t *p, size_t n) +{ + size_t i; + + for (i = 0u; i < n; i++) { + if (p[i] != 0u) { + return true; + } + } + return false; +} + +bool od_net_a_config_active(const struct FindMyConfig *cfg) +{ + if (cfg == NULL) { + return false; + } + if ((cfg->flags & OD_FINDMY_FLAG_NET_A_ENABLE) == 0u) { + return false; + } + return od_net_a_bytes_nonzero(cfg->net_a_adv_key, OD_NET_A_ADV_KEY_LEN); +} + +static bool od_net_a_apply_address(uint8_t adv_set, const uint8_t key[OD_NET_A_ADV_KEY_LEN]) +{ + bd_addr addr = { { 0 } }; + bd_addr applied = { { 0 } }; + sl_status_t sc; + + addr.addr[5] = (uint8_t)(key[0] | 0xC0u); + addr.addr[4] = key[1]; + addr.addr[3] = key[2]; + addr.addr[2] = key[3]; + addr.addr[1] = key[4]; + addr.addr[0] = key[5]; + + sc = sl_bt_advertiser_set_random_address(adv_set, + sl_bt_gap_static_address, + addr, + &applied); + if (sc != SL_STATUS_OK) { + printf("[OD][NetA] set_random_address sc=0x%04lX\r\n", (unsigned long)sc); + return false; + } + printf("[OD][NetA] addr %02X:%02X:%02X:%02X:%02X:%02X\r\n", + applied.addr[5], applied.addr[4], applied.addr[3], + applied.addr[2], applied.addr[1], applied.addr[0]); + return true; +} + +static size_t od_net_a_build_legacy_adv(const uint8_t key[OD_NET_A_ADV_KEY_LEN], + uint8_t *adv, + size_t adv_cap) +{ + if (adv_cap < OD_NET_A_LEGACY_ADV_LEN) { + return 0u; + } + + adv[0] = 0x1Eu; + adv[1] = 0xFFu; + adv[2] = 0x4Cu; + adv[3] = 0x00u; + adv[4] = 0x12u; + adv[5] = 0x19u; + adv[6] = 0x00u; + memcpy(&adv[7], &key[6], 22u); + adv[29] = (uint8_t)(key[0] >> 6); + adv[30] = 0x00u; + return OD_NET_A_LEGACY_ADV_LEN; +} + +void od_net_a_stop(uint8_t adv_set) +{ + if (adv_set == 0xFFu) { + return; + } + if (s_net_a_adv_started) { + (void)sl_bt_advertiser_stop(adv_set); + s_net_a_adv_started = false; + } +} + +static void od_net_a_log_fail(const char *step, sl_status_t sc) +{ + uint32_t now_ms = sl_sleeptimer_tick_to_ms(sl_sleeptimer_get_tick_count()); + + if ((now_ms - s_net_a_last_fail_log_ms) < 5000u) { + return; + } + s_net_a_last_fail_log_ms = now_ms; + printf("[OD][NetA] %s sc=0x%04lX (retrying)\r\n", step, (unsigned long)sc); +} + +void od_net_a_sync(uint8_t adv_set, const struct FindMyConfig *cfg) +{ + uint8_t adv[OD_NET_A_LEGACY_ADV_LEN]; + size_t adv_len; + sl_status_t sc; + + if (adv_set == 0xFFu) { + return; + } + if (!od_net_a_config_active(cfg)) { + od_net_a_stop(adv_set); + return; + } + if (s_net_a_adv_started) { + return; + } + + od_net_a_stop(adv_set); + + if (!od_net_a_apply_address(adv_set, cfg->net_a_adv_key)) { + return; + } + + adv_len = od_net_a_build_legacy_adv(cfg->net_a_adv_key, adv, sizeof(adv)); + if (adv_len == 0u) { + return; + } + + sc = sl_bt_advertiser_set_timing(adv_set, + OD_NET_A_ADV_INTERVAL_SLOTS, + OD_NET_A_ADV_INTERVAL_SLOTS, + 0, + 0); + if (sc != SL_STATUS_OK) { + od_net_a_log_fail("set_timing", sc); + return; + } + + sc = sl_bt_legacy_advertiser_set_data(adv_set, + sl_bt_advertiser_advertising_data_packet, + (uint8_t)adv_len, + adv); + if (sc != SL_STATUS_OK) { + od_net_a_log_fail("set_data", sc); + return; + } + + sc = sl_bt_legacy_advertiser_start(adv_set, sl_bt_legacy_advertiser_non_connectable); + if (sc != SL_STATUS_OK) { + od_net_a_log_fail("advertiser_start", sc); + s_net_a_adv_started = false; + return; + } + + s_net_a_adv_started = true; + s_net_a_last_fail_log_ms = 0u; + printf("[OD][NetA] advertising (static legacy, %u byte payload)\r\n", + (unsigned)adv_len); +} diff --git a/opendisplay_net_a_adv.h b/opendisplay_net_a_adv.h new file mode 100644 index 0000000..aaea772 --- /dev/null +++ b/opendisplay_net_a_adv.h @@ -0,0 +1,19 @@ +#ifndef OPENDISPLAY_NET_A_ADV_H +#define OPENDISPLAY_NET_A_ADV_H + +#include +#include + +#include "opendisplay_structs.h" + +#define OD_NET_A_ADV_KEY_LEN 28u +#define OD_NET_A_LEGACY_ADV_LEN 31u +#define OD_NET_A_ADV_INTERVAL_SLOTS 1600u + +bool od_net_a_config_active(const struct FindMyConfig *cfg); + +void od_net_a_sync(uint8_t adv_set, const struct FindMyConfig *cfg); + +void od_net_a_stop(uint8_t adv_set); + +#endif diff --git a/opendisplay_runtime.h b/opendisplay_runtime.h index f2e7d4a..2814dd7 100644 --- a/opendisplay_runtime.h +++ b/opendisplay_runtime.h @@ -41,6 +41,8 @@ struct GlobalConfig { uint8_t nfc_config_count; struct FlashConfig flash_configs[2]; uint8_t flash_config_count; + struct FindMyConfig findmy_config; + bool has_findmy_config; uint8_t version; uint8_t minor_version; bool loaded; diff --git a/scripts/ble_ota.py b/scripts/ble_ota.py new file mode 100755 index 0000000..d92c866 --- /dev/null +++ b/scripts/ble_ota.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Targeted Silicon Labs BLE OTA for OpenDisplay BG22. + +Finds a device by GAP name (substring), sends CMD_ENTER_DFU (0x0051), then +flashes a .gbl with silabs-ble-ota (AppLoader). + + pip install silabs-ble-ota bleak cryptography + +Example: + ./scripts/ble_ota.py --name ODDEE7 --gbl artifacts/opendisplay-bg22.gbl +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +import time +from pathlib import Path + +CHAR_UUID = "00002446-0000-1000-8000-00805f9b34fb" +CMD_ENTER_DFU = bytes([0x00, 0x51]) + + +def _die(msg: str, code: int = 1) -> None: + print(f"ERROR: {msg}", file=sys.stderr) + raise SystemExit(code) + + +def _require_deps() -> None: + missing = [] + try: + import bleak # noqa: F401 + except ImportError: + missing.append("bleak") + try: + import silabs_ble_ota # noqa: F401 + except ImportError: + missing.append("silabs-ble-ota") + if missing: + root = Path(__file__).resolve().parents[1] + _die( + "missing packages: " + + ", ".join(missing) + + "\n python3 -m venv " + + str(root / ".venv-ble-ota") + + "\n " + + str(root / ".venv-ble-ota/bin/pip") + + " install -r " + + str(root / "scripts/requirements-ble-ota.txt") + ) + + +def _normalize_name(s: str) -> str: + return "".join(ch for ch in s.strip().upper() if ch.isalnum()) + + +async def _scan_named(needle: str, timeout: float) -> list[tuple[str, str, object]]: + """Return (name, address, BLEDevice) matches for needle (substring, case-insensitive).""" + from bleak import BleakScanner + + want = _normalize_name(needle) + if not want: + _die("empty --name") + + print(f"==> Scanning BLE up to {timeout:.0f}s for name containing '{needle}' …") + devices = await BleakScanner.discover(timeout=timeout, return_adv=True) + matches: list[tuple[str, str, object]] = [] + for _addr, (dev, adv) in devices.items(): + name = dev.name or adv.local_name or "" + if not name: + continue + if want in _normalize_name(name): + matches.append((name, dev.address, dev)) + return matches + + +async def _pick_device(needle: str, timeout: float): + matches = await _scan_named(needle, timeout) + if not matches: + _die(f"no advertising device matched '{needle}'") + if len(matches) > 1: + print("Multiple matches:", file=sys.stderr) + for name, addr, _ in matches: + print(f" {name} ({addr})", file=sys.stderr) + _die("refine --name so it matches exactly one device") + name, addr, dev = matches[0] + print(f"==> Target: {name} @ {addr}") + return name, addr, dev + + +async def _find_by_address(address: str, timeout: float): + """Return a fresh BLEDevice for ``address``, or None if not seen.""" + from bleak import BleakScanner + + try: + found = await BleakScanner.find_device_by_address(address, timeout=timeout) + if found is not None: + return found + except Exception as ex: # noqa: BLE001 - fall through to discover + print(f"==> find_device_by_address: {ex}") + + # Fallback: one-shot discover (some bleak/BlueZ builds are flaky on finder) + remaining = max(1.0, min(timeout, 5.0)) + devices = await BleakScanner.discover(timeout=remaining, return_adv=True) + for addr, (dev, _adv) in devices.items(): + if addr.lower() == address.lower(): + return dev + return None + + +async def _wait_address(address: str, timeout: float): + """Re-discover BLEDevice by address after AppLoader reboot.""" + deadline = time.monotonic() + timeout + print(f"==> Waiting for AppLoader at {address} (up to {timeout:.0f}s) …") + while time.monotonic() < deadline: + remaining = max(1.0, min(8.0, deadline - time.monotonic())) + dev = await _find_by_address(address, remaining) + if dev is not None: + print(f"==> Seen again: {dev.name or '(no name)'} @ {dev.address}") + return dev + await asyncio.sleep(0.25) + _die(f"device {address} did not reappear (AppLoader?) within {timeout:.0f}s") + + +def _is_transient_ota_connect_error(exc: BaseException) -> bool: + """True when silabs-ble-ota never got a usable AppLoader link (safe to retry).""" + msg = str(exc).lower() + # perform_silabs_ota wraps connect failures as "Could not connect to AppLoader: …". + # Mid-transfer errors must not be retried — a successful connect arms reboot-on-drop. + if "could not connect" in msg: + return True + needles = ("disappeared", "not found", "device disappeared") + return any(n in msg for n in needles) and "silabs ota failed" not in msg + + +async def _authenticate(client, master_key: bytes, timeout: float = 5.0) -> object: + """OpenDisplay 0x0050 handshake (matches Firmware/tools/od-device-cli.py).""" + tools = Path(__file__).resolve().parents[2] / "Firmware" / "tools" + if str(tools) not in sys.path: + sys.path.insert(0, str(tools)) + try: + from ble_crypto import ( # type: ignore + BleSession, + compute_challenge_response, + compute_server_response, + derive_session_id, + derive_session_key, + ) + except ImportError: + _die("auth requested but Firmware/tools/ble_crypto.py is unavailable") + + import os as _os + + box: dict[str, bytes] = {} + ev = asyncio.Event() + + def on_notify(_handle, data: bytearray) -> None: + box["data"] = bytes(data) + ev.set() + + await client.start_notify(CHAR_UUID, on_notify) + + async def exchange(payload: bytes) -> bytes: + ev.clear() + await client.write_gatt_char(CHAR_UUID, payload, response=False) + await asyncio.wait_for(ev.wait(), timeout=timeout) + return box["data"] + + session = BleSession(master_key=master_key) + resp1 = await exchange(bytes([0x00, 0x50, 0x00])) + status1 = resp1[2] if len(resp1) > 2 else 0xFF + if status1 == 0x03: + raise RuntimeError("device does not have encryption enabled (omit --key)") + if status1 == 0x04: + raise RuntimeError("authentication rate-limited by device") + if status1 != 0x00 or len(resp1) < 23: + raise RuntimeError(f"auth challenge failed (status 0x{status1:02X})") + server_nonce = resp1[3:19] + device_id = resp1[19:23] + client_nonce = _os.urandom(16) + proof = compute_challenge_response(master_key, server_nonce, client_nonce, device_id) + resp2 = await exchange(bytes([0x00, 0x50]) + client_nonce + proof) + status2 = resp2[2] if len(resp2) > 2 else 0xFF + if status2 == 0x01: + raise RuntimeError("authentication failed (wrong key)") + if status2 != 0x00 or len(resp2) < 19: + raise RuntimeError(f"auth response failed (status 0x{status2:02X})") + server_response = resp2[3:19] + session.session_key = derive_session_key(master_key, client_nonce, server_nonce, device_id) + session.session_id = derive_session_id(session.session_key, client_nonce, server_nonce) + expected = compute_server_response(session.session_key, server_nonce, client_nonce, device_id) + if expected != server_response: + raise RuntimeError("mutual authentication failed") + session.authenticated = True + session.counter = 0 + print("==> Authenticated") + return session + + +async def _enter_dfu(dev, master_key: bytes | None) -> str: + from bleak import BleakClient + + address = dev.address + print("==> Connecting (application) …") + async with BleakClient(dev, timeout=20.0) as client: + if not client.is_connected: + _die("failed to connect to application") + session = None + if master_key is not None: + session = await _authenticate(client, master_key) + if session is not None: + wire = session.encrypt(0x00, 0x51, b"") + else: + wire = CMD_ENTER_DFU + print("==> Sending CMD_ENTER_DFU (0x0051)") + try: + await client.write_gatt_char(CHAR_UUID, wire, response=False) + except Exception as ex: + # Device may reset immediately after scheduling DFU. + print(f"==> Write ended ({ex}); assuming DFU reset") + # Let the ATT write / DFU schedule settle before we drop the link. + await asyncio.sleep(0.5) + # App firmware only enters the bootloader after the BLE link fully closes. + print("==> Waiting for app disconnect / AppLoader boot …") + await asyncio.sleep(1.5) + return address + + +async def _flash_address(gbl: bytes, address: str, *, fast: bool, attempts: int = 5) -> None: + """Flash AppLoader at ``address``, refreshing the BlueZ device between tries. + + ``silabs-ble-ota`` sleeps several seconds before the first connect. On direct + BlueZ that often ages the scanned ``BLEDevice`` out of D-Bus ("device + disappeared"). Re-scan immediately before each attempt and shrink the + library's pre-connect delay once AppLoader has already been seen. + """ + from silabs_ble_ota import SilabsOTAError, perform_silabs_ota + import silabs_ble_ota.ota as silabs_ota_mod + + # We only call perform_silabs_ota after AppLoader is advertising; the stock + # 6s boot delay mostly creates a BlueZ disappearance race on Linux. + if hasattr(silabs_ota_mod, "APPLOADER_BOOT_DELAY"): + silabs_ota_mod.APPLOADER_BOOT_DELAY = 0.75 + + def on_progress(pct: float) -> None: + print(f"\r==> OTA {pct:5.1f}%", end="", flush=True) + + def on_log(msg: str) -> None: + print(f"\n==> {msg}") + + print(f"==> Flashing {len(gbl)} bytes via silabs-ble-ota (fast={fast})") + last_err: BaseException | None = None + for attempt in range(1, attempts + 1): + print(f"\n==> AppLoader connect attempt {attempt}/{attempts} …") + ble_device = await _find_by_address(address, timeout=12.0) + if ble_device is None: + last_err = RuntimeError(f"AppLoader at {address} not advertising") + print(f"==> {last_err}; rescanning …") + await asyncio.sleep(0.5) + continue + print(f"==> Using {ble_device.name or '(no name)'} @ {ble_device.address}") + try: + await perform_silabs_ota( + gbl, + ble_device, + on_progress=on_progress, + on_log=on_log, + fast=fast, + ) + print("\n==> OTA complete") + return + except SilabsOTAError as ex: + print() + last_err = ex + # Only retry pure connect failures. A mid-transfer failure means the + # AppLoader may already have armed reboot-on-disconnect. + if attempt < attempts and _is_transient_ota_connect_error(ex): + print(f"==> Connect failed ({ex}); getting a fresh advertisement …") + await asyncio.sleep(1.0) + continue + _die(f"OTA failed: {ex}") + _die(f"OTA failed: {last_err}") + + +async def async_main(args: argparse.Namespace) -> None: + _require_deps() + gbl_path = Path(args.gbl) + if not gbl_path.is_file(): + _die(f"GBL not found: {gbl_path}") + gbl = gbl_path.read_bytes() + if len(gbl) < 64: + _die(f"GBL looks empty/too small: {gbl_path} ({len(gbl)} bytes)") + + master_key = None + if args.key: + raw = args.key.strip().replace(" ", "") + try: + master_key = bytes.fromhex(raw) + except ValueError: + _die("--key must be hex") + if len(master_key) != 16: + _die("--key must be 16 bytes (32 hex chars)") + + if args.already_in_ota: + _name, addr, _ble_device = await _pick_device(args.name, args.scan_timeout) + else: + _name, addr, app_dev = await _pick_device(args.name, args.scan_timeout) + await _enter_dfu(app_dev, master_key) + await _wait_address(addr, args.apploader_timeout) + + await _flash_address(gbl, addr, fast=not args.slow) + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--name", "-n", required=True, help="GAP name substring (e.g. ODDEE7 or OD)") + p.add_argument("--gbl", "-g", required=True, help="Path to OpenDisplay .gbl OTA image") + p.add_argument("--key", "-k", help="Optional 16-byte security master key (hex) if encryption is enabled") + p.add_argument("--scan-timeout", type=float, default=12.0, help="BLE scan timeout seconds") + p.add_argument("--apploader-timeout", type=float, default=45.0, help="Wait for AppLoader re-advertise") + p.add_argument( + "--already-in-ota", + action="store_true", + help="Skip CMD_ENTER_DFU; device is already advertising AppLoader", + ) + p.add_argument( + "--slow", + action="store_true", + help="Disable silabs-ble-ota fast mode (use when flashing via ESPHome BLE proxy)", + ) + args = p.parse_args() + try: + asyncio.run(async_main(args)) + except KeyboardInterrupt: + print("\nInterrupted", file=sys.stderr) + raise SystemExit(130) from None + + +if __name__ == "__main__": + main() diff --git a/scripts/requirements-ble-ota.txt b/scripts/requirements-ble-ota.txt new file mode 100644 index 0000000..b7ce31e --- /dev/null +++ b/scripts/requirements-ble-ota.txt @@ -0,0 +1,3 @@ +silabs-ble-ota>=0.1.0 +bleak>=0.22 +cryptography>=42