I'm trying to write a custom characteristic to a BLE Device using Google's example project .
On the DeviceControlActivity and while I'm looping through the fetched BluetoothGattServices I try to set a characteristic for one of them. At this point I'm just picking one randomly to test.
private void displayGattServices(List<BluetoothGattService> gattServices) {
...
// Loops through available GATT Services.
for (BluetoothGattService gattService : gattServices) {
uuid = gattService.getUuid().toString();
if(uuid.equals("00001800-0000-1000-8000-00805f9b34fb") && !firstTime){
firstTime = true;
BluetoothGattCharacteristic customChar = new BluetoothGattCharacteristic(MY_CHARACTERISTIC,
BluetoothGattCharacteristic.PROPERTY_READ | BluetoothGattCharacteristic.PROPERTY_WRITE,
BluetoothGattCharacteristic.PERMISSION_READ |BluetoothGattCharacteristic.PERMISSION_WRITE);
byte[] val = new byte[20];
val[0] = 71;
val[1] = 97;
val[2] = 108;
val[3] = 97;
val[4] = 120;
val[5] = 121;
val[6] = 32;
val[7] = 70;
val[8] = 105;
val[9] = 116;
val[10] = -30;
val[11] = -109;
val[12] = -108;
val[13] = 32;
val[14] = 40;
val[15] = 70;
val[16] = 57;
val[17] = 66;
val[18] = 57;
val[19] = 41;
customChar.setValue(val);
boolean isAdded = gattService.addCharacteristic(customChar);
Log.d(TAG,"CARAC ADDED? "+isAdded);
}
...
}
This call to addCharacteristic returns true which according to the docs means the writing operation was successful, however when I rescan the services (without executing the code above) I cannot find the BluetoothGattCharacteristic also when I try to read the value of said characteristic I get a status 135. According to this post means GATT_ILLEGAL_PARAMETER
Please note that the byte array is literally copied from another characteristic so normally it should do the trick.
Is it possible that the device I'm trying to write to doesn't support writing ? or the service itself ?
EDIT:
Following the suggestion on the comment, I have tried to modify an existing characteristic using the code below without success (I think the characteristic was created using READ only flag?)
for (BluetoothGattCharacteristic gattCharacteristic : gattCharacteristics) {
...
uuid = gattCharacteristic.getUuid().toString();
// UUID for device name
if(uuid.equals("00002a00-0000-1000-8000-00805f9b34fb") && !firstTime){
firstTime = true;
gattCharacteristic.setValue("Hello");
boolean isAdded = mBluetoothLeService.writeCharacteristic(gattCharacteristic);
Log.d(TAG,"CARAC ADDED? "+isAdded); // returns false
}
I also tried to setup a GattServer and creating a new service with new characteristic but also without any success. Here is the code (this partuses Kotlin):
mBluetoothGattServer = mBluetoothManager?.openGattServer(this#MainActivity,gattCallback)
mBluetoothGattServer?.connect(result.device,false)
val gattCallback = object: BluetoothGattServerCallback() {
override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) {
super.onConnectionStateChange(device, status, newState)
if (newState == BluetoothProfile.STATE_CONNECTED) {
val service = BluetoothGattService(UUID.fromString("f000aa01-0451-4000-b000-000000000000"), BluetoothGattService.SERVICE_TYPE_PRIMARY)
val characteristic = BluetoothGattCharacteristic(UUID.fromString("00002a00-0000-1000-8000-00805f9b34fb"),
BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_NOTIFY,
BluetoothGattCharacteristic.PERMISSION_READ or BluetoothGattCharacteristic.PERMISSION_WRITE)
characteristic.addDescriptor(BluetoothGattDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"), BluetoothGattCharacteristic.PERMISSION_WRITE))
service.addCharacteristic(characteristic)
mBluetoothGattServer?.addService(service)
}
}
// Not sure if this is needed but it never triggers.
override fun onCharacteristicWriteRequest(device: BluetoothDevice?, requestId: Int, characteristic: BluetoothGattCharacteristic?, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray?) {
super.onCharacteristicWriteRequest(device, requestId, characteristic, preparedWrite, responseNeeded, offset, value)
mBluetoothGattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, "Hello".toByteArray())
}
Related
I have a sensor it will notify data with fixed length in different characteristic.
The Char3 only notify data with 48 bytes.
The Char4 only notify data with 240 bytes.
When I use the Redmi note T8 to develop my android app.
I sometimes see 240 bytes in char3 and the 48 bytes in Char4.
The data are exchange. That's so weired
I also use nrfConnect/lightBlue to check the characteristic data change.
and use different mobile device to develop (Samsung SM-T510) with the same code.
I also don't see the situation I mention before.
I also try to add mutex to broadcastUpdate function, but it doesn't work.
Is there anyone face this issue?
And, Here is my related code in Android.
private val mBluetoothGatt0Callback = object : BluetoothGattCallback() {
...
override fun onCharacteristicChanged(
gatt: BluetoothGatt?,
characteristic: BluetoothGattCharacteristic?
) {
super.onCharacteristicChanged(gatt, characteristic)
when (characteristic?.uuid) {
Profile.CHAR3_UUID -> {
broadcastUpdate(Action.IMU_DataComing, gatt!!, characteristic!!)
}
Profile.CHAR4_UUID ->{
broadcastUpdate(Action.EMG_DataComing, gatt!!, characteristic!!)
}
}
}
...
}
private fun broadcastUpdate(action: String, gatt: BluetoothGatt, char: BluetoothGattCharacteristic) {
val intent = Intent(action)
var serialNum = -1;
for (i in 0..3) {
if (gatt == mBluetoothGatt[i]) {
serialNum = i
break;
}
}
intent.putExtra("SerialNum", serialNum)
val msg = char.value
if (action == Action.IMU_DataComing) {
if (msg.size != 48) {
Log.i("Test", "IMU ${msg.size}")
return
}
intent.putExtra("IMU_DATA_MSG", msg)
} else if (action == Action.EMG_DataComing) {
if (msg.size != 240) {
Log.i("Test", "EMG ${msg.size}")
return
}
intent.putExtra("EMG_DATA_MSG", msg)
}
sendBroadcast(intent)
}
I have an android app which scans and connects to a predefined device name. My peripheral is an nRF module which is sending an incrementing data at 1Hz.
However, when I launch the app, it scans and logs multiple connections to the same device and consequently the values received duplicated values. So if there is a log for 3 connections then I get 3 duplicate values and 2 duplicates if there are 2 connections.
D/BluetoothGatt: onClientConnectionState() - status=0 clientIf=14 device=FD:72:38:AA:1A:E7
I/BLE log: Connected to device FD:72:38:AA:1A:E7
D/BluetoothGatt: onClientConnectionState() - status=0 clientIf=15 device=FD:72:38:AA:1A:E7
I/BLE log: Connected to device FD:72:38:AA:1A:E7
Here is my code:
Scanning:
private fun bleScan() {
val scanFilter = ScanFilter.Builder()
.setDeviceName("VEGA-TEST")
.build()
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
bleScanner.startScan(mutableListOf(scanFilter), scanSettings, object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
super.onScanResult(callbackType, result)
with(result.device) {
Log.i("BLE log", "Found device $address")
connectGatt(this#BluetoothService, false ,gattCallback)
}
}
})
}
GATT Connections
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
super.onConnectionStateChange(gatt, status, newState)
val deviceAddr = gatt.device.address
if(status == BluetoothGatt.GATT_SUCCESS) {
when(newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.i("BLE log", "Connected to device $deviceAddr")
bleStopScan()
Handler(Looper.getMainLooper()).post {
gatt.discoverServices()
}
}
BluetoothProfile.STATE_DISCONNECTED -> {
Log.i("BLE log", "Disconnected from device $deviceAddr")
}
else -> {
Log.i("BLE log", "Hit event $newState")
}
}
} else {
Log.e("BLE log", "BLE error $status for $deviceAddr")
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
super.onServicesDiscovered(gatt, status)
with(gatt) {
Log.i("BLE log","Discovered ${services.size} services for ${device.address}")
printGattTable()
enableNotification(gatt)
}
}
}
On the NRF Side i am sending data using a timer
static void txTimer_hander(void *p_context){
//TODO: Handle timeout
// SEND data to Device
//NRF_LOG_INFO("Timer : %d", tx_Data);
memset(val, 0, 7);
if(tx_Data < 50){
// SEND this Data
uint16_t now_data = tx_Data;
val[0] = 0xFF;
val[5] = now_data;
//memcpy(&val[0], (uint16_t*)0xFF, 1);
//memcpy(&val[6], &now_data, 2);
//val[0] = now_data;
//
tx_Data++;
}else{
tx_Data = 0;
// SEND this Data
uint16_t now_data = tx_Data;
//memcpy(&val[0], (uint16_t*)0xFF, 1);
val[0] = 0xFF;
val[5] = now_data;
//memcpy(&val[6], &now_data, 2);
//
tx_Data++;
}
NRF_LOG_INFO("Sending %d", tx_Data);
send_nus_data(val, 7);
}
NOTE I am using Android 12 and testing using Android Studio
I would appreciate any help!
Thanks
If your phone catches two advertisement packets, it will create two logical connections. You should check before you call connectGatt if you have already called connectGatt, and if so don't connect again. You should also stop the scan if you don't need the scan to continue when you have already found a device.
I want to scan and bond with nearby android smartwatches which are running WearOS. I followed the instruction here using CompanionDeviceManager: https://developer.android.com/guide/topics/connectivity/companion-device-pairing
The CompanionDeviceManager always shows a list of all device types including other mobile phones, laptops, bluetooth speakers, and watches (Both bonded and unpaired). Comparing to Google Wear OS app, it also use CompanionDeviceManager (since I saw the same popup UI) and it always show the Android Watch only.
How does it filter the smartwatch among plenty of other bluetooth devices?
Here is my code:
private fun scanLeDevice(enable: Boolean) {
val deviceFilter: BluetoothLeDeviceFilter = BluetoothLeDeviceFilter.Builder()
.build()
// The argument provided in setSingleDevice() determines whether a single
// device name or a list of device names is presented to the user as
// pairing options.
val pairingRequest: AssociationRequest = AssociationRequest.Builder()
.setSingleDevice(false)
.addDeviceFilter(deviceFilter)
.build()
// When the app tries to pair with the Bluetooth device, show the
// appropriate pairing request dialog to the user.
deviceManager.associate(pairingRequest,
object : CompanionDeviceManager.Callback() {
override fun onDeviceFound(chooserLauncher: IntentSender) {
startIntentSenderForResult(chooserLauncher,
Companion.SELECT_DEVICE_REQUEST_CODE, null, 0, 0, 0)
}
override fun onFailure(error: CharSequence?) {
// Handle failure
}
}, null)
}
Remark: This isn't completed answer since I don't know what's exactly filter-mask for each device type, however at least it can help to filter the Android smartwatch
I decompiled the Google WearOS app and looking at source file FindDeviceControllerSdk26.java. Here are interested things:
private static final byte[] BLE_WEAR_MATCH_DATA = { 0, 20 };
private static final byte[] BLE_WEAR_MATCH_DATA_LEGACY = { 0, 19 };
private static final byte[] BLE_WEAR_MATCH_MASK = { 0, -1 };
private final void showAssociationDialog()
{
Object localObject1 = new ArrayList();
Object localObject3 = new FindDeviceDeviceFilterBuilder();
medium = 1;
Object localObject4 = new ScanFilter.Builder();
byte[] arrayOfByte = BLE_WEAR_MATCH_DATA;
Object localObject2 = BLE_WEAR_MATCH_MASK;
scanFilter = ((ScanFilter.Builder)localObject4).setManufacturerData(224, arrayOfByte, (byte[])localObject2).build();
FindDeviceAssociationRequestBuilder.unbox_addDeviceFilter$ar$ds((FindDeviceDeviceFilterBuilder)localObject3, (List)localObject1);
localObject3 = new FindDeviceDeviceFilterBuilder();
medium = 1;
scanFilter = new ScanFilter.Builder().setManufacturerData(224, BLE_WEAR_MATCH_DATA_LEGACY, (byte[])localObject2).build();
FindDeviceAssociationRequestBuilder.unbox_addDeviceFilter$ar$ds((FindDeviceDeviceFilterBuilder)localObject3, (List)localObject1);
if (!deviceWhitelist.shouldShowAllDevices())
{
localObject2 = (GServicesFindDeviceWhitelist)deviceWhitelist;
((GServicesFindDeviceWhitelist)localObject2).initializeIfNecessary();
localObject2 = cachedDeviceWhitelistRegularExpression;
if (!TextUtils.isEmpty((CharSequence)localObject2))
{
localObject3 = new FindDeviceDeviceFilterBuilder();
medium = 0;
namePatternString = ((String)localObject2);
FindDeviceAssociationRequestBuilder.unbox_addDeviceFilter$ar$ds((FindDeviceDeviceFilterBuilder)localObject3, (List)localObject1);
}
}
else
{
localObject2 = new FindDeviceDeviceFilterBuilder();
medium = 0;
namePatternString = ".*";
FindDeviceAssociationRequestBuilder.unbox_addDeviceFilter$ar$ds((FindDeviceDeviceFilterBuilder)localObject2, (List)localObject1);
}
localObject2 = new AssociationRequest.Builder().setSingleDevice(false);
localObject3 = ((List)localObject1).iterator();
while (((Iterator)localObject3).hasNext())
{
localObject1 = (FindDeviceDeviceFilterBuilder)((Iterator)localObject3).next();
if (medium != 0)
{
localObject4 = new BluetoothLeDeviceFilter.Builder();
localObject1 = scanFilter;
if (localObject1 != null) {
((BluetoothLeDeviceFilter.Builder)localObject4).setScanFilter((ScanFilter)localObject1);
}
localObject1 = ((BluetoothLeDeviceFilter.Builder)localObject4).build();
}
else
{
localObject4 = new BluetoothDeviceFilter.Builder();
if (!Platform.stringIsNullOrEmpty(namePatternString)) {
((BluetoothDeviceFilter.Builder)localObject4).setNamePattern(Pattern.compile(namePatternString));
}
localObject1 = ((BluetoothDeviceFilter.Builder)localObject4).build();
}
((AssociationRequest.Builder)localObject2).addDeviceFilter((DeviceFilter)localObject1);
}
localObject1 = ((AssociationRequest.Builder)localObject2).build();
viewClient.hideRefreshButton();
cwEventLogger.incrementCounter(Counter.COMPANION_PAIR_CDM_ASSOCIATE_REQUEST);
companionDeviceManager.associate((AssociationRequest)localObject1, deviceManagerCallback, null);
}
I then rewrite my scan function as following
private val BLE_WEAR_MATCH_DATA = byteArrayOf(0, 20)
private val BLE_WEAR_MATCH_DATA_LEGACY = byteArrayOf(0, 19)
private val BLE_WEAR_MATCH_MASK = byteArrayOf(0, -1)
private fun scanLeDevice(enable: Boolean) {
val scanFilter = ScanFilter.Builder().setManufacturerData(224,BLE_WEAR_MATCH_DATA_LEGACY,BLE_WEAR_MATCH_MASK).build()
val deviceFilter: BluetoothLeDeviceFilter = BluetoothLeDeviceFilter.Builder()
.setScanFilter(scanFilter)
.setNamePattern(Pattern.compile(".*"))
.build()
// The argument provided in setSingleDevice() determines whether a single
// device name or a list of device names is presented to the user as
// pairing options.
val pairingRequest: AssociationRequest = AssociationRequest.Builder()
.setSingleDevice(false)
.addDeviceFilter(deviceFilter)
.build()
// When the app tries to pair with the Bluetooth device, show the
// appropriate pairing request dialog to the user.
deviceManager.associate(pairingRequest,
object : CompanionDeviceManager.Callback() {
override fun onDeviceFound(chooserLauncher: IntentSender) {
startIntentSenderForResult(chooserLauncher,
Companion.SELECT_DEVICE_REQUEST_CODE, null, 0, 0, 0)
}
override fun onFailure(error: CharSequence?) {
// Handle failure
}
}, null)
}
I would hope someone can help to describe what do these maskes stand for, and then I will help to update to make this answer completely.
private val BLE_WEAR_MATCH_DATA = byteArrayOf(0, 20)
private val BLE_WEAR_MATCH_DATA_LEGACY = byteArrayOf(0, 19)
private val BLE_WEAR_MATCH_MASK = byteArrayOf(0, -1)
Please look at that article: https://medium.com/#martijn.van.welie/making-android-ble-work-part-1-a736dcd53b02
TL;TR
String[] names = new String[]{"Polar H7 391BB014"};
List<ScanFilter> filters = null;
if(names != null) {
filters = new ArrayList<>();
for (String name : names) {
ScanFilter filter = new ScanFilter.Builder()
.setDeviceName(name)
.build();
filters.add(filter);
}
}
scanner.startScan(filters, scanSettings, scanCallback);
You should be able to add filters in your https://developer.android.com/reference/android/companion/BluetoothLeDeviceFilter.Builder.
For masks, take a look at https://developer.android.com/reference/android/bluetooth/le/ScanFilter.Builder#setManufacturerData(int,%20byte[],%20byte[]), For any bit in the mask, set it the 1 if it needs to match the one in manufacturer data, otherwise set it to 0
I'm trying to implement an app for transfer some strings between ble devices (for now one device act as central and the other one as pheripheral) but without success.
This is how my peripheral (server) is set up.
Characteristic build
fun buildCharacteristic(
characteristicUUID: UUID,
): BluetoothGattCharacteristic {
var properties = BluetoothGattCharacteristic.PROPERTY_READ or
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or
BluetoothGattCharacteristic.PROPERTY_NOTIFY
var permission = BluetoothGattCharacteristic.PERMISSION_READ or
BluetoothGattCharacteristic.PERMISSION_WRITE
var characteristic = BluetoothGattCharacteristic(
characteristicUUID,
properties,
permission
)
return characteristic
}
service build
fun buildService(
serviceUUID: UUID,
serviceType: Int,
characteristics: List<BluetoothGattCharacteristic>
) {
bluetoothGattService = BluetoothGattService(
serviceUUID,
BluetoothGattService.SERVICE_TYPE_PRIMARY
)
for (characteristic in characteristics) {
bluetoothGattService.addCharacteristic(characteristic)
}
}
and this is how i start ble server (i omit implementation of callbacks)
fun startServer(
bleAdapter: BluetoothAdapter,
btManager: BluetoothManager,
context: Context
) {
bleAdvertiser = bleAdapter.bluetoothLeAdvertiser
bleGattServer = btManager.openGattServer(context, gattServerCallback)
bleGattServer.addService(bluetoothGattService)
var settings = AdvertiseSettings.Builder().apply {
setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
setConnectable(true)
setTimeout(0)
setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
}
var data = AdvertiseData.Builder().apply {
setIncludeDeviceName(true)
}
bleAdvertiser.startAdvertising(settings.build(), data.build(), advertiseCallback)
}
On central (client) side, when onScanResult is triggered, i try to connect with device:
fun connectToDevice(device: BluetoothDevice) {
device.connectGatt(
context,
false,
createGattCallback()
)
}
where createGattCallback() is a function return a BluetoothGattCallback object. Inside this callback, when onConnectionStateChange is called, i call service discover, and when service is discovered i try do write data to peripheral
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
super.onServicesDiscovered(gatt, status)
if (gatt?.services != null) {
var serviceFound = false
for (service in gatt.services) {
if (service.uuid == Consts.SERVICE_UUID) {
serviceFound = true
var bluetoothGattCharacteristic = service.getCharacteristic(Consts.CHARACTERISTIC_UUID)
writeCharacteristic(
gatt,
bluetoothGattCharacteristic
)
}
}
if (!serviceFound) {
gatt.disconnect()
}
}
}
fun writeCharacteristic(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
var toSendString = "A random string for testing purpose only"
var toSendByteArray = toSendString.toByteArray(Charsets.UTF_8)
val chunkSize = 18
val numberOfPackets = ceil(
(toSendByteArray.size).toDouble() / chunkSize.toDouble()
)
for (i in 0 until numberOfPackets.toInt()) {
var startIndex = i * chunkSize
var endIndex = if (startIndex + chunkSize <= toSendByteArray.size) {
startIndex + chunkSize
} else {
toSendByteArray.size
}
var packet = toSendByteArray.copyOfRange(startIndex, endIndex)
characteristic.value = packet
gatt.writeCharacteristic(characteristic)
Thread.sleep(250)
}
}
My code seems not workin, on peripheral i don't receive entire string, but only the first 18 bytes. Where i'm wrong?
You need to wait for onCharacteristicWrite before you can send the next value. See Android BLE BluetoothGatt.writeDescriptor() return sometimes false.
And your sleep won't solve anything.
I want to read blood pressure from Medisana device via bluetooth on my Android device.
Here is my code
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
super.onServicesDiscovered(gatt, status)
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.e(TAG, "onServicesDiscovered failed")
return
}
val characteristic = gatt?.getService(BLOOD_PRESSURE_SERVICE_UUID)?.
getCharacteristic(BLOOD_PRESSURE_CHARACTERISTICS_UUID)
if (characteristic == null) {
Log.e(TAG, "blood pressure measurement characteristics is not supported by the device")
} else {
gatt.setCharacteristicNotification(characteristic, true)
val descriptor = characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
gatt.writeDescriptor(descriptor)
if (!gatt.readCharacteristic(characteristic)) {
Log.e(TAG, "Unable to read characteristic")
}
}
}
Here are the constants I copied them from an official Bluetooth website
private val BLOOD_PRESSURE_SERVICE_UUID = UUIDUtils.convertFromInteger(0x1810)
private val BLOOD_PRESSURE_CHARACTERISTICS_UUID = UUIDUtils.convertFromInteger(0x2A35)
object UUIDUtils {
fun convertFromInteger(i: Int): UUID {
val MSB = 0x0000000000001000L
val LSB = -0x7fffff7fa064cb05L
val value = (i and -0x1).toLong()
return UUID(MSB or (value shl 32), LSB)
}
}
When the code is executed I see "Unable to read characteristic" in the logs. When I debug readCharacteristic it returns in the line
public boolean readCharacteristic(BluetoothGattCharacteristic characteristic) {
if ((characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_READ) == 0) {
return false;
}
So the characteristic is not readable, but how to make it readable. I am new in Bluetooth and all this stuff makes me confusing.
Try this
According to the document, blood pressure need to add indication enable to get the data.
So you need to:
First, add "ENABLE_INDICATION_VALUE" to the CLIENT_CHARACTERISTIC_CONFIG descriptor.
This descriptor come from:
descriptor = characteristic.getDescriptor(
UUID.fromString(CLIENT_CHARACTERISTIC_CONFIG)); //CLIENT_CHARACTERISTIC_CONFIG equal to 00002902-0000-1000-8000-00805f9b34fb
Then:
descriptor.setValue(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE);
And last step, write the descriptor to remote device (pressure master)
like this
mBluetoothGatt.writeDescriptor(descriptor);
This work for me, I get the blood pressure data from the devices.
I'm not a native English speaker, sorry for my poor English