При включването си, устройството проверява в постоянната си памет дали е регистрирано от домакинство. Ако не е, става видимо през Bluetooth за нашето мобилно приложение. За техническите особености и регистриране в домакинство – ще пиша в друга статия.

Щом се включи в контакта, устройството се опитва да се свържи с бекенда. Има две възможности – устройството да е пасивно или активно. Повече инфо на сайта на Кинетид – www.kinetid.com и в предходната ни статия за тях.

В пасивните устройства няма добавено ESP – модул за комуникация по WiFi. Целта е да държим ниска цена. В устройствата, обаче, има PLC модул. Пасивните пращат сигнали до активните устройства по Power line (електрическата мрежа). Разбира се, в домакинството трябва да има поне едно активно устройство. То, през своя WiFi си говори през интернет със системата.

Всяко устройство има уникален идентификатор, с помощта на който разпознаваме кой ни говори.

Как си говорим с бекенда? Направили сме на всяка минута устройствата да докладват моментно потребление. Във всяко устройство има електромер, който следи консумацията. Освен това, бекендът управлява устройствата, като ги кара да спират или пускат захранването на електроуред, закачен в тях.

За да работят тези възможности използваме Web Socket. За програмирането на ESP модула, както се очакваше, имаше изненади. Ето как ги решихме.

WebSocket-а, както се знае, използва HTTP протокола за първоначално отваряне на връзката. Ето какво казва инспектора на Chrome, когато се конектва WebSocket клиент.

GET ws://localhost:9000/socket HTTP/1.1
Host: localhost:9000
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Origin: http://localhost:8080
Sec-WebSocket-Version: 13
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,bg;q=0.7,ru;q=0.6
Cookie: _ga=GA1.1.1230152116.1522842268
Sec-WebSocket-Key: pCqArSp9LxSw7BHQSj9i/g==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

Нашият бекенд си посреща заявки по HTTP, тъй като е уеб сървър. Там се сервира и сайта, там е и API-то за мобилните телефони. Но и то е през http (REST с json-и).

За да направим връзката ни е нужен Sec-WebSocket-Key стринга. С него и специален стринг с GUID

258EAFA5-E914-47DA-95CA-C5AB0DC85B11

сървърът смята SHA-1 хеш. В отговор уеб сървърът връща на клиента хеша. Оттък нататък конекцията остава отворена, стига клиентът да хареса отговора.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: YfeGvxJVa7iJA3g5rdHWaWZFsHA=

Бекенд си имаме, но трябва да напишем кода, който ще се държи като клиент. Той ще се инсталира в ESP модула и с негова помощ ще си говорим със сървъра.

За да тестваме си подготвихме простичък WebSocket клиент, който се изпълнява в браузъра. Така, докато вървим по спека на WebSocketa, ще можем да следим какви пакети праща клиента.

<html>
<head>
<script src="jquery.min.js"></script>
</head>
<body>
	<script>
		var W = {
			s : null,
			id : "39628657580236",
			init : function() {
				W.s = new WebSocket("ws://localhost:9000/socket");
				W.s.onmessage = W.onMessage;
				W.s.onopen = W.onOpen;
				$("#btn").click(W.addConsumptions);
			},
			onMessage : function(event) {
				console.log(event.data);
			},
			send : function(s) {
				console.log("Will send", s);
				W.s.send(JSON.stringify(s));
			},
			onOpen : function() {
				console.log("Connected and sending hello")
				W.send({
					"cmd" : "hello",
					"deviceId" : W.id
				})
				W.setHb()
			},
			setHb : function() {
				if (W.hbHandler) {
					clearInterval(W.hbHandler)
				}
				W.hbHandler = setInterval(function() {
					W.send({
						"cmd" : "hb",
						"deviceId" : W.id
					})
				}, 4000)
			},
			addConsumptions : function() {
				W.send({
					"cmd" : "report",
					"deviceId" : W.id,
					"consumptions" : [ {
						"interval" : 60,
						"consumption" : Math.floor(Math.random() * 100) + 1
					} ]
				})
			}
		};
		$(document).ready(function() {
			W.init()

		});
	</script>
	<button id="btn">Add consumptions</button>
</body>
</html>

Това, разбира се, не е достатъчно. Наложи се да си напишем простичък TCP сървър, за да сме сигурни, че разбираме RFC-то, описващо протокола. Да не говорим, че след превключване от HTTP протокол към WebSocket връзка, отново има протокол – пращат се уеб сокет фреймове с такава структура:

      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-------+-+-------------+-------------------------------+
     |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
     |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
     |N|V|V|V|       |S|             |   (if payload len==126/127)   |
     | |1|2|3|       |K|             |                               |
     +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
     |     Extended payload length continued, if payload len == 127  |
     + - - - - - - - - - - - - - - - +-------------------------------+
     |                               |Masking-key, if MASK set to 1  |
     +-------------------------------+-------------------------------+
     | Masking-key (continued)       |          Payload Data         |
     +-------------------------------- - - - - - - - - - - - - - - - +
     :                     Payload Data continued ...                :
     + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
     |                     Payload Data continued ...                |
     +---------------------------------------------------------------+

И така, на Java си пуснахме ServerSocket, с него ще симулиране уеб сървър, за да следим какво ни праща браузърския клиент от по-горе.

public void server() throws Exception{
		
		Socket client = new ServerSocket(9000).accept();
		InputStream stream = client.getInputStream();
		StringBuilder b = new StringBuilder();
		boolean http = true;
		
		while (true) {
			byte [] bytes = new byte[10000];
			int r = stream.read(bytes);
			if (r==-1)break;
			if (!http) {
				System.out.println(bytes2Hex(bytes));
				System.out.println(parseMessage(bytes));
			}else {
				System.out.print(new String(bytes, "utf8"));
				b.append(new String(bytes, "utf8"));
				client.getOutputStream().write((handshakeResponse+getResponse(getKey(b.toString()))+"\r\n\r\n").getBytes());
				http=false;
			}
		}
	}

Какво се случва тук? След като дойде клиента, влизаме в безкраен цикъл и започваме да четем онова, което иска да ни каже. Тъй като първите команди са по HTTP протокола и да кажем, че буфер от 10 000 байта ще ни стигне за тях, можем да сме сигурни, че ще получим всичко наведнаж първия път и последващи четения ще са с уеб сокет фреймовете. Затова си смъкваме флагчето http при обработка на заявката.

Хромът изпраща заявката от фигурата най-горе, нашият сървър трябва да отговори подобавящо. Отговорът е в променливата handshakeResponse.

String handshakeResponse = "HTTP/1.1 101 Switching Protocols\n" + 
			"Upgrade: websocket\n" + 
			"Connection: Upgrade\n" + 
			"Sec-WebSocket-Accept: ";

Към него добавяме сметнатия SHA-1 хеш от стринга, който ни даде браузъра и посочения по-горе GUID.

private String getResponse(String key)throws Exception {
		MessageDigest crypt = MessageDigest.getInstance("SHA-1");
		crypt.reset();
		crypt.update((key+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes("utf8"));
		return Base64.getEncoder().encodeToString(crypt.digest());
	}

Връщаме на браузъра отговор и ако го хареса, уеб сокетът е отворен. В този момент ще влезем в JavaScript функцията onConnect от ред 24 от клиента по-горе.

onOpen : function() {
				console.log("Connected and sending hello")
				W.send({
					"cmd" : "hello",
					"deviceId" : W.id
				})
				W.setHb()
			},

При отворяне на връзка, клиентът праща команда hello. Протоколът си е наш и върви над уеб сокет фреймовете. Тъй като наша задача е да напишем клиента, за да работи в ESP модула, трябва да подготовим всичко онова, което браузъра прави.

Какви са тези фреймове?

Както се вижда от фигурата по-горе, протоколът от по-високо ниво трябва да се увие в друг протокол, който върви по сокета.

След като смъкнем http флага, при следващи четения, Java програмката ни първо ще ни покаже какви байтове идват и след това ще опита да ги парсне. Ето как изглеждат байтовете, който идват от хром, отговарящи на командата

{"cmd":"hello","deviceId":"39628657580236"}
81 AB A4 1B 7B 95 DF 39 18 F8 C0 39 41 B7 CC 7E 17 F9 CB 39 57 B7 C0 7E 0D FC C7 7E 32 F1 86 21 59 A6 9D 2D 49 AD 92 2E 4C A0 9C 2B 49 A6 92 39 06 00 00 00 00 00

Редът ще продължи с нули до десетхилядния байт.

Какво значат тези байтове според спецификацията? Да видим пак как изглежда фрейм.

      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-------+-+-------------+-------------------------------+
     |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
     |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
     |N|V|V|V|       |S|             |   (if payload len==126/127)   |
     | |1|2|3|       |K|             |                               |
     +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
     |     Extended payload length continued, if payload len == 127  |
     + - - - - - - - - - - - - - - - +-------------------------------+
     |                               |Masking-key, if MASK set to 1  |
     +-------------------------------+-------------------------------+
     | Masking-key (continued)       |          Payload Data         |
     +-------------------------------- - - - - - - - - - - - - - - - +
     :                     Payload Data continued ...                :
     + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
     |                     Payload Data continued ...                |
     +---------------------------------------------------------------+

Първият байт в отговора ни е 0x81. В битове, той е 1000 0001. Според диаграмата горе и спека, това значи, че съобщението не е продължение на друго съобщение (първия бит) и че съобщението е текстово (последния).

Вторият байт казва колко е дълго съобщението и дали е маскирано с masking-key-я отдолу. Според спека и втория ни байт отгоре, който е 0хAB или 1010 1011 получаваме, че имаме XOR кодиране на данните заради най-левия бит. После имаме 010 1011, което е 43 символа. Ами всъщност дължината на

{"cmd":"hello","deviceId":"39628657580236"}

е точно 43.

В седем бита можем да съберем не повече от числото 127, а има по-големи фреймове. Затова спецификацията казва, ако седемте бита са 126, тогава дължината се определя от следващите два байта. Ако, обаче, тези 7 бита съдържа числото 127, тогава, дължината на фрейма са следващите 4 байта.

int size = ((byte)bytes[1])&0b0111_1111;
		ofs =2;
		if (size==126) {
			size = bytes[2]<<8+bytes[3];
			ofs +=2;
		}
		if (size==127) {
			size = bytes[2]<<32+bytes[3]<<16+bytes[4]<<8+bytes[5];
			ofs +=4;
			
		}

Нашето съобщение е <127, продължаваме с парсването.

Следват 4 байта, маска. С нея се XOR-ва съобщението до края.

byte [] maskingKey = new byte[4];
System.arraycopy(bytes, ofs, maskingKey, 0, 4);
ofs+=4;
byte [] decoded = new byte[size];
System.out.println("Size is "+size);
for (int i =0;i<size;i++) {
	byte b = maskingKey[i%4];
	byte vb = bytes[i+ofs];
	decoded[i]= (byte)(vb^b);
}

Ето го целия метод, с който парсваме съобщение. В крайна сметка получаваме готов парсиран стринг.

private String parseMessage(byte []bytes) throws Exception {
		int ofs =1;
		int size = ((byte)bytes[1])&0b0111_1111;
		ofs =2;
		if (size==126) {
			size = bytes[2]<<8+bytes[3];
			ofs +=2;
		}
		if (size==127) {
			size = bytes[2]<<32+bytes[3]<<16+bytes[4]<<8+bytes[5];
			ofs +=4;
			
		}
		byte [] maskingKey = new byte[4];
		
		
		System.arraycopy(bytes, ofs, maskingKey, 0, 4);
		ofs+=4;
		byte [] decoded = new byte[size];
		System.out.println("Size is "+size);
		for (int i =0;i<size;i++) {
			byte b = maskingKey[i%4];
			byte vb = bytes[i+ofs];
			decoded[i]= (byte)(vb^b);
		}
		
		return new String(decoded);
	}

И така, вече е доста ясно как да се сглоби съобщението, което ще праща ESP-то. Благодарение на простия socket server на Java, има възможност лесно да се тества клиента.

Ето и целия сървър:

package com.infinno.websocket;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.security.MessageDigest;
import java.util.Base64;

public class SimpleWebsocket {
	public static void main(String ...args) throws Exception {
		new SimpleWebsocket().server();
	}
	private String getKey(String request) {
		String key= request.split("Sec-WebSocket-Key: ")[1].split("\r\n")[0];
		return key;
	}
	private String bytes2Hex(byte[] bytes) {
		
	    StringBuilder sb = new StringBuilder();
	    for (byte b : bytes) {
	        sb.append(String.format("%02X ", b));
	    }
	    return sb.toString();
	}
	public void server() throws Exception{
		
		Socket client = new ServerSocket(9000).accept();
		InputStream stream = client.getInputStream();
		StringBuilder b = new StringBuilder();
		boolean http = true;
		
		while (true) {
			byte [] bytes = new byte[10000];
			int r = stream.read(bytes);
			if (r==-1)break;
			if (!http) {
				System.out.println(bytes2Hex(bytes));
				System.out.println(parseMessage(bytes));
			}else {
				System.out.print(new String(bytes, "utf8"));
				b.append(new String(bytes, "utf8"));
				client.getOutputStream().write((handshakeResponse+getResponse(getKey(b.toString()))+"\r\n\r\n").getBytes());
				http=false;
			}
		}
	}
	private String parseMessage(byte []bytes) throws Exception {
		int ofs =1;
		int size = ((byte)bytes[1])&0b0111_1111;
		ofs =2;
		if (size==126) {
			size = bytes[2]<<8+bytes[3];
			ofs +=2;
		}
		if (size==127) {
			size = bytes[2]<<32+bytes[3]<<16+bytes[4]<<8+bytes[5];
			ofs +=4;
			
		}
		byte [] maskingKey = new byte[4];
		
		
		System.arraycopy(bytes, ofs, maskingKey, 0, 4);
		ofs+=4;
		byte [] decoded = new byte[size];
		System.out.println("Size is "+size);
		for (int i =0;i<size;i++) {
			byte b = maskingKey[i%4];
			byte vb = bytes[i+ofs];
			decoded[i]= (byte)(vb^b);
		}
		
		return new String(decoded);
	}
	private String getResponse(String key)throws Exception {
		MessageDigest crypt = MessageDigest.getInstance("SHA-1");
		crypt.reset();
		crypt.update((key+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes("utf8"));
		return Base64.getEncoder().encodeToString(crypt.digest());
	}
	String handshakeResponse = "HTTP/1.1 101 Switching Protocols\n" + 
			"Upgrade: websocket\n" + 
			"Connection: Upgrade\n" + 
			"Sec-WebSocket-Accept: ";
	
}

В Инфино Java програмисти помагат на embeded колегите си и обратно. Споделяме знание, за да вървят бързо и гладко проектите.