Создание профилей Bluetooth в BLE стеке TI

в 9:54, , рубрики: cc2541, diy или сделай сам, texas instruments, wireless, Беспроводные технологии, Блог компании Intellectro, Электроника для начинающих

Создание профилей Bluetooth в BLE стеке TI - 1

В первой части статьи мы настраивали средства разработки, пытались разобраться с тем, как устроен код, как и чем его отлаживать, но так и не написали ни единой строки кода. Исправим это во второй части. Напишем собственный BLE профиль для CC2541.


1. Постановка задачи

Предположим, нам для нашего устройства нужен профиль, который будет поддерживать следующие функции:
— установка и чтение значений двух каналов ШИМ (от 0 до 255),
— получение состояния нажатия двух кнопок,
— чтение состояния одной из этих кнопок.

По факту нам понадобятся четыре характеристики профиля — две отвечающие за состояние ШИМ каналов и две нотифицирующие состояние кнопок.

2. UUID сервиса и характеристик

Как говорилось в предыдущей части, 16-байтные адреса сервисов простой смертный использовать не может. Для этого как минимум нужно быть Associated Member Bluetooth SIG. В общем, для нас выделены 128-битные UUID сервисов. Для генерации UUID нашего сервиса воспользуемся вот этим сервисом.

Нам потребуется пять UUID — четыре для характеристик и один для сервиса. Чтобы UUID шли по порядку, выбираем алгоритм «Time/node based». После генерации получаем примерно такой набор UUID:
Создание профилей Bluetooth в BLE стеке TI - 2

Теперь все готово для того, чтобы начать писать код профиля. Обзовем наш профиль «HabrProfile» и добавим его в проект «SimpleBLEPerepherial».

3. Создание заголовочного файла

В папке ProjectsProfiles стека создадим папку HabrProfile, а в ней файлы habrprofile.h и habrprofile.c
Дальше добавим файлы в проект SimpleBLEPerepherial в папку PROFILES.
Заголовочный файл должен содержать:
-UUID профиля и характеристик, которые были получены ваше
-Именование атрибутов профиля (чтобы удобно было обращаться к ним из основной программы)
-Объявление функций для работы с характеристиками и профилем из внешней программы
-Определение типа функции колбэк вызова профиля

Содержимое файла habrprofile.h

#define HABR_UUID(uuid) 0x66, 0x9a, 0x0c, 0x20, 0x00, 0x08, 0xa9, 0xb4, 0xe4, 0x11, 0xd7, 0x85, uuid, 0x50 , 0x2e, 0x51

#define HH_BUTTON1_STATE_ATTR           0
#define HH_BUTTON2_STATE_ATTR           1
#define HH_CHANNEL1_PWM_ATTR              2
#define HH_CHANNEL2_PWM_ATTR              3

#define HH_SERVICE_UUID                 0x10
#define HH_BUTTON1_STATE_UUID           0x11
#define HH_BUTTON2_STATE_UUID           0x12
#define HH_CHANNEL1_PWM_UUID              0x13
#define HH_CHANNEL2_PWM_UUID              0x14

#define HH_SERVICE                 0x00000001

typedef void (*habrControlCB_t)( uint8 paramID ) ;

typedef struct
{
  habrControlCB_t        pfnHabrCB;  // Called when some att changed
} HarbCBs_t;

extern bStatus_t Habr_AddService();
extern bStatus_t Habr_RegisterAppCBs( HarbCBs_t *appCallbacks );
extern bStatus_t Habr_SetParameter( uint8 param, uint8 len, void *value );
extern bStatus_t Habr_GetParameter( uint8 param, void *value );


Первое, с чем мы сталкиваемся – это с тем, что 15 байтов из 16 наших UUID совпадают. Соответственно, разумно объединить их в общий дефайн, учитывая однако факт, что порядок байтов в Bluetooth — big-endian, а в записи UUID, полученной нами — little endian. Посему запись байтов в дефайне перевернута зеркально.
Функции AddService и RegisterAppCBs служат для регистрации профиля в стеке и привязки функций обратного вызова программы к профилю.
Функции SetParameter и GetParameter нужны для управления значениями характеристик профиля.
Кроме того, нам понадобится сделать обработчики для событий установки и чтения переменных по протоколу, но об этом позже. Для начала разметим таблицу профиля в исполняемом файле.

4. Таблица сервисов

Итак, у нас есть четыре характеристики, две из которых могут нотифицировать пользовательское приложение об изменении значения характеристики. Как говорилось в первой части статьи, для инициализации одной переменной для чтения или записи требуется три записи в таблице устройства, для нотифицируемой переменной — четыре, то есть для всех переменных профиля нам понадобится 14 записей, добавив к ним запись, объявляющую профиль, получим 15 записей.

Самое важное сейчас — правильно задать таблицу устройства.
Первое, что нужно сделать — сформировать UUID профиля и характеристик в переменные в виде:

CONST uint8 HhServUUID[ATT_UUID_SIZE] =
{ 
  HABR_UUID(HH_SERVICE_UUID)
};

Далее определяем переменные/константы, которые будут отвечать за параметры конкретных характеристик:

static uint8 hhButton1CharProps = GATT_PROP_NOTIFY;                   //определит параметры доступа к переменной
static uint8 hhButton1Value     = 0x00;               //определит значение по умолчанию
static gattCharCfg_t hhButton1Config[GATT_MAX_NUM_CONN];        //параметры уведомления - только для переменных NOTIFY
static uint8 hhButton1UserDesc[]="Button 1 variable";                        //текстовое описание характеристики

И заносим характеристики в таблицу gatt характеристик устройства (массив типа gattAttribute_t) в виде:

{
  gattAttrType_t type;   //  содержит длину handle и указатель на UUID атрибута.
  uint8 permissions;   // права доступа к атрибуту.
  uint16 handle;       // устанавливается стеком самостоятельно - писать 0. 
  uint8* const pValue; // значение (до 512 байт).
} 

Это создает небольшую путаницу. С одной стороны, у нас есть переменная, которая определяет права доступа к характеристике (в предыдущем листинге — GATT_PROP_NOTIFY). С другой стороны, есть запись, отвечающая за права доступа к атрибуту. Проясним эту разницу на нашем примере. В нашем профиле есть нотификация от обеих кнопок и есть возможность чтения состояния одной из них (второй).
Тогда для первой настройки характеристики — GATT_PROP_NOTIFY, но нет разрешения на чтение либо запись.
Для второй настройки характеристики — GATT_PROP_NOTIFY | GATT_PROP_READ, кроме того, должно быть объявлено разрешение на чтение в GATT таблице устройства (иначе не будет вызван колбэк с запросом на чтение) — GATT_PERMIT_READ.

Более подробно — в полной таблице атрибутов:

Таблица атрибутов профиля, инклуды и объявления переменных

#include "bcomdef.h"
#include "OSAL.h"
#include "linkdb.h"
#include "att.h"
#include "gatt.h"
#include "gatt_uuid.h"
#include "gattservapp.h"
#include "habrprofile.h"
#include "OSAL_Clock.h"

#define SERVAPP_NUM_ATTR_SUPPORTED        15
#define UUID_SIZE 16


CONST uint8 hhServUUID[ATT_UUID_SIZE] =
{ 
  HABR_UUID(HH_SERVICE_UUID)
};

CONST uint8 hhButton1UUID[ATT_UUID_SIZE] =
{ 
  HABR_UUID(HH_BUTTON1_STATE_UUID)
};
CONST uint8 hhButton2UUID[ATT_UUID_SIZE] =
{ 
  HABR_UUID(HH_BUTTON2_STATE_UUID)
};
CONST uint8 hhPWM1UUID[ATT_UUID_SIZE] =
{ 
  HABR_UUID(HH_CHANNEL1_PWM_UUID)
};
CONST uint8 hhPWM2UUID[ATT_UUID_SIZE] =
{ 
  HABR_UUID(HH_CHANNEL2_PWM_UUID)
};

static HarbCBs_t *habrahabrAppCBs_t = NULL;
//attribute definitions
static CONST gattAttrType_t hhService = {ATT_UUID_SIZE, hhServUUID};
static uint8 hhButton1CharProps = GATT_PROP_NOTIFY;
static uint8 hhButton1Value     = 0x00;
static gattCharCfg_t hhButton1Config[GATT_MAX_NUM_CONN];
static uint8 hhButton1UserDesc[]="Button 1 variable";
static uint8 hhButton2CharProps = GATT_PROP_NOTIFY|GATT_PROP_READ;
static uint8 hhButton2Value     = 0x00;
static gattCharCfg_t hhButton2Config[GATT_MAX_NUM_CONN];
static uint8 hhButton2UserDesc[]="Button 2 variable";
static uint8 hhPWM1CharProps = GATT_PROP_READ | GATT_PROP_WRITE;
static uint8 hhPWM1Value = 0x00;
static uint8 hhPWM1UserDesc[] = "PWM 1 variable";
static uint8 hhPWM2CharProps = GATT_PROP_READ | GATT_PROP_WRITE;
static uint8 hhPWM2Value = 0x00;
static uint8 hhPWM2UserDesc[] = "PWM 2 variable";

//attribute table
static gattAttribute_t HabrProfileAttrTable[15]={

  //Service
  {
      { ATT_BT_UUID_SIZE, primaryServiceUUID }, 
      GATT_PERMIT_READ,                   
      0,                                  
      (uint8 *)&hhServUUID
  },
    //Button1
  { 
      { ATT_BT_UUID_SIZE, characterUUID },
      GATT_PERMIT_READ, 
      0,
      &hhButton1CharProps 
   },
   { 
       {UUID_SIZE, hhButton1UUID },
       0,
       0, 
       (uint8 *)&hhButton1Value
   },
   {
       {ATT_BT_UUID_SIZE , clientCharCfgUUID},
       GATT_PERMIT_READ | GATT_PERMIT_WRITE,
       0,
       (uint8 *)hhButton1Config
   },
   { 
        { ATT_BT_UUID_SIZE, charUserDescUUID },
        GATT_PERMIT_READ, 
        0, 
        hhButton1UserDesc
    } ,
    //Button2
   { 
      { ATT_BT_UUID_SIZE, characterUUID },
      GATT_PERMIT_READ, 
      0,
      &hhButton2CharProps 
   },
   { 
       {UUID_SIZE, hhButton2UUID },
       GATT_PERMIT_READ,
       0, 
       (uint8 *)&hhButton2Value
   },
   {
       {ATT_BT_UUID_SIZE , clientCharCfgUUID},
       GATT_PERMIT_READ | GATT_PERMIT_WRITE,
       0,
       (uint8 *)hhButton2Config
   },
   { 
        { ATT_BT_UUID_SIZE, charUserDescUUID },
        GATT_PERMIT_READ, 
        0, 
        hhButton2UserDesc
    } ,
    //PWM channel 1
     { 
      { ATT_BT_UUID_SIZE, characterUUID },
      GATT_PERMIT_READ, 
      0,
      &hhPWM1CharProps 
   },
   { 
        {UUID_SIZE, hhPWM1UUID },
        GATT_PERMIT_READ | GATT_PERMIT_WRITE, 
        0,
        (uint8*)&hhPWM1Value 
      },
   { 
        { ATT_BT_UUID_SIZE, charUserDescUUID },
        GATT_PERMIT_READ, 
        0, 
        hhPWM1UserDesc
    } ,
    //PWM channel 2
      { 
      { ATT_BT_UUID_SIZE, characterUUID },
      GATT_PERMIT_READ, 
      0,
      &hhPWM2CharProps 
   },
   { 
     {UUID_SIZE, hhPWM2UUID },
        GATT_PERMIT_READ | GATT_PERMIT_WRITE, 
        0,
        (uint8*)&hhPWM2Value 
    },
   { 
        { ATT_BT_UUID_SIZE, charUserDescUUID },
        GATT_PERMIT_READ, 
        0, 
        hhPWM2UserDesc
    }
    
};

5. Пользовательские функции

Следующий этап – описать функции, вызываемые из основной программы для:
— регистрации профиля,
— назначения функции обратного вызова,
— чтения переменных,
— записи переменных.

Для регистрации профиля в стеке в первую очередь нужно объявить функции обратного вызова профиля — как раз те функции, которые вызываются, когда происходит внешнее событие — запрос на чтение или запись характеристики, а также функцию, вызываемую при изменении статуса соединения.

static uint8 hh_ReadAttrCB( uint16 connHandle, gattAttribute_t *pAttr, 
                           uint8 *pValue, uint8 *pLen, uint16 offset, uint8 maxLen );
static bStatus_t hh_WriteAttrCB( uint16 connHandle, gattAttribute_t *pAttr,
                                    uint8 *pValue, uint8 len, uint16 offset );

static void hh_HandleConnStatusCB( uint16 connHandle, uint8 changeType );

CONST gattServiceCBs_t  HH_CBs =
{
  hh_ReadAttrCB,  // Read callback function pointer
  hh_WriteAttrCB, // Write callback function pointer
  NULL               
};

Окей, теперь можно зарегистрировать профиль в стеке и проверить корректность таблицы характеристик профиля. Функция регистрации профиля в стеке подразумевает помимо непосредственно вызова GATTServApp_RegisterService еще и регистрацию функции обратного вызова при изменении статуса соединения и инициализацию переменных конфигурации (для тех характеристик, которые должны быть нотифицируемыми):

bStatus_t Habr_AddService()
{
  uint8 status = SUCCESS;

  GATTServApp_InitCharCfg( INVALID_CONNHANDLE, hhButton1Config ); 
  GATTServApp_InitCharCfg( INVALID_CONNHANDLE, hhButton2Config );
  VOID linkDB_Register( hh_HandleConnStatusCB );  
  status = GATTServApp_RegisterService(HabrProfileAttrTable, GATT_NUM_ATTRS(HabrProfileAttrTable), &HH_CBs );
  return ( status );
}

Проверим правильность таблицы атрибутов. Для этого в SimpleBLEPerepherial.c в функции SimpleBLEPeripheral_Init вызовем Habr_AddService, предварительно добавив инклуд заголовочника (и не забыв добавить путь к заголовочнику для компилятора — строку "$PROJ_DIR$....ProfilesHabrProfile"). Прошьем отладочную плату, подключимся к ней через BLE Device Monitor и проверим полученную таблицу атрибутов:
Создание профилей Bluetooth в BLE стеке TI - 3
Важно сверить UUID, состав профиля. Если все хорошо, идем дальше.

Опущу описание функции

Функции чтения и записи переменных из пользовательского приложения

bStatus_t Habr_RegisterAppCBs( HarbCBs_t *appCallbacks ){
  if ( appCallbacks )
  {
    habrahabrAppCBs_t = appCallbacks;
    
    return ( SUCCESS );
  }
  else
  {
    return ( bleAlreadyInRequestedMode );
  }
}
bStatus_t Habr_SetParameter( uint8 param, uint8 len, void *value ){
    bStatus_t ret = SUCCESS;
    switch ( param )
    {
    case HH_BUTTON1_STATE_ATTR:
      if(len == sizeof(uint8))
      {
        hhButton1Value = *((uint8*)value);
        GATTServApp_ProcessCharCfg (hhButton1Config, (uint8 *)&hhButton1Value, FALSE, HabrProfileAttrTable 
                                    ,GATT_NUM_ATTRS(HabrProfileAttrTable), INVALID_TASK_ID);
      }
      else{
        ret = bleInvalidRange;
      }
      break;
    case HH_BUTTON2_STATE_ATTR:
      if(len == sizeof(uint8))
      {
        hhButton2Value = *((uint8*)value);
        GATTServApp_ProcessCharCfg (hhButton2Config, (uint8 *)&hhButton2Value, FALSE, HabrProfileAttrTable 
                                    ,GATT_NUM_ATTRS(HabrProfileAttrTable), INVALID_TASK_ID);
      }
      else{
        ret = bleInvalidRange;
      }
      break;
    case HH_CHANNEL1_PWM_ATTR:
      if(len == sizeof(uint8))
      {
          hhPWM1Value = *((uint8*)value);
      }
      else{
        ret = bleInvalidRange;
      }
      break;
    case HH_CHANNEL2_PWM_ATTR:
      if(len == sizeof(uint8))
      {
          hhPWM2Value = *((uint8*)value);
      }
      else{
        ret = bleInvalidRange;
      }
      break;
    default:
       ret = INVALIDPARAMETER;
      break;
  
    } 
   return(ret);
}

Останавливаться на функции регистрации колбэка не вижу смысла. Немного подробней рассмотрим функции записи и чтения значения переменных, а в первую очередь функцию записи значений в профиль. Тут стоит обратить внимание на то, что необходимо обязательно делать вызов GATTServApp_ProcessCharCfg — эта функция обеспечит собственно нотификацию.

Дело за малым — дописать функции для обработки событий стека.
6. Колбэк функции BLE стека

Обработкой событий стека, как было сказано выше, займутся три функции — колбэк запроса чтения значения атрибута, колбэк запроса чтения записи атрибута, колбэк состояния соединения.
Научить профиль отдавать свои характеристики на чтение очень просто (особенно в нашем случае, когда все характеристики – значения одного типа uint8) — для этого нужно убедиться, что мы имеем дело с правильными характеристиками. Стек в ответ от функции получает три значения — status, pLen (так что архиважно всегда устанавливать точное значение pLen) и pValue. Все три значения передаются дальше и могут быть получены нами на приемной стороне.

Чтение характеристик сервиса

static uint8 hh_ReadAttrCB( uint16 connHandle, gattAttribute_t *pAttr, uint8 *pValue, uint8 *pLen, uint16 offset, uint8 maxLen )
{
                             
    bStatus_t status = SUCCESS;
    if ( offset > 0 )
    {
      return ( ATT_ERR_ATTR_NOT_LONG );
    }
    
    if ( pAttr->type.len == ATT_UUID_SIZE )
    {    
      // 128-bit UUID
      uint8 uuid[ATT_UUID_SIZE];
      osal_memcpy(uuid, pAttr->type.uuid, ATT_UUID_SIZE);
    
      if(osal_memcmp(uuid,hhPWM2UUID,ATT_UUID_SIZE)||
       osal_memcmp(uuid,hhPWM1UUID,ATT_UUID_SIZE)||
       osal_memcmp(uuid,hhButton2UUID,ATT_UUID_SIZE)||
         osal_memcmp(uuid,hhButton1UUID,ATT_UUID_SIZE))
      {
       *pLen = 1;
        pValue[0] = *pAttr->pValue;
      }
    } 
    else
    {
    // 16-bit UUID
      *pLen = 0;
      status = ATT_ERR_INVALID_HANDLE;
    }


  return ( status );
}

}

Заодно проверим чтение характеристик — все ли работает корректно (кстати, мы ожидаем ошибку чтения для переменной первой кнопки):
Создание профилей Bluetooth в BLE стеке TI - 4

Запись переменных в профиль происходит подобным образом, однако в функции чтения мы группировали переменные — здесь это делать не желательно, поскольку хочется, чтобы вызываемый профилем колбэк понимал, какая конкретно характеристика была изменена. Достигается это за счет определения переменной notify. Если она была установлена, то в данной функция вызовет функцию в пользовательском приложении с параметром notify.
Кроме того, помимо обработки записи значений ШИМ, эта функция включает (и выключает) нотификацию, если было записано значение для атрибута конфигурации нотифицируемой характеристики — это достигается вызовом функции GATTServApp_ProcessCCCWriteReq();

Запись характеристик сервиса

static bStatus_t hh_WriteAttrCB( uint16 connHandle, gattAttribute_t *pAttr,
                                uint8 *pValue, uint8 len, uint16 offset ){
bStatus_t status = SUCCESS;
     uint8 notify = 0xFF;
     if ( pAttr->type.len == ATT_UUID_SIZE )
    {
        const uint8 uuid[ATT_UUID_SIZE] = {
          HABR_UUID(pAttr->type.uuid[12])
        };
        if(osal_memcmp(uuid,hhPWM1UUID,ATT_UUID_SIZE))
        {
            if ( offset == 0 )
            {
              if ( len != 1 ){
                status = ATT_ERR_INVALID_VALUE_SIZE;
                }
            }
            else
            {
                status = ATT_ERR_ATTR_NOT_LONG;
            }
            if ( status == SUCCESS )
            {
              uint8 *pCurValue = (uint8 *)pAttr->pValue;
              *pCurValue = pValue[0];
              notify = HH_CHANNEL1_PWM_ATTR;        
        }
        }
            else if(osal_memcmp(uuid,hhPWM2UUID,ATT_UUID_SIZE)){
              
                  if ( offset == 0 )
                {
                  if ( len != 1 ){
                    status = ATT_ERR_INVALID_VALUE_SIZE;
                    }
                }
                else
                {
                    status = ATT_ERR_ATTR_NOT_LONG;
                }
                if ( status == SUCCESS )
                {
                  uint8 *pCurValue = (uint8 *)pAttr->pValue;
                  *pCurValue = pValue[0];
                  notify = HH_CHANNEL2_PWM_ATTR;
            }
        
      }
    }
   else if (pAttr->type.len== ATT_BT_UUID_SIZE)
  {
    uint16 uuid= BUILD_UINT16(pAttr->type.uuid[0],pAttr->type.uuid[1]);
    switch(uuid){
    case GATT_CLIENT_CHAR_CFG_UUID:
      status=GATTServApp_ProcessCCCWriteReq(connHandle, pAttr, pValue, len, offset, GATT_CLIENT_CFG_NOTIFY);
      break;
    default:
      status = ATT_ERR_ATTR_NOT_FOUND;
    }
  }
  else{
    status = ATT_ERR_INVALID_HANDLE;
  }

  // If an attribute changed then callback function to notify application of change
  if ( (notify != 0xFF) && habrahabrAppCBs_t && habrahabrAppCBs_t->pfnHabrCB )
    habrahabrAppCBs_t->pfnHabrCB(notify);  
                                  
   return ( status );                               
                                                               
}

Профиль почти готов. Последнее что в него нужно добавить — функцию, которая отключит нотификацию переменных при потере соединения.

Функция, которая выключает нотификацию при потере связи

static void hh_HandleConnStatusCB( uint16 connHandle, uint8 changeType ){
if ( connHandle != LOOPBACK_CONNHANDLE )
  {
    if ( ( changeType == LINKDB_STATUS_UPDATE_REMOVED )      ||
         ( ( changeType == LINKDB_STATUS_UPDATE_STATEFLAGS ) && 
           ( !linkDB_Up( connHandle ) ) ) )
    { 
      GATTServApp_InitCharCfg ( connHandle, hhButton1Config);
      GATTServApp_InitCharCfg ( connHandle, hhButton2Config);
      
    }
  }

}

Профиль готов! Теперь убедимся, что он работает корректно.

7. Связь с пользовательским приложением

Оторвемся от периферии и сделаем такой сценарий:
При установке значения канала PWM1 то же значение передается нам через переменную Button1. Таким же образом поставим в соответствие PWM2 и Button2.
Для этого нам понадобится в файле SimpleBLEPerepherial:
— Объявить колбэк профиля,
— Зарегистрировать его в профиле,
— Реализовать алгоритм.

Начнем. Объявим собственно колбэк и структуру, которая будет регистрироваться для исполнения колбэка. На первый взгляд такая запись может показаться чересчур сложной, однако если нам потребуется строить профиль с несколькими колбэками (например, если захочется добавить уведомление о чтении переменной), такой подход более чем оправдает себя. Да и вообще все колбэки стека построены именно таким образом.

static void habrProfileCB (uint8 paramID);
static HarbCBs_t HabrProfCBStruct =
{
  habrProfileCB          // Characteristic value change callback
};

В теле функции SimpleBLEPeripheral_Init зарегистрируем в профиле эту структуру:

Habr_AddService();
Habr_RegisterAppCBs(&HabrProfCBStruct);

В функции hh_WriteAttrCB мы уже реализовали передачу в колбэк информации о том, какая характеристика была записана. Дело только за тем, чтобы сейчас эту информацию обработать:

static void habrProfileCB (uint8 paramID){
  
  uint8 u8buffer;
  switch(paramID){
  case HH_CHANNEL1_PWM_ATTR:
    Habr_GetParameter(HH_CHANNEL1_PWM_ATTR, &u8buffer);
    Habr_SetParameter(HH_BUTTON1_STATE_ATTR, sizeof(uint8), &u8buffer);
    break;
  case HH_CHANNEL2_PWM_ATTR:
    Habr_GetParameter(HH_CHANNEL2_PWM_ATTR, &u8buffer);
    Habr_SetParameter(HH_BUTTON2_STATE_ATTR, sizeof(uint8), &u8buffer);
    break;
  default:
    break;
  }
  
}

И наконец проверим, что все работает. Оно и правда работает — можно убедиться в консоли:
Создание профилей Bluetooth в BLE стеке TI - 5
Интеграцию с периферией контроллера читателю предлагается сделать самостоятельно.
Спасибо за внимание!

Автор: agafonishe

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js