Patricia
Published © CC BY

Bluetooth Remote Control (Android) for Windows IoT Devices

Remote control example for controlling Windows IoT devices directly over Bluetooth from Android handhelds.

IntermediateProtip3 hours7,222
Bluetooth Remote Control (Android) for Windows IoT Devices

Things used in this project

Story

Read more

Schematics

bluetooth_led_steckplatine_MNFvEJDzOT.jpg

pin layout for led

Code

Pi Device/"Server" Code

C#
Device-side class, responsible for accepting bluetooth connections and light switching. It runs on Raspberry Pi as an UWP application.
using System;
using System.Diagnostics;
using Windows.Devices.Bluetooth;
using Windows.Devices.Bluetooth.Rfcomm;
using Windows.Devices.Gpio;
using Windows.Networking.Sockets;
using Windows.Storage.Streams;
using Windows.UI.Xaml.Controls;

namespace BluetoothHeadedServer
{
    /// <summary>
    /// An empty page that can be used on its own or navigated to within a Frame.
    /// </summary>
    public sealed partial class MainPage : Page
    {
        private StreamSocket socket;
        private DataWriter writer;
        private RfcommServiceProvider rfcommProvider;
        private StreamSocketListener socketListener;
        private readonly GpioPin lightPin;

        public MainPage()
        {
            var gpioController = GpioController.GetDefault();
            lightPin = gpioController.OpenPin(21);
            lightPin.SetDriveMode(GpioPinDriveMode.Output);
            
            this.InitializeComponent();
            this.InitializeRfcommServer();
        }

        /// <summary>
        /// Initializes the server using RfcommServiceProvider to advertise the Chat Service UUID and start listening
        /// for incoming connections.
        /// </summary>
        private async void InitializeRfcommServer()
        {
            try
            {
                rfcommProvider = await RfcommServiceProvider.CreateAsync(RfcommServiceId.FromUuid(Constants.RfcommDeviceServiceUuid));
            }
            // Catch exception HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_AVAILABLE).
            catch (Exception ex) when ((uint)ex.HResult == 0x800710DF)
            {
                Debug.Write("Make sure your Bluetooth Radio is on: " + ex.Message);
                return;
            }

            // Create a listener for this service and start listening
            socketListener = new StreamSocketListener();
            socketListener.ConnectionReceived += OnConnectionReceived;
            var rfcomm = rfcommProvider.ServiceId.AsString();

            await socketListener.BindServiceNameAsync(rfcommProvider.ServiceId.AsString(),
                SocketProtectionLevel.BluetoothEncryptionAllowNullAuthentication);

            // Set the SDP attributes and start Bluetooth advertising
            InitializeServiceSdpAttributes(rfcommProvider);

            try
            {
                rfcommProvider.StartAdvertising(socketListener, true);
            }
            catch (Exception e)
            {
                Debug.Write(e);
                return;
            }

            Debug.Write("Listening for incoming connections");
        }

        /// <summary>
        /// Creates the SDP record that will be revealed to the Client device when pairing occurs.
        /// </summary>
        /// <param name="rfcommProvider">The RfcommServiceProvider that is being used to initialize the server</param>
        private void InitializeServiceSdpAttributes(RfcommServiceProvider rfcommProvider)
        {
            var sdpWriter = new DataWriter();

            // Write the Service Name Attribute.
            sdpWriter.WriteByte(Constants.SdpServiceNameAttributeType);

            // The length of the UTF-8 encoded Service Name SDP Attribute.
            sdpWriter.WriteByte((byte)Constants.SdpServiceName.Length);

            // The UTF-8 encoded Service Name value.
            sdpWriter.UnicodeEncoding = Windows.Storage.Streams.UnicodeEncoding.Utf8;
            sdpWriter.WriteString(Constants.SdpServiceName);

            // Set the SDP Attribute on the RFCOMM Service Provider.
            rfcommProvider.SdpRawAttributes.Add(Constants.SdpServiceNameAttributeId, sdpWriter.DetachBuffer());
        }

        private async void SendMessage(string message)
        {
            // Make sure that the connection is still up and there is a message to send
            if (socket != null)
            {
                writer.WriteInt32((int)message.Length);
                writer.WriteString(message);

                await writer.StoreAsync();
            }
            else
            {
                Debug.Write("No clients connected, please wait for a client to connect before attempting to send a message");
            }
        }

        private async void Disconnect()
        {
            if (writer != null)
            {
                writer.DetachStream();
                writer = null;
            }

            if (socket != null)
            {
                socket.Dispose();
                socket = null;
            }
            Debug.Write("Disconected");
        }

        /// <summary>
        /// Invoked when the socket listener accepts an incoming Bluetooth connection.
        /// </summary>
        /// <param name="sender">The socket listener that accepted the connection.</param>
        /// <param name="args">The connection accept parameters, which contain the connected socket.</param>
        private async void OnConnectionReceived(
            StreamSocketListener sender, StreamSocketListenerConnectionReceivedEventArgs args)
        {
            Debug.WriteLine("Connection received");
            try
            {
                socket = args.Socket;
            }
            catch (Exception e)
            {
                Debug.Write(e);
                Disconnect();
                return;
            }

            // Note - this is the supported way to get a Bluetooth device from a given socket
            var remoteDevice = await BluetoothDevice.FromHostNameAsync(socket.Information.RemoteHostName);

            writer = new DataWriter(socket.OutputStream);
            var reader = new DataReader(socket.InputStream);
            bool remoteDisconnection = false;

            Debug.Write("Connected to Client: " + remoteDevice.Name);

            var value = lightPin.Read();
            if (value == GpioPinValue.High)
            {
                SendMessage($"Hi! Light is curently on!");
            }
            else
            {
                SendMessage($"Hi! Light is curently off!");
            }
            
            // Infinite read buffer loop
            while (true)
            {
                try
                {
                    // Based on the protocol we've defined, the first uint is the size of the message
                    uint readLength = await reader.LoadAsync(sizeof(uint));

                    // Check if the size of the data is expected (otherwise the remote has already terminated the connection)
                    if (readLength < sizeof(uint))
                    {
                        remoteDisconnection = true;
                        break;
                    }

                    var currentLength = reader.ReadUInt32();

                    // Load the rest of the message since you already know the length of the data expected.
                    readLength = await reader.LoadAsync(currentLength);

                    // Check if the size of the data is expected (otherwise the remote has already terminated the connection)
                    if (readLength < currentLength)
                    {
                        remoteDisconnection = true;
                        break;
                    }
                    string message = reader.ReadString(currentLength);

                    if (message.Equals("on", StringComparison.CurrentCultureIgnoreCase))
                    {
                        lightPin.Write(GpioPinValue.High);
                    }
                    else if (message.Equals("off", StringComparison.CurrentCultureIgnoreCase))
                    {
                        lightPin.Write(GpioPinValue.Low);
                    }

                    Debug.Write("Received: " + message);
                }
                // Catch exception HRESULT_FROM_WIN32(ERROR_OPERATION_ABORTED).
                catch (Exception ex) when ((uint)ex.HResult == 0x800703E3)
                {
                    Debug.Write("Client Disconnected Successfully");
                    break;
                }
            }

            reader.DetachStream();
            if (remoteDisconnection)
            {
                Disconnect();
                Debug.Write("Client disconnected");
            }
        }
    }
}

MainActivity.java

Java
MainActivity, managing ui
package com.patricia.bluetoothremote;

import android.content.SharedPreferences;
import android.os.Handler;
import android.os.Message;
import android.support.design.widget.TabLayout;
import android.support.design.widget.FloatingActionButton;
import android.support.design.widget.Snackbar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;

import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentPagerAdapter;
import android.support.v4.view.ViewPager;
import android.os.Bundle;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Toast;

public class MainActivity extends AppCompatActivity {

    public static final String PREFS_NAME = "com.patricia.bluetoothremote.settings";
    private static final String TAG = "MainAcivity";
    /**
     * The {@link android.support.v4.view.PagerAdapter} that will provide
     * fragments for each of the sections. We use a
     * {@link FragmentPagerAdapter} derivative, which will keep every
     * loaded fragment in memory. If this becomes too memory intensive, it
     * may be best to switch to a
     * {@link android.support.v4.app.FragmentStatePagerAdapter}.
     */
    private SectionsPagerAdapter mSectionsPagerAdapter;

    /**
     * The {@link ViewPager} that will host the section contents.
     */
    private ViewPager mViewPager;
    public static SharedPreferences sharedPreferences;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        // Create the adapter that will return a fragment for each of the three
        // primary sections of the activity.
        mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager());

        // Set up the ViewPager with the sections adapter.
        mViewPager = (ViewPager) findViewById(R.id.container);
        mViewPager.setAdapter(mSectionsPagerAdapter);

        TabLayout tabLayout = (TabLayout) findViewById(R.id.tabs);
        tabLayout.setupWithViewPager(mViewPager);

        FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);

        fab.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
                        .setAction("Action", null).show();
            }
        });

        BluetoothService bs = BluetoothService.getInstance();
        bs.registerNewHandlerCallback(new Handler.Callback() {
            @Override
            public boolean handleMessage(Message msg) {
                try {
                    Log.d(TAG, "got a notification in " + Thread.currentThread());
                    String toasttext = "";
                    if (msg.what == BluetoothService.MessageConstants.MESSAGE_READ){
                        toasttext = "Reading from device: ";
                    }
                    else if (msg.what == BluetoothService.MessageConstants.MESSAGE_TOAST) {
                        toasttext = "Info: ";
                    }
                    else if (msg.what == BluetoothService.MessageConstants.MESSAGE_WRITE) {
                        toasttext = "Sending to device: ";
                    }
                    toasttext += msg.obj.toString();

                    Toast.makeText(getApplicationContext(), toasttext, Toast.LENGTH_LONG).show();
                } catch (Throwable t) {
                    Log.e(TAG,null, t);
                }

                return false;
            }
        });

        sharedPreferences = getSharedPreferences(PREFS_NAME, 0);
    }


    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        // Inflate the menu; this adds items to the action bar if it is present.
        getMenuInflater().inflate(R.menu.menu_main, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        int id = item.getItemId();

        //noinspection SimplifiableIfStatement
        if (id == R.id.action_settings) {
            return true;
        }

        return super.onOptionsItemSelected(item);
    }

    /**
     * A {@link FragmentPagerAdapter} that returns a fragment corresponding to
     * one of the sections/tabs/pages.
     */
    public class SectionsPagerAdapter extends FragmentPagerAdapter {

        public SectionsPagerAdapter(FragmentManager fm) {
            super(fm);
        }

        @Override
        public Fragment getItem(int position) {
            // getItem is called to instantiate the fragment for the given page.
            // Return a ConnectFragment (defined as a static inner class below).
            switch(position) {
                case 0:
                    return ConnectFragment.newInstance();
                case 1:
                default:
                    return CommandsFragment.newInstance();
            }

        }

        @Override
        public int getCount() {
            // Show 3 total pages.
            return 2;
        }

        @Override
        public CharSequence getPageTitle(int position) {
            switch (position) {
                case 0:
                    return "Connect";
                case 1:
                    return "Commands";
            }
            return null;
        }
    }
}

ConnectFragment.java

Java
Handles ui part for connecting to device.
package com.patricia.bluetoothremote;

import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.support.v4.app.Fragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ListView;
import android.widget.TextView;
import java.util.Set;

public class ConnectFragment extends Fragment {
    public static final String PREFS_NAME = "com.patricia.bluetoothremote.settings";

    private Button mScanButton;
    private ListView mDeviceList;
    private Button mConnectButton;
    private ArrayAdapter<String> mDeviceListAdapter;
    private BluetoothAdapter mBluetoothAdapter;
    private Set<BluetoothDevice> mPairedDevices;
    private BluetoothDevice mSelectedDevice;
    private Button mReconnectButton;
    private String mLastDevice = null;

    public ConnectFragment() {
    }

    /**
     * Returns a new instance of this fragment for the given section
     * number.
     */
    public static ConnectFragment newInstance() {
        ConnectFragment fragment = new ConnectFragment();
        return fragment;
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View rootView = inflater.inflate(R.layout.fragment_connect, container, false);
        mScanButton = (Button) rootView.findViewById(R.id.btnScan);
        mDeviceListAdapter = new ArrayAdapter<String>(getContext(), R.layout.list_device);
        mDeviceList = (ListView) rootView.findViewById(R.id.lsvDevices);
        mDeviceList.setAdapter(mDeviceListAdapter);
        mConnectButton = (Button) rootView.findViewById(R.id.connect_button);
        mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        mReconnectButton = (Button) rootView.findViewById(R.id.reconnectbutton);

        mScanButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mConnectButton.setVisibility(View.GONE);
                mDeviceListAdapter.clear();

                mPairedDevices = mBluetoothAdapter.getBondedDevices();
                if (mPairedDevices.size() > 0) {
                    // There are paired devices. Get the name and address of each paired device.
                    for (BluetoothDevice device : mPairedDevices) {
                        String deviceName = device.getName();
                        mDeviceListAdapter.add(deviceName);
                    }
                }
            }
        });

        mDeviceList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                    String deviceName = ((TextView)view).getText().toString();
                    mConnectButton.setText("Connect " + deviceName);
                    mConnectButton.setVisibility(View.VISIBLE);
                    for (BluetoothDevice device : mPairedDevices) {
                        if (device.getName().equalsIgnoreCase(deviceName)) {
                            mSelectedDevice = device;
                        }
                    }
            }
        });

        mConnectButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                connectToDevice(mSelectedDevice);
                SharedPreferences.Editor editor = MainActivity.sharedPreferences.edit();
                editor.putString("lastConnectedDevice", mSelectedDevice.getName());
                editor.commit();
            }
        });

        String lastDevice = MainActivity.sharedPreferences.getString("lastConnectedDevice", null);
        if (lastDevice != null) {
            mReconnectButton.setText("Reconnect " + lastDevice);
            mLastDevice = lastDevice;
            mReconnectButton.setEnabled(true);
        } else  {
            mReconnectButton.setEnabled(false);
        }
        mReconnectButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                reconnect();
            }
        });
        return rootView;
    }

    private void reconnect() {
        if (mLastDevice != null) {
            Set<BluetoothDevice> devices = mBluetoothAdapter.getBondedDevices();
            for (BluetoothDevice device : devices) {
                if (device.getName().equalsIgnoreCase(mLastDevice)) {
                    connectToDevice(device);
                    break;
                }
            }
        } else  {
            mReconnectButton.setEnabled(false);
        }
    }

    private void connectToDevice(BluetoothDevice device) {
        BluetoothService bluetooth = BluetoothService.getInstance();
        bluetooth.connectToDevice(device);
    }
}

CommandsFragment.java

Java
Handles triggering commands upon UI interactions. Send on or off when a button is pressed.
package com.patricia.bluetoothremote;

import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;


public class CommandsFragment extends Fragment {
    public static CommandsFragment newInstance() {
        CommandsFragment fragment = new CommandsFragment();
        return fragment;
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View rootView = inflater.inflate(R.layout.fragment_commands, container, false);

        Button buttonOn = (Button) rootView.findViewById(R.id.button_on);
        buttonOn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                sendCommand("ON");
            }
        });

        Button buttonOff = (Button) rootView.findViewById(R.id.button_off);
        buttonOff.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                sendCommand("OFF");
            }
        });

        return rootView;
    }

    private void sendCommand(String command) {
        BluetoothService bluetooth = BluetoothService.getInstance();
        boolean sent =  bluetooth.send(command);
    }
}

BluetoothService.java

Java
Handles actual connecting and sending messages to device. Has two nested classes: ConnectThread, ConnectedThread.
package com.patricia.bluetoothremote;

import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothSocket;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.util.Log;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.util.UUID;

public class BluetoothService {
    public static final UUID SERVICE_UUID = UUID.fromString("34B1CF4D-1069-4AD6-89B6-E161D79BE4D9");
    private static final String TAG = "BluetoothService";
    private static BluetoothService instance = new BluetoothService();
    private ConnectThread mConnectThread;
    private ConnectedThread mConnectedThread;
    private Handler mHandler; // handler that gets info from Bluetooth service

    private BluetoothService() {
        mHandler = new Handler(new Handler.Callback() {
            @Override
            public boolean handleMessage(Message msg) {
                Log.d(TAG, "got a notification in " + Thread.currentThread());
                return false;
            }
        });
    }

    public static BluetoothService getInstance() {
        return instance;
    }

    public void connectToDevice(BluetoothDevice device) {
        //close existing connections
        if (mConnectedThread != null) mConnectedThread.cancel();
        if (mConnectThread != null) mConnectThread.cancel();

        mConnectThread = new ConnectThread(device);
        mConnectThread.start();
    }

    private void manageMyConnectedSocket(BluetoothSocket mmSocket) {
        if (mmSocket.isConnected()) {
            mConnectedThread = new ConnectedThread(mmSocket);
            mConnectedThread.start();
        }
    }

    public boolean send(String command) {
        return mConnectedThread.write(command);
    }

    public void registerNewHandlerCallback(Handler.Callback callback) {
        mHandler = new Handler(callback );
    }

    // Defines several constants used when transmitting messages between the
    // service and the UI.
    public interface MessageConstants {
        public static final int MESSAGE_READ = 0;
        public static final int MESSAGE_WRITE = 1;
        public static final int MESSAGE_TOAST = 2;
    }

    private class ConnectThread extends Thread {
        private final BluetoothSocket mmSocket;
        private final BluetoothDevice mmDevice;

        public ConnectThread(BluetoothDevice device) {
            // Use a temporary object that is later assigned to mmSocket
            // because mmSocket is final.
            BluetoothSocket tmp = null;
            mmDevice = device;
            try {
                // Get a BluetoothSocket to connect with the given BluetoothDevice.
                // SERVICE_UUID is the app's UUID string, also used in the server code.
                tmp = device.createRfcommSocketToServiceRecord(SERVICE_UUID);
            } catch (IOException e) {
                Log.e(TAG, "Socket's create() method failed", e);
            }
            mmSocket = tmp;
        }

        public void run() {
            try {
                // Connect to the remote device through the socket. This call blocks
                // until it succeeds or throws an exception.
                mmSocket.connect();
            } catch (IOException connectException) {
                // Unable to connect; close the socket and return.
                try {
                    mmSocket.close();
                } catch (IOException closeException) {
                    Log.e(TAG, "Could not close the client socket", closeException);
                }
                return;
            }

            // The connection attempt succeeded. Perform work associated with
            // the connection in a separate thread.
            manageMyConnectedSocket(mmSocket);
        }

        // Closes the client socket and causes the thread to finish.
        public void cancel() {
            try {
                mmSocket.close();
            } catch (IOException e) {
                Log.e(TAG, "Could not close the client socket", e);
            }
        }
    }

    private class ConnectedThread extends Thread {
        private final BluetoothSocket mmSocket;
        private final InputStream mmInStream;
        private final OutputStream mmOutStream;

        public ConnectedThread(BluetoothSocket socket) {
            mmSocket = socket;
            InputStream tmpIn = null;
            OutputStream tmpOut = null;

            // Get the input and output streams; using temp objects because
            // member streams are final.
            try {
                tmpIn = socket.getInputStream();
            } catch (IOException e) {
                Log.e(TAG, "Error occurred when creating input stream", e);
            }
            try {
                tmpOut = socket.getOutputStream();
            } catch (IOException e) {
                Log.e(TAG, "Error occurred when creating output stream", e);
            }

            mmInStream = tmpIn;
            mmOutStream = tmpOut;
        }

        public void run() {
            // Keep listening to the InputStream until an exception occurs.
            while (true) {
                try {
                    // Read from the InputStream.
                    byte[] buffer = new byte[4];
                    int read = mmInStream.read(buffer);
                    if (read < 4) {
                        this.cancel();
                    }
                    int dataLength = ByteBuffer.wrap(buffer).getInt();

                    buffer = new byte[dataLength];
                    read = mmInStream.read(buffer);
                    if (read < dataLength) {
                        this.cancel();
                    }

                    String command = new String(buffer);

                    // Send the obtained bytes to the UI activity.
                    Message readMsg = mHandler.obtainMessage(
                            MessageConstants.MESSAGE_READ, -1, -1,
                            command);
                    readMsg.sendToTarget();
                } catch (IOException e) {
                    Log.d(TAG, "Input stream was disconnected", e);
                    break;
                }
            }
        }

        // Call this from the main activity to send data to the remote device.
        public boolean write(String command) {
            try {
                // Allocate bytes for integer indicating size and message itself
                ByteBuffer bb = ByteBuffer.allocate(4 + command.length())
                        .putInt(command.length())
                        .put(command.getBytes());

                mmOutStream.write(bb.array());
                mmOutStream.flush();

                // Share the sent message with the UI activity.
                Message writtenMsg = mHandler.obtainMessage(MessageConstants.MESSAGE_WRITE, -1, -1, command);
                writtenMsg.sendToTarget();
                return true;
            } catch (IOException e) {
                Log.e(TAG, "Error occurred when sending data", e);

                // Send a failure message back to the activity.
                Message writeErrorMsg =
                        mHandler.obtainMessage(MessageConstants.MESSAGE_TOAST);
                Bundle bundle = new Bundle();
                bundle.putString("toast",
                        "Couldn't send data to the other device");
                writeErrorMsg.setData(bundle);
                mHandler.sendMessage(writeErrorMsg);
            }
            return false;
        }

        // Call this method from the main activity to shut down the connection.
        public void cancel() {
            try {
                mmSocket.close();
            } catch (IOException e) {
                Log.e(TAG, "Could not close the connect socket", e);
            }
        }
    }
}

AndroidToWinIoTBluetoothRemote

My hackster GitHub repository. Folder AndroidToWinIoTBluetoothRemote contains this sample.

Credits

Patricia

Patricia

5 projects • 18 followers
A c# developer. As child i used to play with electronics and this source of fun is living up again in me, as we enter the age of IoT.

Comments