10 December 2018

Использование внешнего беспроводного термометра Buro H999 совместно с самодельными устройствами

Reverse engineeringProgramming microcontrollersIOTDIYLifehacks for geeks
Всем хороша погодная станция Buro H146G с внешним беспроводным термометром H999. Но вот только чтобы увидеть показания на её блеклом ЖК-дисплее требуется хорошее освещение. А мне было бы лучше, если бы вывод температуры и влажности за окном отображался на достаточно ярких индикаторах (например, совместив отображение температуры и влажности с часами на газоразрядных индикаторах ИН-12). Сделать такую поделку несложно, но нужно знать протокол обмена с беспроводным термометром. Здесь уже были статьи про использование беспроводного термометра метеостанций для получения температуры и влажности по радиоканалу. Но для станций Buro протокол обмена ещё не был описан. Значит, надо это исправить: возможно, кому-то он может пригодиться.

В интернете описания протокола обмена станций BURO я не нашёл. А это значит, что придётся вскрывать протокол обмена этого беспроводного датчика.

Мой внешний термометр выглядит так:



Подключив к осциллографу китайский сверхрегенеративный приёмник на 433,92 МГц и нажав кнопку TEST на термометре, было отчётливо видно, как бегут импульсы передачи. Ну а так как частота там небольшая, выход приёмника был подключён к входу звуковой карты через резистивный делитель. После обработки записанного звукового файла компаратором получилась следующая картинка:



Как и у других погодных станций модуляция осуществляется изменением скважности. Начинается передача с блока синхросигналов, потом идёт ещё один синхросигнал, а потом идут данные, после которых идёт завершающий синхросигнал. Два ноля после синхросигнала, по-видимому, являются идентификатором начала данных – во всяком случае, я ни разу не заметил их изменения. Данные с начальным и конечным синхросигналом дублируются шесть раз. Обмен данными ведётся нибблами.

Для декодирования я решил начинать приём по первому синхросигналу и двум нулям, а завершать по последнему синхросигналу.

Чтобы такой сигнал декодировать, достаточно посчитать длительности между перепадами сигнала.

Я для этого написал простенькую тестовую программу для контроллера Atmega8:

Программа для Atmega8
//----------------------------------------------------------------------------------------------------
//библиотеки
//----------------------------------------------------------------------------------------------------
#include <avr/io.h>
#include <util/delay.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>
#include <stdio.h>
#include <avr/interrupt.h>
#include <avr/pgmspace.h>
#include <string.h>
#include <stdbool.h>
#include <stdint.h>
 
//----------------------------------------------------------------------------------------------------
//частота контроллера
//----------------------------------------------------------------------------------------------------
#define F_CPU 8000000UL

//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
//макроопределения
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 
//скорость передачи данных UART, бит/с
#define UART_SPEED 9600UL

//----------------------------------------------------------------------------------------------------
//перечисления
//----------------------------------------------------------------------------------------------------

//тип блока
enum BLOCK_TYPE
{
 BLOCK_TYPE_UNKNOW,//неизвестный блок
 BLOCK_TYPE_DIVIDER,//разделитель
 BLOCK_TYPE_SYNCHRO,//синхросигнал
 BLOCK_TYPE_ONE,//единица
 BLOCK_TYPE_ZERO//ноль
};

//режим декодирования
enum MODE
{
 MODE_WAIT_SYNCHRO,//ожидание синхросигнала
 MODE_WAIT_ZERO_FIRST,//ожидание первого нуля
 MODE_WAIT_ZERO_SECOND,//ожидание второго нуля
 MODE_RECEIVE_DATA//приём данных
}; 
 
//----------------------------------------------------------------------------------------------------
//глобальные переменные
//----------------------------------------------------------------------------------------------------
static const uint16_t MAX_TIMER_INTERVAL_VALUE=0xFFFF;//максимальное значение интервала таймера

static volatile bool TimerOverflow=false;//было ли переполнение таймера

static uint8_t Buffer[20];//буфер сборки полубайта
static uint8_t BitSize=0;//количество принятых бит
static uint8_t Byte=0;//собираемый байт

//----------------------------------------------------------------------------------------------------
//прототипы функций
//----------------------------------------------------------------------------------------------------
 
void InitAVR(void);//инициализация контроллера
void UART_Write(unsigned char byte);//передача символа в COM-порт
void SendText(const char *text);//отправить текст в COM-порт

void RF_Init(void);//инициализация
void RF_SetTimerOverflow(void);//установить флаг переполнения таймера
void RF_ResetTimerOverflow(void);//сбросить флаг переполнения таймера
bool RF_IsTimerOverflow(void);//получить, есть ли переполнение таймера
uint16_t RF_GetTimerValue(void);//получить значение таймера
void RF_ResetTimerValue(void);//сбросить значение таймера 
BLOCK_TYPE RF_GetBlockType(uint32_t counter,bool value);//получить тип блока
void RF_AddBit(bool state);//добавить бит данных
void RF_ResetData(void);//начать сборку данных заново
void RF_AnalizeCounter(uint32_t counter,bool value,MODE &mode);//анализ блока

//----------------------------------------------------------------------------------------------------
//основная функция программы
//----------------------------------------------------------------------------------------------------
int main(void)
{
 InitAVR();  
 _delay_ms(200); 
 SendText("Thermo unit\r\n");
 _delay_ms(200);  
 sei();
 while(1);
 cli();  
}
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
//общие функции
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 
//----------------------------------------------------------------------------------------------------
//инициализация контроллера
//----------------------------------------------------------------------------------------------------
void InitAVR(void)
{
 //настраиваем порты
 DDRB=0;
 DDRD=0;
 DDRC=0; 
 //задаём состояние портов
 PORTB=0;
 PORTD=0;
 PORTC=0;
 
 //устанавливаем режим передачи данных UART
 UCSRB=(1<<RXEN)|(1<<TXEN)|(0<<RXCIE);  
 //RXCIE=1 и прерывания разрешены (бит I=1 в регистре SREG) : прерывание по завершению приёма по UART разрешено
 //TXCIE=1 и прерывания разрешены (бит I=1 в регистре SREG) : прерывание по завершению передачи по UART разрешено
 //UDRIE=1 и прерывания разрешены (бит I=1 в регистре SREG) : прерывание по опустошению регистра данных UART разрешено
 //RXEN=1 : активация приёмника, вывод D0 становится входом UART.
 //TXEN=1 : активация передатчика, вывод D1 становится выходом UART.
 //CHR9=1 : длина передаваемой посылки с становится равной 11 бит (9 бит данных + старт-стоповый бит + стоп-бит).
 //RXB8-расширенный стоп-бит
 //TXB8-расширенный стоп-бит
 //вычисляем значение регистра скорости передачи данных
 unsigned long speed=F_CPU/(16UL);
 speed=(speed/UART_SPEED)-1UL;
 UBRRH=(speed>>8)&0xff;
 UBRRL=speed&0xFF;
 
 RF_Init();  
}
//----------------------------------------------------------------------------------------------------
//передача символа в COM-порт
//----------------------------------------------------------------------------------------------------
void UART_Write(unsigned char byte)
{ 
 while(!(UCSRA&(1<<UDRE)));
 UDR=byte;
}

//----------------------------------------------------------------------------------------------------
//отправить текст в COM-порт
//----------------------------------------------------------------------------------------------------
void SendText(const char *text)
{
 while((*text))
 {
  UART_Write(*text);
  text++;
 }
}

//----------------------------------------------------------------------------------------------------
//инициализация
//----------------------------------------------------------------------------------------------------
void RF_Init(void)
{
 //настраиваем аналоговый компаратор
 ACSR=(0<<ACD)|(1<<ACBG)|(0<<ACO)|(0<<ACI)|(1<<ACIE)|(0<<ACIC)|(0<<ACIS1)|(0<<ACIS0);
 //ACD - включение компаратора (0 - ВКЛЮЧЁН!)
 //ACBG - подключение к неинвертрирующему входу компаратора внутрреннего ИОН'а
 //ACO - результат сравнения (выход компаратора)
 //ACI - флаг прерывания от компаратора
 //ACIE - разрешение прерываний от компаратора
 //ACIC - подключение компаратора к схеме захвата таймера T1
 //ACIS1,ACID0 - условие генерации прерывания от компаратора
  
 //настраиваем таймер T1 на частоту 31250 Гц
 TCCR1A=(0<<WGM11)|(0<<WGM10)|(0<<COM1A1)|(0<<COM1A0)|(0<<COM1B1)|(0<<COM1B0);
 //COM1A1-COM1A0 - состояние вывода OC1A
 //COM1B1-COM1B0 - состояние вывода OC1B 
 //WGM11-WGM10 - режим работы таймера
 TCCR1B=(0<<WGM13)|(0<<WGM12)|(1<<CS12)|(0<<CS11)|(0<<CS10)|(0<<ICES1)|(0<<ICNC1);
 //WGM13-WGM12 - режим работы таймера
 //CS12-CS10 - управление тактовым сигналом (выбран режим деления тактовых импульсов на 256 (частота таймера 31250 Гц))
 //ICNC1 - управление схемой подавления помех блока захвата
 //ICES1 - выбор активного фронта сигнала захвата
 TCNT1=0;//начальное значение таймера
 TIMSK|=(1<<TOIE1);//прерывание по переполнению таймера (таймер T1 шестнадцатибитный) 
} 
//----------------------------------------------------------------------------------------------------
//установить флаг переполнения таймера
//----------------------------------------------------------------------------------------------------
void RF_SetTimerOverflow(void)
{
 cli();
 TimerOverflow=true;
 sei();
}
//----------------------------------------------------------------------------------------------------
//сбросить флаг переполнения таймера
//----------------------------------------------------------------------------------------------------
void RF_ResetTimerOverflow(void)
{
 cli();
 TimerOverflow=false;
 sei();
}
//----------------------------------------------------------------------------------------------------
//получить, есть ли переполнение таймера
//----------------------------------------------------------------------------------------------------
bool RF_IsTimerOverflow(void)
{
 cli();
 bool ret=TimerOverflow;
 sei();
 return(ret);
}

//----------------------------------------------------------------------------------------------------
//получить значение таймера 
//----------------------------------------------------------------------------------------------------
uint16_t RF_GetTimerValue(void)
{
 cli();
 uint16_t ret=TCNT1;
 sei(); 
 return(ret);
} 


//----------------------------------------------------------------------------------------------------
//сбросить значение таймера 
//----------------------------------------------------------------------------------------------------
void RF_ResetTimerValue(void)
{
 cli();
 TCNT1=0;
 sei();
 RF_ResetTimerOverflow();
} 
//----------------------------------------------------------------------------------------------------
//получить тип блока
//----------------------------------------------------------------------------------------------------
BLOCK_TYPE RF_GetBlockType(uint32_t counter,bool value)
{ 
 static const uint32_t DIVIDER_MIN=(31250UL*12)/44100UL;
 static const uint32_t DIVIDER_MAX=(31250UL*25)/44100UL;
 static const uint32_t ZERO_MIN=(31250UL*80)/44100UL;
 static const uint32_t ZERO_MAX=(31250UL*100)/44100UL;
 static const uint32_t ONE_MIN=(31250UL*160)/44100UL;
 static const uint32_t ONE_MAX=(31250UL*200)/44100UL;
 static const uint32_t SYNCHRO_MIN=(31250UL*320)/44100UL;
 static const uint32_t SYNCHRO_MAX=(31250UL*400)/44100UL;
 

 if (counter>DIVIDER_MIN && counter<DIVIDER_MAX) return(BLOCK_TYPE_DIVIDER);//разделитель
 if (counter>ZERO_MIN && counter<ZERO_MAX) return(BLOCK_TYPE_ZERO);//ноль
 if (counter>ONE_MIN && counter<ONE_MAX) return(BLOCK_TYPE_ONE);//один
 if (counter>SYNCHRO_MIN && counter<SYNCHRO_MAX) return(BLOCK_TYPE_SYNCHRO);//синхросигнал
 return(BLOCK_TYPE_UNKNOW);//неизвестный блок
}
//----------------------------------------------------------------------------------------------------
//добавить бит данных
//----------------------------------------------------------------------------------------------------
void RF_AddBit(bool state)
{
 if ((BitSize>>2)>=19) return;//буфер заполнен
 Byte<<=1;
 if (state==true) Byte|=1;
 BitSize++; 
 if ((BitSize&0x03)==0)
 {
  Buffer[(BitSize>>2)-1]=Byte;
  Byte=0;
 }
}
//----------------------------------------------------------------------------------------------------
//начать сборку данных заново
//----------------------------------------------------------------------------------------------------
void RF_ResetData(void)
{
 BitSize=0;
 Byte=0;
}

//----------------------------------------------------------------------------------------------------
//анализ блока
//----------------------------------------------------------------------------------------------------
void RF_AnalizeCounter(uint32_t counter,bool value,MODE &mode)
{
 //узнаем тип блока
 BLOCK_TYPE type=RF_GetBlockType(counter,value);

 if (type==BLOCK_TYPE_UNKNOW)
 {
  mode=MODE_WAIT_SYNCHRO;
  RF_ResetData();
  return;
 } 
 if (type==BLOCK_TYPE_DIVIDER) return;//разделитель бесполезен для анализа 
 //посылка должна начинаться и завершаться синхросигналом
 if (mode==MODE_WAIT_SYNCHRO)//ждём синхросигнала
 {
  if (type==BLOCK_TYPE_SYNCHRO)
  {
   mode=MODE_WAIT_ZERO_FIRST;
   return;
  }
  mode=MODE_WAIT_SYNCHRO;
  RF_ResetData();
  return;
 }
 if (mode==MODE_WAIT_ZERO_FIRST || mode==MODE_WAIT_ZERO_SECOND)//ждём два нуля
 {
  if (type==BLOCK_TYPE_SYNCHRO && mode==MODE_WAIT_ZERO_FIRST) return;//продолжается синхросигнал
  if (type==BLOCK_TYPE_ZERO && mode==MODE_WAIT_ZERO_FIRST)
  {
   mode=MODE_WAIT_ZERO_SECOND;
   return;
  }
  if (type==BLOCK_TYPE_ZERO && mode==MODE_WAIT_ZERO_SECOND)
  {
   mode=MODE_RECEIVE_DATA;
   return;
  }
  mode=MODE_WAIT_SYNCHRO;
  RF_ResetData();
  return;
 }
 //принимаем данные
 if (type==BLOCK_TYPE_SYNCHRO)//приём окончен
 {
  uint8_t size=(BitSize>>2);
  char str[30];
  if  (size!=10)
  {
   mode=MODE_WAIT_SYNCHRO;
   RF_ResetData();
   return; 
  }
  //выдаём блок
  for(uint8_t n=0;n<size;n++)
  {
   uint8_t b=Buffer[n];  
   uint8_t mask=(1<<3);
   for(uint8_t m=0;m<4;m++,mask>>=1)
   {
    if (b&mask) SendText("1");
	       else SendText("0");
   }
   SendText(" "); 
  }
  
  uint8_t channel=Buffer[2]&0x03;
  uint8_t key=(Buffer[8]>>3)&0x01;
  uint8_t h=(Buffer[7]<<4)|(Buffer[6]);//влажность
  int16_t temp=(Buffer[5]<<8)|(Buffer[4]<<4)|(Buffer[3]);//температура 
  int16_t k=18;
  int16_t t=(10*(temp-1220))/k;
  sprintf(str,"%i",key);
  SendText("Key:");
  SendText(str);  
  
  sprintf(str,"%i",channel+1);  
  SendText(" Ch:");
  SendText(str);  
  sprintf(str,"%i",h);  
  SendText(" H:");
  SendText(str);
  SendText("%, T:");
  if (t<0)
  {
   t=-t;
   sprintf(str,"-%i.%i",(int)(t/10),(int)(t%10));
  }
  else
  {
   sprintf(str,"%i.%i",(int)(t/10),(int)(t%10));
  }  
  SendText(str);
  SendText(" C\r\n");
  mode=MODE_WAIT_SYNCHRO;
  RF_ResetData();
  return;
 }
 //приём данных
 if (type==BLOCK_TYPE_ONE)
 {
  RF_AddBit(true);
  return;
 }
 if (type==BLOCK_TYPE_ZERO)
 {
  RF_AddBit(false);
  return;
 }
 mode=MODE_WAIT_SYNCHRO;
 RF_ResetData();
}


 
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
//обработчики векторов прерываний
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
//++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
  
//----------------------------------------------------------------------------------------------------
//обработчик вектора прерывания таймера T1 (16-ми разрядный таймер) по переполнению
//----------------------------------------------------------------------------------------------------
ISR(TIMER1_OVF_vect)
{   
 RF_SetTimerOverflow();
} 
 
//----------------------------------------------------------------------------------------------------
//обработчик вектора прерывания от компаратора
//----------------------------------------------------------------------------------------------------
ISR(ANA_COMP_vect)
{
 ACSR&=0xFF^(1<<ACIE);//запрещаем прерывания
 ACSR|=(1<<ACI);//сбрасываем флаг прерывания компаратора
 
 static MODE mode=MODE_WAIT_SYNCHRO;
 
 //узнаём длительность интервала
 uint16_t length=RF_GetTimerValue();
 if (RF_IsTimerOverflow()==true) length=MAX_TIMER_INTERVAL_VALUE;//было переполнение, считаем интервал максимальным
 RF_ResetTimerValue();
 //отправляем на анализ
 bool value=true;
 if (ACSR&(1<<ACO)) value=false;
 RF_AnalizeCounter(length,value,mode);
 ACSR|=(1<<ACIE);//разрешаем прерывания
}


Выход приёмника подключается к выводу 13 (AIN1). Atmega через max232 подключается к COM-порту компьютера (ну или к переходнику USB-COM). Скорость работы порта 9600 бод.

После декодирования получится следующий поток данных (два начальных ноля я выбрасываю):

//без кнопки, канал 1
1100 1100 0000 1110 1000 0110 1100 0001 0000 1001 Влажность:28% Температура:25.4
//без кнопки, канал 2
1100 1100 0001 1110 1000 0110 1101 0001 0000 0110 Влажность:29% Температура:25.4

Итого, пакет выглядит так:



I0-I7 – идентификатор термометра. При каждом новом включении термометра идентификатор меняется.

C0-C1 — канал (всего их возможно 3). Каналы нумеруются с нуля.

H0-H7 — влажность. Влажность в процентах считывается как есть, а вот температура (T0-T11) почему-то задана в необычном для метеостанций формате. Судя по найденным мной описаниям протоколов обмена различных метеостанций, можно было бы ожидать температуру в десятых долях градуса и со смещением нижнего предела измерения термометра. Так вот, нет. Эксперименты показали, что код температуры данной метеостанции переводится в градусы Цельсия как (T-1220)/18. Откуда эти магические числа знают только китайцы, придумавшие этот протокол обмена.

Как подсказал wolowizard в комментариях, станция передаёт температуру в десятых долях градусов Фаренгейта, поэтому осмысленный перевод в градусы Цельсия будет 0.1*(T-320)*5/9-500=0.1*(T-1220)/1.8.

Бит K соответствует нажатию на кнопку TEST.

Назначение остальных полей установить не удалось, но выяснилось, что значение переключателя Фаренгейты/Цельсии на термометре в протокол обмена не попадает. Предположительно так же последний ниббл (а может, и часть предпоследнего) является CRC, но вычислить алгоритм мне пока не удалось (есть подозрение, что в вычислении участвуют строки и столбцы нибблов). Если кто-нибудь сумеет разгадать эту загадку, сообщите, пожалуйста, алгоритм вычисления.
Для желающих поломать голову, но не имеющих такого термометра, привожу таблицу принятых данных.

Таблица
1001 0110 0101 1011 1000 0110 1000 0010 0001 1111 Key:0 Ch:2 H:40%, T:25.2 C
1001 1001 0000 1101 1010 0100 0101 0101 0000 0110 Key:0 Ch:1 H:85%, T:-1.2 C
1001 0110 0101 1100 1000 0110 1010 0010 0001 0100 Key:0 Ch:2 H:42%, T:25.3 C
1001 0110 1001 0110 0111 0110 1101 0001 0010 1111 Key:0 Ch:2 H:29%, T:24.1 C
1001 0110 1001 0000 0111 0110 1101 0001 0010 1000 Key:0 Ch:2 H:29%, T:23.7 C
1001 0110 1001 0010 0101 0110 1110 0001 0010 1111 Key:0 Ch:2 H:30%, T:22.1 C
1001 0110 1001 1001 0011 0110 1110 0001 0010 1100 Key:0 Ch:2 H:30%, T:20.7 C
1001 0110 1001 1111 0001 0110 1111 0001 0010 1010 Key:0 Ch:2 H:31%, T:19.2 C
1001 0110 0101 1001 0000 0110 0001 0010 0010 1000 Key:0 Ch:2 H:33%, T:18.0 C
1001 0110 0101 0010 1111 0101 0010 0010 0010 0111 Key:0 Ch:2 H:34%, T:16.7 C
1001 0110 0101 0100 1110 0101 0010 0010 0010 0010 Key:0 Ch:2 H:34%, T:16.0 C
1001 0110 0101 0100 1101 0101 0011 0010 0010 0001 Key:0 Ch:2 H:35%, T:15.1 C
1001 0110 0101 1100 1100 0101 0100 0010 0010 1110 Key:0 Ch:2 H:36%, T:14.6 C
1001 0110 0101 1111 1011 0101 0101 0010 0010 1111 Key:0 Ch:2 H:37%, T:13.9 C
1001 0110 0101 0011 1011 0101 0101 0010 0010 0001 Key:0 Ch:2 H:37%, T:13.2 C
1001 0110 0101 1001 1010 0101 0110 0010 0010 0101 Key:0 Ch:2 H:38%, T:12.7 C
1001 0110 0101 0100 1010 0101 0111 0010 0010 1000 Key:0 Ch:2 H:39%, T:12.4 C
1001 0110 0101 1011 1001 0101 0111 0010 0010 1010 Key:0 Ch:2 H:39%, T:11.9 C
1001 0110 0101 0011 1001 0101 1000 0010 0010 1001 Key:0 Ch:2 H:40%, T:11.5 C
1001 0110 0101 1011 1000 0101 1000 0010 0010 1110 Key:0 Ch:2 H:40%, T:11.0 C
1001 0110 0101 0111 1000 0101 1001 0010 0010 0101 Key:0 Ch:2 H:41%, T:10.8 C
1001 0110 0101 1111 0111 0101 1001 0010 0010 1101 Key:0 Ch:2 H:41%, T:10.3 C
1001 0110 0101 0111 0111 0101 1010 0010 0010 0111 Key:0 Ch:2 H:42%, T:9.9 C
1001 0110 0101 0001 0111 0101 1011 0010 0010 0101 Key:0 Ch:2 H:43%, T:9.6 C
1001 0110 0101 1011 0110 0101 1100 0010 0010 0110 Key:0 Ch:2 H:44%, T:9.2 C
1001 0110 0101 1000 0110 0101 1100 0010 0010 1100 Key:0 Ch:2 H:44%, T:9.1 C
1001 0110 0101 0011 0110 0101 1101 0010 0010 0110 Key:0 Ch:2 H:45%, T:8.8 C
1001 0110 0101 1001 0101 0101 1110 0010 0010 0110 Key:0 Ch:2 H:46%, T:8.2 C
1001 0110 0101 0101 0101 0101 1111 0010 0010 1101 Key:0 Ch:2 H:47%, T:8.0 C
1001 0110 0101 0010 0101 0101 1111 0010 0010 1100 Key:0 Ch:2 H:47%, T:7.8 C
1001 0110 0101 1110 0100 0101 1111 0010 0010 0000 Key:0 Ch:2 H:47%, T:7.6 C
1001 0110 0101 1100 0100 0101 1111 0010 0010 1100 Key:0 Ch:2 H:47%, T:7.5 C

Tags:Buro H999термометрметеостанцияпогодная станция
Hubs: Reverse engineering Programming microcontrollers IOT DIY Lifehacks for geeks
+18
4.8k 19
Comments 15
Engineering Manager
from 2,500 to 4,000 $LuxandRemote job
Reverse Engineer
from 3,000 to 4,000 $Hand2NoteRemote job
Reverse Engineer
from 150,000 to 220,000 ₽AtlantisRemote job
Director of Engineering
from 260,000 ₽Spark EquationСанкт-ПетербургRemote job
Java API Developer
from 3,300 to 5,000 $AWWCOR Inc.Remote job