/* * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ #include "gap_le_advert.h" #include "gap_le_connect.h" #include #include "comm/ble/ble_log.h" #include "comm/bt_lock.h" #include "kernel/pbl_malloc.h" #include "services/common/analytics/analytics.h" #include "services/common/regular_timer.h" #include "system/logging.h" #include "system/passert.h" #include "util/list.h" //! CC2564 / HCI Advertising Limitation: //! ------------------------------ //! The Bluetooth chip can accept only one advertising payload, one //! corresponding scan response and one set of intervals. However, we need to //! juggle multiple advertising payloads for different needs. For example, //! to be discoverable we need to advertise, to be reconnectable we need to //! advertise something else, to be an iBeacon we need to advertise yet //! something different, etc. //! Unfortunately, the Ti CC2564 Bluetooth controller does not offer built-in //! functionality to cope with this, so we need to implement a scheduling //! mechanism in the firmware of the host / microcontroller. //! //! Advertisement Scheduling: //! ------------------------- //! The advertisement scheduling is pretty dumb and works as follows: //! The scheduler has "cycles" which are fixed size windows in time, during //! which one of the scheduled jobs is set to advertise. //! //! At the beginning of a cycle, the scheduler decides which job to advertise //! next. It will just round-robin through the jobs to advertise. //! //! Note that only one job is advertising at a time. Even though a job might //! have such a long interval that another job could be squeezed in between, //! clever things like that are not considered for simplicity's sake. //! //! To-Do's: //! -------- //! - ble_discoverability/pairability.c //! - Use private addresses for privacy / harder tracebility. #define GAP_LE_ADVERT_LOG_LEVEL LOG_LEVEL_DEBUG typedef struct GAPLEAdvertisingJob { ListNode node; //! The callback to call when this job is unscheduled. GAPLEAdvertisingJobUnscheduleCallback unscheduled_callback; //! The data to pass into the unscheduled callback. void *unscheduled_callback_data; //! The number of seconds the current term has been on air. uint16_t term_time_elapsed_secs; uint8_t cur_term; uint8_t num_terms; //! The terms are run in the order that they appear in this array GAPLEAdvertisingJobTerm *terms; GAPLEAdvertisingJobTag tag:8; //! The advertisement and scan response data BLEAdData payload; } GAPLEAdvertisingJob; // ----------------------------------------------------------------------------- // Static Variables -- MUST be protected with bt_lock/unlock! static bool s_gap_le_advert_is_initialized; static bool s_deinit_in_progress; //! Circular list! Pointing to the current job that needs air-time. static GAPLEAdvertisingJob *s_jobs; //! Job that is currently on air. static GAPLEAdvertisingJob *s_current; //! Advertising data that was last configured into the controller. //! @note This pointer may be dangling, don't try to reference! static const BLEAdData *s_current_ad_data; //! The regular timer that marks the end of a cycle and triggers the next job //! to be aired. static RegularTimerInfo s_cycle_regular_timer; static bool s_is_advertising; //! Cache of the last advertising transmission power in dBm. A cache is kept in //! case the API call fails, for example because Bluetooth is disabled. //! 12 dBm is what the PAN1315 Bluetooth module reports. static int8_t s_tx_power_cached = 12; // ----------------------------------------------------------------------------- //! Prototypes static void prv_perform_next_job(bool force_refresh); // ----------------------------------------------------------------------------- static const char * prv_string_for_debug_tag(GAPLEAdvertisingJobTag tag) { switch (tag) { case GAPLEAdvertisingJobTagDiscovery: return "DIS"; case GAPLEAdvertisingJobTagReconnection: return "RCN"; case GAPLEAdvertisingJobTagiOSAppLaunch: return "iOS"; default: return "?"; } } // ----------------------------------------------------------------------------- //! Helpers to manage the s_jobs list //! bt_lock is expected to be taken with all of them! static bool prv_is_current_term_infinite(const GAPLEAdvertisingJob *job) { return (job->terms[job->cur_term].duration_secs == GAPLE_ADVERTISING_DURATION_INFINITE); } static bool prv_is_current_term_silent(GAPLEAdvertisingJob *job) { return (job->terms[job->cur_term].min_interval_slots == GAPLE_ADVERTISING_SILENCE_INTERVAL_SLOTS); } //! Links the job into the ring of jobs. Will make the new job the start (s_jobs) of the ring only //! if the first term isn't silent. //! @return True if the new job was made the start of the ring, false if not. static bool prv_link_job(GAPLEAdvertisingJob *job) { if (!s_jobs) { // First job, make it point to itself: job->node.next = &job->node; job->node.prev = &job->node; s_jobs = job; return true; } list_insert_after(&s_jobs->node, &job->node); // Make it the next one up, unless the first term is silent: if (!prv_is_current_term_silent(job)) { s_jobs = job; return true; } return false; } static void prv_unlink_job(GAPLEAdvertisingJob *job) { if (job->node.next == &job->node) { // Last job left... job->node.next = NULL; job->node.prev = NULL; s_jobs = NULL; } else { list_remove(&job->node, (ListNode **) &s_jobs, NULL); } // Part of CC2564 advertising bug work-around: if (job == s_current) { bt_driver_advert_client_set_cycled(false); } } static bool prv_is_registered_job(const GAPLEAdvertisingJob *job) { if (!job) { return false; } // Search jobs (can't use list_contains(), because circular): ListNode *node = &s_jobs->node; while (node) { if (node == (const ListNode *) job) { return true; } node = node->next; if (node == &s_jobs->node) { // wrapped around break; } } return false; } static void prv_increment_elapsed_time_for_job(GAPLEAdvertisingJob **job_ptr, bool *has_new_term) { GAPLEAdvertisingJob *job = *job_ptr; if (prv_is_current_term_infinite(job)) { return; } // Increment the `time elapsed` counter: ++(job->term_time_elapsed_secs); if (job->term_time_elapsed_secs >= job->terms[job->cur_term].duration_secs) { // The current term has elapsed job->cur_term++; // Schedule the next term if (job->cur_term < job->num_terms) { // Take care of GAPLE_ADVERTISING_DURATION_LOOP_AROUND: if (job->terms[job->cur_term].duration_secs == GAPLE_ADVERTISING_DURATION_LOOP_AROUND) { BLE_LOG_DEBUG("Job looped around to term %"PRIu16, job->terms[job->cur_term].loop_around_index); job->cur_term = job->terms[job->cur_term].loop_around_index; } job->term_time_elapsed_secs = 0; BLE_LOG_DEBUG("Job is performing next advertising term (%d/%d)", job->cur_term, job->num_terms); // force an update to make sure the new requested term takes if (has_new_term) { *has_new_term = true; } } else { // Job's done, remove done job: // If it's the last, this will update s_jobs to NULL as well: prv_unlink_job(job); // Call the unscheduled callback: if (job->unscheduled_callback) { job->unscheduled_callback(job, true /* completed */, job->unscheduled_callback_data); } BLE_LOG_DEBUG("Unscheduled advertising completed job: %s", prv_string_for_debug_tag(job->tag)); kernel_free(job->terms); kernel_free(job); *job_ptr = NULL; } } } static void prv_increment_time_elapsed_for_all_silent_terms_except_current(void) { if (!s_jobs) { return; } GAPLEAdvertisingJob *job = s_jobs; do { GAPLEAdvertisingJob *next = (GAPLEAdvertisingJob *) job->node.next; if (job != s_current && prv_is_current_term_silent(job)) { prv_increment_elapsed_time_for_job(&job, NULL); } job = next; } while (job != s_jobs); } // ----------------------------------------------------------------------------- //! Cycle timer callback. //! It increments the air-time counter of the job's current term. //! Updates the job's term if the term is done. //! It removes the job if it's done. //! It updates the s_jobs list. //! It calls prv_perform_next_job() to set up the next job. static void prv_cycle_timer_callback(void *unused) { bool force_update = false; bt_lock(); { if (!s_current || !bt_driver_advert_client_has_cycled() || !s_gap_le_advert_is_initialized) { // Job got removed in the meantime. goto unlock; } prv_increment_time_elapsed_for_all_silent_terms_except_current(); GAPLEAdvertisingJob *job = s_current; BLE_LOG_DEBUG("Currently running job: %s (non-connectable=%u)", prv_string_for_debug_tag(job->tag), gap_le_connect_is_connected_as_slave()); // Set to next job (round-robin) that isn't silent (unless there is no non-silent one): s_jobs = (GAPLEAdvertisingJob *) job->node.next; while (prv_is_current_term_silent(s_jobs) && s_jobs != job /* looped around */) { s_jobs = (GAPLEAdvertisingJob *) s_jobs->node.next; }; prv_increment_elapsed_time_for_job(&job, &force_update); prv_perform_next_job(force_update); } unlock: bt_unlock(); } // ----------------------------------------------------------------------------- //! Timer start / stop utilities //! bt_lock is expected to be taken! static void prv_timer_start(void) { if (regular_timer_is_scheduled(&s_cycle_regular_timer)) { PBL_LOG(LOG_LEVEL_ERROR, "Advertising timer already started"); regular_timer_remove_callback(&s_cycle_regular_timer); } regular_timer_add_seconds_callback(&s_cycle_regular_timer); } static void prv_timer_stop(void) { regular_timer_remove_callback(&s_cycle_regular_timer); } // ----------------------------------------------------------------------------- //! Airs the next advertisement job. //! It sends the ad & scan response data to the Bluetooth controller and //! enables/disables advertising. //! It sets up / cleans up the cycle timer. //! It updates the s_current pointer. //! It does *not* mutate the s_jobs list. //! bt_lock is expected to be taken! //! @param force_refresh If true, the advertisement job will be re-setup even //! though the current job has not changed. This is (only) useful when the //! connectability mode has changed. static void prv_perform_next_job(bool force_refresh) { // Part of work-around for CC2564 bug (see comment with prv_work_around_should_not_cycle). // When forced, we've just connected or disconnected, in that case cycle as well, because // otherwise we'll continue to advertise non-connectable if we've just disconnected and there // was already a job being advertised. if (!force_refresh && bt_driver_advert_should_not_cycle()) { return; } // Pick the next job: GAPLEAdvertisingJob *next = s_jobs; // s_is_dangling is checked here, in case the next job happens to have been allocated at the // same address as the old s_current: const bool is_same_job = (next == s_current && bt_driver_advert_client_has_cycled()); if (is_same_job && !force_refresh && s_is_advertising) { // No change in job to give air time, keep going. return; } if (s_current) { // Clean up old job: if (!next) { // No more jobs. Stop timer: prv_timer_stop(); } if (s_is_advertising) { // Controller needs to stop advertising before we can start a new job: PBL_LOG(GAP_LE_ADVERT_LOG_LEVEL, "Disable last Ad job"); bt_driver_advert_advertising_disable(); s_is_advertising = false; analytics_stopwatch_stop(ANALYTICS_DEVICE_METRIC_BLE_ESTIMATED_BYTES_ADVERTISED_COUNT); } } if (next) { // Set up the next job to be on air: if (!s_current) { // No current job, start timer: prv_timer_start(); } if (!prv_is_current_term_silent(next)) { const bool enable_scan_resp = (next->payload.scan_resp_data_length > 0); if (s_current_ad_data != &next->payload) { // Give the advertisement data to the BT controller: bt_driver_advert_set_advertising_data(&next->payload); s_current_ad_data = &next->payload; } // One slot is 625us: const uint32_t min_interval_ms = ((next->terms[next->cur_term].min_interval_slots * 5) / 8); const uint32_t max_interval_ms = ((next->terms[next->cur_term].max_interval_slots * 5) / 8); BLE_LOG_DEBUG("Enable Ad job %s", prv_string_for_debug_tag(next->tag)); bool result = bt_driver_advert_advertising_enable(min_interval_ms, max_interval_ms, enable_scan_resp); if (result) { s_is_advertising = true; PBL_LOG(GAP_LE_ADVERT_LOG_LEVEL, "Airing advertising job: %s ", prv_string_for_debug_tag(next->tag)); // Use average interval ms. BT controller does not report back what it uses. const uint32_t interval_ms = (min_interval_ms + max_interval_ms) / 2; // The ad data is fixed in size. See below. // The scan response data size is omitted here, because we can't tell how // often a scan request happens. BT controller does not report it either. const uint32_t size = next->payload.ad_data_length /* ad data */ + 10 /* packet overhead */; const uint32_t bytes_per_second = (size * 1000 /* ms */) / interval_ms; analytics_stopwatch_start_at_rate( ANALYTICS_DEVICE_METRIC_BLE_ESTIMATED_BYTES_ADVERTISED_COUNT, bytes_per_second, AnalyticsClient_System); } } } s_current = next; bt_driver_advert_client_set_cycled(true); } // ----------------------------------------------------------------------------- GAPLEAdvertisingJobRef gap_le_advert_schedule(const BLEAdData *payload, const GAPLEAdvertisingJobTerm *terms, uint8_t num_terms, GAPLEAdvertisingJobUnscheduleCallback callback, void *callback_data, GAPLEAdvertisingJobTag tag) { // Sanity check payload: if (!payload || payload->ad_data_length > GAP_LE_AD_REPORT_DATA_MAX_LENGTH || payload->scan_resp_data_length > GAP_LE_AD_REPORT_DATA_MAX_LENGTH) { return NULL; } // Each job must have at least 1 term if (num_terms == 0 || terms == NULL) { return NULL; } // Minimum interval is 32 slots (20ms), or 160 slots (100ms) when there is a // scan response: const uint16_t min_threshold = payload->scan_resp_data_length ? 160 : 32; for (int i = 0; i < num_terms; i++) { // Loop-around term: const bool is_loop_around = (terms[i].duration_secs == GAPLE_ADVERTISING_DURATION_LOOP_AROUND); if (is_loop_around) { if (i == 0) { // First term cannot be loop-around return NULL; } continue; } // Silent term: const bool is_silent = (terms[i].min_interval_slots == GAPLE_ADVERTISING_SILENCE_INTERVAL_SLOTS && terms[i].max_interval_slots == GAPLE_ADVERTISING_SILENCE_INTERVAL_SLOTS); if (is_silent) { if (terms[i].duration_secs == GAPLE_ADVERTISING_DURATION_INFINITE) { // Can't be silent forever return NULL; } continue; } // Normal term, verify min and max interval values: if (terms[i].min_interval_slots < min_threshold || terms[i].max_interval_slots < terms[i].min_interval_slots) { return NULL; } } // Create the job data structure: GAPLEAdvertisingJob *job = kernel_malloc_check(sizeof(GAPLEAdvertisingJob) + payload->ad_data_length + payload->scan_resp_data_length); *job = (const GAPLEAdvertisingJob) { .unscheduled_callback = callback, .unscheduled_callback_data = callback_data, .term_time_elapsed_secs = 0, .num_terms = num_terms, .tag = tag, .payload = { .ad_data_length = payload->ad_data_length, .scan_resp_data_length = payload->scan_resp_data_length, }, }; job->terms = kernel_malloc_check(sizeof(GAPLEAdvertisingJobTerm) * num_terms); memcpy(job->terms, terms, sizeof(GAPLEAdvertisingJobTerm) * num_terms); memcpy(job->payload.data, payload->data, payload->ad_data_length + payload->scan_resp_data_length); PBL_LOG(LOG_LEVEL_INFO, "Scheduling advertising job: %s", prv_string_for_debug_tag(job->tag)); // Schedule bt_lock(); { if (s_gap_le_advert_is_initialized && !s_deinit_in_progress) { if (prv_link_job(job)) { prv_perform_next_job(false); } } else { kernel_free(job->terms); kernel_free(job); job = NULL; } } bt_unlock(); return job; } // ----------------------------------------------------------------------------- void gap_le_advert_unschedule(GAPLEAdvertisingJobRef job) { if (!job) { return; } bool is_registered = false; bt_lock(); { if (!s_gap_le_advert_is_initialized) { goto unlock; } is_registered = prv_is_registered_job(job); if (is_registered) { PBL_LOG(LOG_LEVEL_INFO, "Unscheduling advertising job: %s", prv_string_for_debug_tag(job->tag)); prv_unlink_job(job); prv_perform_next_job(false); // Call the unscheduled callback: if (job->unscheduled_callback) { job->unscheduled_callback(job, false /* completed */, job->unscheduled_callback_data); } // In case the payload pointer of a future jobs ends up being the same, ensure the adv data // will get updated in that case: if (s_current_ad_data == &job->payload) { s_current_ad_data = NULL; } } } unlock: bt_unlock(); if (is_registered) { kernel_free(job->terms); kernel_free(job); } } void gap_le_advert_unschedule_job_types( GAPLEAdvertisingJobTag *tag_types, size_t num_types) { bt_lock(); ListNode *first_node = bt_driver_advert_client_has_cycled() ? &s_current->node : &s_jobs->node; // get the last job in the list ListNode *curr_node = list_get_prev(first_node); // Note: We attempt to get the currently running job and walk through the // list backwards so that we don't keep updating the running job as we remove // advertisements from our list while (curr_node) { GAPLEAdvertisingJob *job = (GAPLEAdvertisingJob *)curr_node; ListNode *prev_node = job->node.prev; for (size_t i = 0; i < num_types; i++) { if (job->tag == tag_types[i]) { BLE_LOG_DEBUG("Removing advertisement of type %s", prv_string_for_debug_tag(job->tag)); gap_le_advert_unschedule(job); } } if (curr_node == first_node) { break; // we have cycled through all the jobs } curr_node = prev_node; } bt_unlock(); } // ----------------------------------------------------------------------------- int8_t gap_le_advert_get_tx_power(void) { int8_t tx_power; bt_lock(); { // In case this API call fails, (e.g. Airplane Mode), // the s_tx_power_cached is untouched: if (bt_driver_advert_client_get_tx_power(&tx_power)) { s_tx_power_cached = tx_power; } } bt_unlock(); return tx_power; } // ----------------------------------------------------------------------------- void gap_le_advert_init(void) { bt_lock(); { if (s_gap_le_advert_is_initialized) { PBL_LOG(LOG_LEVEL_ERROR, "gap le advert has already been initialized"); goto unlock; } s_deinit_in_progress = false; s_jobs = NULL; s_current = NULL; s_current_ad_data = NULL; s_cycle_regular_timer = (const RegularTimerInfo) { .cb = prv_cycle_timer_callback, }; s_is_advertising = false; s_gap_le_advert_is_initialized = true; } unlock: bt_unlock(); } // ----------------------------------------------------------------------------- void gap_le_advert_deinit(void) { bt_lock(); { s_deinit_in_progress = true; while (s_jobs) { gap_le_advert_unschedule(s_jobs); } PBL_ASSERTN(!regular_timer_is_scheduled(&s_cycle_regular_timer) || regular_timer_pending_deletion(&s_cycle_regular_timer)); s_gap_le_advert_is_initialized = false; } bt_unlock(); } // ----------------------------------------------------------------------------- void gap_le_advert_handle_connect_as_slave(void) { bt_lock(); { if (!s_gap_le_advert_is_initialized) { goto unlock; } // The link layer state machine inside the Bluetooth controller // automatically stops advertising when transitioning to "connected", so // update our own state. See 7.8.9 of Bluetooth Specification // // We don't instantly cycle the advertisements because our LE client // handler (kernel_le_client.c) will unschedule jobs accordingly and we // want to avoid unnecessary refreshes of the advertising state s_is_advertising = false; if (!bt_driver_advert_client_has_cycled()) { s_current = NULL; bt_driver_advert_client_set_cycled(true); } analytics_stopwatch_stop(ANALYTICS_DEVICE_METRIC_BLE_ESTIMATED_BYTES_ADVERTISED_COUNT); } unlock: bt_unlock(); } // ----------------------------------------------------------------------------- void gap_le_advert_handle_disconnect_as_slave(void) { bt_lock(); { if (!s_gap_le_advert_is_initialized) { goto unlock; } // Call prv_perform_next_job() to trigger refreshing the configuration of // the controller: it can advertise connectable packets again. prv_perform_next_job(true /* force refresh, connectability mode changed */); } unlock: bt_unlock(); } GAPLEAdvertisingJobRef gap_le_advert_get_current_job(void) { return s_current; } GAPLEAdvertisingJobRef gap_le_advert_get_jobs(void) { return s_jobs; } GAPLEAdvertisingJobTag gap_le_advert_get_job_tag(GAPLEAdvertisingJobRef job) { return job->tag; } #undef GAP_LE_ADVERT_LOG_LEVEL