Today, we are creating an Android application that connects with esp32 through web sockets and controls a relay.
We need to connect two individual devices with distinct operation systems, so we need to use a common protocol for communication. There are many ways to do it, but I used WebSockets to achieve faster responses.
Why WebSockets?HTTP requests work like small talk in the elevator.
- *enters elevator*
- Hello!
- Hello!
- How are you?
- I am good!
- *leaves elevator*
We can receive a lot of data, but the session is limited to a short time (I don’t want to use a long pooling-ish solution either).
If we want to turn on/off our device at any given moment — we need something more persistent. WebSockets are like a conversation in a pub when you have plenty of time to chat and can share and receive information until network error (and errors could even be in a pub) 🙂
WebSockets fit our needs, and there are some solutions, like Ktor, for Android.
System designLet’s split this task into baby steps and then implement the solution. We will require:
- Android WebSocket app with a Ktor server
- ESP32 with Wi-Fi
- ESP32 WebSocket client
- ESP32 relay switching
In a nutshell, an Android app starts a WebSockets server. ESP32 connects to the Android app and sends commands to a relay device. Quite straightforward.
Android Ktor WebSockets serverFirst things first: an Android project. You shouldn’t have issues creating a project, so let’s concentrate on a WebSockets server. The following code will do it:
@Singleton
class WebSocketServer @Inject constructor() {
// Hold a reference to websocket server
private val engine = AtomicReference<NettyApplicationEngine>()
// Hold a mutable list of connected devices
private val _connectedDevices = MutableStateFlow<List<ConnectedDevice>>(listOf())
// Expose an immutable list of connected devices
val connectedDevices = _connectedDevices.asStateFlow()
// We don't want to block a Main Thread
private val scope = CoroutineScope(Dispatchers.IO)
// Store sockets to be able to send a message
private val openedSockets = ConcurrentHashMap<String, DefaultWebSocketServerSession>()
// Store devices states
private val relayStates = ConcurrentHashMap<String, Boolean>()
// Some magic to synchronize threads
private val mutex = Mutex()
/**
* Starts a WebSockets server
*/
fun start() = scope.launch {
engine.set(
// Starting webserver
embeddedServer(Netty, port = PORT) {
// Installing WebSockets plug in
install(WebSockets)
// Create a routing map
routing {
// We need only main socket, connected to WS://IP:PORT/
webSocket("/") {
// Create a UUID for a connected device
val uuid = UUID.randomUUID().toString()
// Send OK message
send("OK")
mutex.withLock {
// Store socket connection and a device state
openedSockets[uuid] = this
relayStates[uuid] = false
updateActiveConnections()
}
// Looping in a messages
for (frame in incoming) {
if (frame is Frame.Text) {
val receivedText = frame.readText()
// We don't need to handle responses
println("Server received: $receivedText")
}
}
// Connection is closed, clearing state
mutex.withLock {
openedSockets.remove(uuid)
updateActiveConnections()
}
}
}
}.start(wait = true)
)
}
/**
* Creates a list of connected websocket clients and pass it to a Flow (and listeners)
*/
private fun updateActiveConnections() {
val connectedDevices = mutableListOf<ConnectedDevice>()
for ((key, _) in openedSockets) {
connectedDevices.add(ConnectedDevice(key, relayStates[key] ?: false))
}
connectedDevices.sortBy { it.id }
_connectedDevices.value = connectedDevices
}
/**
* Stops a WebSockets server
*/
fun stop() {
engine.get()?.stop()
engine.set(null)
}
/**
* Sends a message to a socket of a device (if connected)
*/
fun switchDeviceState(id: String, targetState: Boolean) = scope.launch {
mutex.withLock {
if (targetState) {
openedSockets[id]?.send("ON")
} else {
openedSockets[id]?.send("OFF")
}
relayStates[id] = targetState
updateActiveConnections()
}
}
companion object {
const val PORT = 8080
}
data class ConnectedDevice(
val id: String,
val isEnabled: Boolean,
)
}
The code above does the following:
- Creates a WebSocket server that listens to an 8080 port
- Creates a HashMap of connected devices
- Stores connected devices in a HashMap
- Sends “ON” or “OFF” commands to a connected device in the switchDeviceState method.
- Removes a device from a HashMap if it was disconnected
We will need to connect an ESP32 device to ws://IP:PORT. Let's add the network detection class to avoid confusion with IP addresses. It will get an IP address and pass it to a StateFlow.
@Singleton
class NetworkMonitor @Inject constructor(@ApplicationContext context: Context) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
as ConnectivityManager
// Mutable state
private val _ipAddress = MutableStateFlow("")
// Expose immutable state
val ipAddress = _ipAddress.asStateFlow()
private val networkCallback = CustomNetworkCallback(connectivityManager)
fun start() {
// Get Wi-Fi connection network request
val networkRequest = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.build()
// Register to events from Wi-Fi network
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
}
fun stop() {
// Unregister event listener
connectivityManager.unregisterNetworkCallback(networkCallback)
}
inner class CustomNetworkCallback(private val connectivityManager: ConnectivityManager) :
NetworkCallback() {
private val ipv4Address = Regex("/\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}")
override fun onAvailable(network: Network) {
super.onAvailable(network)
val props: LinkProperties? = connectivityManager.getLinkProperties(network)
props?.linkAddresses?.forEach {
// Filter ipV4 address aka (192.168.X.X)
val ip = it.address.toString()
if (ip.matches(ipv4Address)) {
_ipAddress.value = ip.replace("/", "")
}
}
}
override fun onUnavailable() {
super.onUnavailable()
_ipAddress.value = ""
}
}
}
Now, let’s create a ViewModel and a View (Composable) to show connected devices to a user.
A ViewModel starts the server and exposes data from it.
@HiltViewModel
class MainViewModel @Inject constructor(
private val networkMonitor: NetworkMonitor,
private val webSocketServer: WebSocketServer,
) : ViewModel() {
val connectedDevices = webSocketServer.connectedDevices
val ipAddress = networkMonitor.ipAddress
val port = WebSocketServer.PORT
init {
networkMonitor.start()
webSocketServer.start()
}
override fun onCleared() {
super.onCleared()
webSocketServer.stop()
networkMonitor.stop()
}
fun switchDevice(id: String, targetState: Boolean) {
webSocketServer.switchDeviceState(id, targetState)
}
}
Simple UI will show a switcher for each connected device. I’ll not post the UI here. You can find it in the repository.
To test WebSocket, we can use a Websocat library. It will allow us to connect to a web socket server from a command line terminal.
For Linux:
sudo apt-get install websocat
For mac:
brew install websocat
And then, we can connect with a command line:
websocat ws://YOUR_IP_HERE:8080
Your laptop will connect to the Android Server, and a device with a switcher will be shown on the screen. If you press on the switcher, it will change state, and the console will print the actual state (“ON, ” “OFF.”)
It means success. Your server is working and ready to control a real ESP32 device!
Let’s see it in action: pay attention to console output and Android UI.
It’s time to set up ESP32 and a relay.We can have:
Connect everything as shown on the schema:
For ESP32, we will use Visual Code Studio with the ESP-IDF plugin. If you don’t know what it is, please set up these tools using these instructions.
I’ve uploaded all the code to a GitHub repository. Let’s clone it:
git clone https://github.com/Nerdy-Things/01-android-ktor-esp32-relay.git
Then, in Visual Code, open the ESP32 folder from the repository. Next, press menu View -> Command Pallete, search for ESP-IDF, select your folders in IDE, and press “Install.” After installation, VSCode will say that you can close the window.
Use express installation:
And set all paths to our freshly checked-out repo:
After installation, we can close the plugin window and review our files.
We need to copy the config file and fill it with real values. So copy config.h.example to config.h and fill it with your values:
#define WS_HOST "ws://192.168.1.76" // Enter your websocket server IP from the Android App screen
#define WS_PORT 8080
#define WIFI_SSID "ENTER_YOUR_WI_FI_ACCESS_POINT_NAME" // Enter your wi-fi access point name
#define WIFI_PWD "ENTER_YOUR_WI_FI_AP_PASSWORD" // Enter your wi-fi password
CMakeLists.txt
# The following lines of boilerplate have to be in your project's CMakeLists
# in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(app_main)
It initializes the project and calls main/app_main.c
main/CMakeLists.txt
idf_component_register(
SRCS "app_main.c" "nt_wifi.c"
REQUIRES nvs_flash esp_wifi esp_websocket_client esp_event driver
INCLUDE_DIRS "."
)
It adds to the compilation of a few files and includes some dependencies.
Our entry point is main/app_main.c
void app_main(void)
{
// Initialize NVS
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
gpio_set_direction(RELAY_PIN, GPIO_MODE_OUTPUT);
nt_wifi_connect();
nt_websocket_connect();
}
It:
- initializes the OS
- sets a pin as output
- starts a Wi-Fi connection
- starts a WebSocket client
Wi-Fi connection is handled in main/nt_wifi.c
static void event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data)
{
ESP_LOGI(TAG, "Received event: %s %" PRId32 , event_base, event_id);
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
esp_wifi_connect();
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data;
ESP_LOGI(TAG, "got ip:" IPSTR, IP2STR(&event->ip_info.ip));
}
}
/* Initialize Wi-Fi as sta and set scan method */
void nt_wifi_connect()
{
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL, NULL));
// Initialize default station as network interface instance (esp-netif)
esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta();
assert(sta_netif);
// Initialize and start WiFi
wifi_config_t wifi_config = {
.sta = {
.ssid = WIFI_SSID,
.password = WIFI_PWD,
.scan_method = DEFAULT_SCAN_METHOD,
.sort_method = DEFAULT_SORT_METHOD,
.threshold = {
.rssi = DEFAULT_RSSI,
.authmode = DEFAULT_AUTHMODE,
},
},
};
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
}
The nt_wifi_connect method initializes Wi-Fi and starts listening to Wi-Fi events.
A method event_handler will be notified about all WiFi events, like connect, disconnect, or error. Also, the method will know the IP address when the device receives it from the router.
Return to main/app_main.c andreview a WebSocket connection code.
void nt_websocket_event_handler(void* handler_args, esp_event_base_t base, int32_t event_id, void* event_data)
{
ESP_LOGI(TAG_WS, "Received websocket event: %s %" PRId32 " ", base, event_id);
switch (event_id) {
case WEBSOCKET_EVENT_CLOSED:
case WEBSOCKET_EVENT_ERROR:
case WEBSOCKET_EVENT_DISCONNECTED:
ESP_LOGI(TAG_WS, "Disonnected or error");
gpio_set_level(RELAY_PIN, 0);
break;
case WEBSOCKET_EVENT_CONNECTED:
ESP_LOGI(TAG_WS, "Connected");
gpio_set_level(RELAY_PIN, 0);
break;
case WEBSOCKET_EVENT_DATA:
esp_websocket_event_data_t* data = (esp_websocket_event_data_t*)event_data;
char *message = (char *)malloc(data->data_len + 1);
if (message == NULL) {
return;
}
memcpy(message, data->data_ptr, data->data_len);
message[data->data_len] = '\0';
ESP_LOGI(TAG_WS, "Received message %s len: %d", message, data->data_len);
if (strcmp( MESSAGE_ON, message ) == 0) {
gpio_set_level(RELAY_PIN, 1);
} else if (strcmp( MESSAGE_OFF, message ) == 0) {
gpio_set_level(RELAY_PIN, 0);
} else {
ESP_LOGI(TAG_WS, "WebSocket Unsuported message %s", message);
}
free(message);
break;
default:
gpio_set_level(RELAY_PIN, 0);
break;
}
}
void nt_websocket_connect()
{
ESP_LOGI(TAG, "WebSockets Connect %s:%d", WS_HOST, WS_PORT);
const esp_websocket_client_config_t ws_cfg = {
.uri = WS_HOST,
.port = WS_PORT,
.reconnect_timeout_ms = 10000,
.network_timeout_ms = 10000,
};
esp_websocket_client_handle_t client = esp_websocket_client_init(&ws_cfg);
esp_websocket_register_events(client, WEBSOCKET_EVENT_ANY, &nt_websocket_event_handler, (void*)client);
esp_websocket_client_start(client);
}
The method nt_websocket_connect initializes a WebSocket connection. All WebSocket-related events will be sent to the nt_websocket_event_handler method.
When nt_websocket_event_handler receives errors, it disables a relay with a command:
gpio_set_level(RELAY_PIN, 0);
When we receive the WEBSOCKET_EVENT_DATA event, we check a message. If the message is ON or OFF, we enable or disable the relay pin accordingly.
On the relay, we have COM, NC, NO channels.
- COM (common) — we should put a line wire in it.
- NC (normally closed) — will be connected to COM when the relay is disabled and disconnected when enabled.
- NO (normally open) — will be disconnected from COM when the relay is disabled and connected when enabled.
So we can connect our lights to the COM & NO channels.
Thats it. When we install OS on ESP32, it will connect to a Wi-FI and WebSocket server in the Android application.
The Android App will show a device with a switcher. Pressing a switcher on the screen will enable/disable the relay and turn the lights on or off.
Here is a video description of this process.
You can see the results of this article at the end of the video.
Devices:
The code repository:
Comments