Про метеостанцию на Хабре писали не раз и не два, и наверное не с десяток раз. И вот настало моё время. Решил с вами поделиться своей. 

Постановка задачи

Зачем вообще нужна метеостанция? Сегодня в мире хватает погодных сервисов, в том числе с локальными сводками погоды. Однако помимо контроля внешних параметров мне нужно было получить данные и с датчиков температуры и влажности внутри помещения. Помимо просто информационной составляющей, зная температуру в доме и снаружи, можно, например, управлять котлом или вентиляцией, и поддерживать комфортный микроклимат (погодозависимая автоматика). Кроме того, мне очень хотелось бы отслеживать тенденции и тренды погоды на длительных промежутках времени, например в год-два. То есть, данные нужно где-то хранить.

Из всего того делаем выводы:

  • Нужно хранилище данных (сервер)

  • Датчики будем использовать разные и в разных местах, поэтому проще сделать систему модульной (IoT)

  • Помимо локального сервера данные, хотя бы текущих показаний хорошо бы скидывать в облако

  • Так как мы будем собирать достаточно много данных, можно данными поделиться

Архитектура

Самым простым и популярным решением для метеостанции является Arduino, однако подружить с его домашней сетью - это дополнительные девайсы\шилды, лишние деньги и сложность, а значит - время. Поэтому из коробки проще использовать модуль уже со встроенным Wi-Fi, например ESP8266 (NodeMCU) с подключенными сенсорами. Это достаточно удобно, что один и тот же модуль можно использовать и дома, и за окном. При желании даже можно его использовать в качестве сервера. 

Но почему бы не проставить в центр системы лучше что-то помощнее? Благо у меня пылится без дела Raspberry Pi первой ревизии (но и любая другая подойдёт). Внутренние датчики можно подключить, в принципе через GPIO и к малинке напрямую, но у меня роутер с малинкой в одной комнате установлен, а мониторить нужно другую. Если у вас такой проблемы нет - то можно от одной NodeMCU избавиться. Малинка будет получать данные от датчиков, сохранять их в базе данных и при необходимости отображать. Так же к GPIO Raspberry Pi можно подцепить LoRa - приёмник и получать данные от удалённых за пределами Wi-Fi сети датчиков (и вот они Arduino). Ну, и наконец, малинка будет отправлять данные в облако.

Итого, нам понадобится:

  • Raspberry PI

  • ESP8266 (2шт. + 1шт. опционально)

  • BME280 (2 шт.)

  • Часы реального времени DS1302 (опционально)

  • OLED-дисплей 128х64 на SH1106 (опционально)

  • Датчик дождя на компараторе LM373 (опционально)

  • УФ-датчик GY-VEML6070 (опционально)

  • Raspberry Pi Camera (опционально)

  • Arduino Nano (2 шт., опционально)

  • SX1278 (3 шт., опционально)

  • Магнитный компас с чипом QMC5883L/HMC5883L (опционально)

  • Датчик освещённости (светодиодный) с компаратором LM737 (опционально)

  • Датчики напряжения до 25V (опционально)

  • Датчики тока ACS712 (опционально)

Подключение SX1278 к Raspberry Pi

Для начала подключим к малинке радиомодуль.

Raspberry Pi

SX1278

3.3V

3.3V

GROUND

GROUND

GPIO10

MOSI

GPIO9

MISO

GPIO11

SCK

GPIO8

NSS/ENABLE

GPIO4

DIO0

GPIO22

RST

Соединяем пины Raspberry Pi и SX1278 как на картинке:

Замечание: для разных ревизий Raspberry Pi используются разное количество пинов, а значит и распиновки, смотрите документацию.

По поводу использования LoRa-модулей хочу обратить внимание на несколько моментов:

  • Перед подачей питания на модуль LoRa обязательно убедитесь, что к нему подключена антенна, иначе есть риск, что модуль сгорит!

  • На качество сигнала помимо антенны влияют правильные настройки, весьма важно, чтобы частоты приёмника и передатчика совпадали, а диапазон был свободен от шума (например игрушечных радиоуправляемых машинок)

Установка сервера

На Raspberry Pi загружаем Raspberry Pi OS Lite.

Далее устанавливаем статический адрес для нашей малинки:

sudo nano /etc/dhcpcd.conf

Добавляем\правим строки на наш желаемый IP и IP наш роутер

interface eth0 # или wlan0 если малинка подключена по Wi-Fi
static ip_address=192.168.0.4/24 
static routers=192.168.0.1 
static domain_name_servers=192.168.0.1. 8.8.8.8

Теперь включаем удалённый доступ через SSH, SPI (нужен для подключения LoRa), а так же Camera, если планируем её использовать.

sudo raspi-config

Включаем:

  • SSH (если собираемся подключаться по SSH, а не только через клавиатуру)

  • SPI (если собираемся использовать LoRa)

  • Camera (если собираемся использовать камеру)

Убеждаемся, что стоит автологин при загрузке:

Boot Options -> Console Autologin

Выходим из raspi-config, перезагружаем:

sudo shutdown -r now

Теперь у нас есть удалённый доступ к малинке, можем подключиться через ssh или можем продолжить через клавиатуру.

 Вся логика сервера написана на Python3, поэтому ставим его:

sudo apt-get install python3.7

 Теперь осталось загрузить собственно мой проект H.O.M.E.:

cd ~
git clone https://github.com/wwakabobik/home.git 

В качестве веб-сервера я выбрал flask, на Хабре есть отличная серия статей, поэтому я не буду останавливаться на подробностях при работе с ним.

Копируем контент из папки с сервером:

mkdir web-server
cp -r home/home_server/* /home/pi/web-server/

Устанавливаем все зависимости:

cd web-server
sudo python3.7 -m pip install -r requirements.txt

Создаём базу данных из шаблона:

cat db/schema.sql | sqlite3 flask_db

 Собственно всё, теперь можем запустить сервер:

cd /home/pi/web-server && sudo python3.7 app.py

Но мы же хотим, чтобы сервер запускался при загрузке Raspberry Pi?

Тогда в конце /etc/rc.local, перед exit 0, добавляем вызов bash-скрипта:

/home/pi/flask_startup.sh &

Копируем этот скрипт на место:

cd ~
cp ~/home/bash/flask_startup.sh .

Я так же считаю, что было бы неплохо иметь скрипт, который проверяет, жив ли ещё сервер, и если он мёртв, то перезапускает его. Копируем и его.

cp ~/home/bash/check_health.sh .

Добавляем в планировщик cron:

sudo crontab -e

задание:

1-59/5 * * * * /home/pi/check_health.sh

Немного о софте сервера

За запуск сервера отвечает app.py.

#!/usr/bin/env python3.7

from multiprocessing.pool import ThreadPool

from flask import Flask

from db.db import init_app
from lora_receiver import run_lora


app = Flask(__name__, template_folder='templates')  # firstly, start Flask

# import all routes
import routes.api
import routes.pages
import routes.single_page


if __name__ == '__main__':
    # Start LoRa receiver as subprocess
    pool = ThreadPool(processes=1)
    pool.apply_async(run_lora)
    # Start Flask server
    init_app(app)
    app.run(debug=True, host='0.0.0.0', port='80')
    # Teardown
    pool.terminate()
    pool.join()

Обратите внимание, что помимо запуска сервера, в отдельном процессе запускаем LoRa ресивер, который будет получать данные от датчиков и пересылать их на сервер.

В остальном архитектура типична для flask’a: все возможные routes вынесены в отдельные файлы, все страницы хранятся в pages, а шаблоны в templates. Логика базы данных лежит в db, статичные файлы (картинки) в static, ну а в camera будем складывать картинки с камеры.

В итоге, текущие показания можно увидеть на dashboard страницах, 

а графики и данные - на отдельных (графики рисует plotly).

Софт LoRa-ресивера

home_server/lora_receiver.py

from time import sleep

import requests
from SX127x.LoRa import *
from SX127x.board_config import BOARD


endpoint = "http://0.0.0.0:80/api/v1"


class LoRaRcvCont(LoRa):
    def __init__(self, verbose=False):
        super(LoRaRcvCont, self).__init__(verbose)
        self.set_mode(MODE.SLEEP)
        self.set_dio_mapping([0] * 6)

    def start(self):
        self.reset_ptr_rx()
        self.set_mode(MODE.RXCONT)
        while True:
            sleep(.5)
            rssi_value = self.get_rssi_value()
            status = self.get_modem_status()
            sys.stdout.flush()

    def on_rx_done(self):
        self.clear_irq_flags(RxDone=1)
        payload = self.read_payload(nocheck=True)
        formatted_payload = bytes(payload).decode("utf-8", 'ignore')
        status = self.send_to_home(formatted_payload)
        if status:
            sleep(1)  # we got the data, force sleep for a while to skip repeats
        self.set_mode(MODE.SLEEP)
        self.reset_ptr_rx()
        self.set_mode(MODE.RXCONT)

    def send_to_home(self, payload):
        if str(payload[:2]) == '0,':
            requests.post(url=f'{endpoint}/add_wind_data', json={'data': payload})
        elif str(payload[:2]) == '1,':
            requests.post(url=f'{endpoint}/add_power_data', json={'data': payload})
        else:
            print("Garbage collected, ignoring")  # debug
            status = 1
        return status


def run_lora():
    BOARD.setup()
    lora = LoRaRcvCont(verbose=False)
    lora.set_mode(MODE.STDBY)
    # Medium Range  Defaults after init are 434.0MHz, Bw = 125 kHz, Cr = 4/5, Sf = 128chips/symbol, CRC on 13 dBm
    lora.set_pa_config(pa_select=1)
    assert (lora.get_agc_auto_on() == 1)

    try:
        lora.start()
    finally:
        lora.set_mode(MODE.SLEEP)
        BOARD.teardown()

В этом коде главное - это получение пакета в событии on_rx_done - если пакет получен, нужно его декодировать.

Проверяем в send_to_home что payload[:2] равен ожидаемому коду датчика (я для простоты использую значения «0,» и «1,»), то отсылаем на сервер и спим секунду, чтобы пропустить повторные пакеты.Если нет, продолжаем получать пакеты.

API


Ключевое, что делает 99% времени сервер - это простой. Но в остальные 1% он отдаёт и получает данные, и за это, помимо отображения страниц через веб-интерфейс отвечает API. 

Именно через Flask REST API мы будем посылать или получать данные от сенсоров.

home_server/routes/api.py

@app.route('/api/v1/send_data')
def send_weather_data():
    return send_data()


@app.route('/api/v1/add_weather_data', methods=['POST'])
def store_weather_data():
    if not request.json:
        abort(400)
    timestamp = str(datetime.now())
    unix_timestamp = int(time())
    data = request.json.get('data', "")
    db_data = f'"{timestamp}", {unix_timestamp}, {data}'
    store_weather_data(db_data)
    return jsonify({'data': db_data}), 201

Данные пишутся в лог:

В моём случае, если мы получили данные от датчика (получили POST запрос с верным JSON), то мы их сохраняем в БД. Так же, если мы получили GET запрос на отправку данных (send_data), то данные отправляем данные на облако.

home_server/pages/weather_station/send_data.py

def send_data():
    data = get_last_measurement_pack('0', '1')
    image = take_photo()
    wu_data = prepare_wu_format(data=data)
    response = str(send_data_to_wu(wu_data))
    response += str(send_data_to_pwsw(wu_data))
    response += str(send_data_to_ow(data))
    response += str(send_data_to_nardmon(data))
    send_image_to_wu(image)
    copyfile(image, f'{getcwd()}/camera/image.jpg')
    return response

Ах да, забыл упомянуть камеру. Если мы подключили камеру к Raspberry Pi, то можем отправлять или сохранять изображения погоды за окном. Для этого есть отдельный метод:

home_server/pages/shared/tools.py

from picamera import PiCamera

<...>
camera = PiCamera()
<...>

def take_photo():
    camera.resolution = (1280, 720)  # lower resolution to fit in limits
    camera.start_preview()
    sleep(5)
    image = f'{getcwd()}/camera/image_{int(time())}.jpg'
    camera.capture(image)
    camera.stop_preview()
    return image
  

Внешние датчики

полные скетчи можно найти в home/iot

Самым удобным и простым модулем для любительской метеостанции является модуль BME280, объединяющий в себе термометр, датчик влажности и давления. Подключаем его по I2C к ESP8266:

Прошивать будем через Arduino IDE (как добавить ESP8266   написано, например, в этой статье).

iot/esp8266/weatherstation_in/weatherstation_in.ino

#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <Wire.h>
#include <SPI.h>
#include <Adafruit_BME280.h>
#include <Arduino_JSON.h>

Adafruit_BME280 bme; // use I2C interface
Adafruit_Sensor *bme_temp = bme.getTemperatureSensor();
Adafruit_Sensor *bme_pressure = bme.getPressureSensor();
Adafruit_Sensor *bme_humidity = bme.getHumiditySensor();

// Датчик не сказать, чтобы очень точный, поэтому добавляем корректирующие значения
float correction_temperature = -0.5; 
float correction_pressure = 15;
float correction_humidity = 10; 

// подключаем Wifi
void connect_to_WiFi()
{
   WiFi.mode(WIFI_STA);
   WiFi.begin(wifi_ssid, wifi_password);
   while (WiFi.status() != WL_CONNECTED)
   {
      delay(500);
   }
   Serial.println("WiFi connected");
   Serial.print("IP address: ");
   Serial.println(WiFi.localIP());
   #endif

}

/* <…> */

// собираем данные с датчиков

float get_temperature()
{
    sensors_event_t temp_event, pressure_event, humidity_event;
    bme_temp->getEvent(&temp_event);
    return temp_event.temperature + correction_temperature;

}

/* <…> */

// также точку росы можно вычислить до отправки на сервер, делаем это:
float get_dew_point()

{
    float dew_point;
    float temp = get_temperature();
    float humi = get_humidity();
    dew_point =  (temp - (14.55 + 0.114 * temp) * (1 - (0.01 * humi)) - pow(((2.5 + 0.007 * temp) * (1 - (0.01 * humi))),3) - (15.9 + 0.117 * temp) * pow((1 - (0.01 * humi)), 14));
    return dew_point;
}

/* <…> */

// Форматируем в строку 
String get_csv_data()
{
    String ret_string = DEVICE_ID;
    ret_string += delimiter + String(get_temperature());
    ret_string += delimiter + String(get_humidity());
    ret_string += delimiter + String(get_pressure());
    ret_string += delimiter + String(get_dew_point());
    return ret_string; 
}

// Отправляем через HTTP, упаковав строку в JSON:
void post_data()
{
    check_connection();
    HTTPClient http;    //Declare object of class HTTPClient
    String content = get_csv_data();
    int http_code = 404;
    int retries = 0;
    while (http_code != 201)
    {
        http.begin(api_url); // connect to request destination
        http.addHeader("Content-Type", "application/json");        // set content-type header
        http_code = http.POST("{\"data\": \"" + content +"\"}");   // send the request
        http.end();                                                // close connection
        retries++;
        if (retries > max_retries)
        {
          	Serial.println("Package lost!");
            break;
        }
    }
}

// cобственно, повторяем это время от времени:
void loop()
{
    post_data();
    delay(cooldown);
}

По умолчанию у меня стоит интервал в 5 минут, и я считаю, что DEVICE_ID = "0" – внутренний датчик, а DEVICE_ID = "1" – внешний.

Датчик дождя LM393+YL83
Датчик дождя LM393+YL83

К внешнему датчику можно подключить так же датчики ультрафиолета (
GY-VEML6070) и датчик дождя (на компараторе LM393). YL-83 достаточно игрушечный вариант для реального измерения уровня осадков, по крайней мере без калибровки, но, на какое-то время сгодиться, потому что мне актуальность по уровню осадкам не сильно интересует. Ну, точнее интересует на уровне "на улице дождь" или "сухо". Так же, альтернативно, можно использовать аналоговый датчик ультрафиолета GY-8511, но тогда придётся выбирать между ним и датчиком дождя, так как аналоговый вход на NodeMCU только один. Датчик ультрафиолета можно использовать, например, для оценки эффективности солнечных панелей. Ну и просто показывает дни, когда лучше воспользоваться солнцезащитным кремом во время покоса газона.

Схема подключения к ESP8266 ниже:

Для этих датчиков соответственно добавим три функции:

iot/esp8266/weatherstation_out/weatherstation_out.ino

#include "Adafruit_VEML6070.h"

Adafruit_VEML6070 uv = Adafruit_VEML6070();
#define VEML6070_ADDR_L     (0x38) ///< Low address
RAIN_SENSOR_PIN = A0;

/* <...> */

#ifdef UV_ANALOG_SENSOR
void get_uv_level()
{
    int uv_level = averageAnalogRead(UV_PIN);
    float uv_intensity = mapfloat(uv_level, 0.99, 2.8, 0.0, 15.0);
    return uv_intensity;
}
#endif

#ifdef UV_I2C_SENSOR
void get_uv_level()
{
		return uv.readUV();
}
#endif

#ifdef RAIN_SENSOR
void get_rain_level()
{
    int rain_level = averageAnalogRead(RAIN_SENSOR_PIN);
    return rain_level;
}
#endif

NodeMCU удобно использовать, когда есть устойчивый Wi-Fi в зоне их действия. Конечно, ставить внешние датчики для погодной станции на крыльце - плохая идея, но оборудованная точка, отнесённая пару-тройку метров от дома - то, что нужно, а сигнала роутера в доме хватает за глаза.

Правила установки погодной станции
  • Датчики температуры и влажности воздуха обязательно устанавливаются над естественной поверхностью земли (трава, грунт). Асфальта, бетона, щебня, камня, металла не должно быть.

  • Датчики температуры и влажности устанавливаются на высоте 2 м над землёй в метеорологической будке: это небольшой деревянный или пластиковый ящик (размером приблизительно 40х40х40 см) с белыми, отражающими свет перфорированными или жалюзийными стенками, а также солнцеводозащитным козырьком (крышка будки должна быть герметичной и иметь наклон для стекания осадков с будки).

  • Датчик ветра - на высоте 10-12 м над землёй (именно над землёй, а не на крышах зданий; в исключительном случае разрешается размещение дачтика ветра на крыше одноэтажного дома, так чтобы датчик возвышался над верхним краем крыши не менее чем на 2-3 м, а над поверхностью земли на 10-12 м).

  • В худшем случае (при этом велик риск погрешностей, особенно в ночное время) датчик Т и влажности может быть установлен с теневой стороны здания, на высоте 2 м над землёй, на штанге длиной от стены как минимум метра 3 , над газоном (не над асфальтом!). Ни в коем случае не рекомендуется устанавливать их поблизости от сильно нагревающихся поверхностей, например крыш, стен и т.п.

  • Датчик атмосферного давления устанавливается в помещении вдали от окон и отопительных приборов. Атмосферное давление зависит от высоты над уровнем моря места, где производится измерение; поэтому требуется калибровка датчика давления перед его использованием. Для правильной установки прибора необходимо воспользоваться показаниями другого барометра или данными ближайшей метеостанции (с учётом разности высот, определённой по подробной топографической карте; 10 м подъёма соответствует уменьшению давления примерно на 1 мм рт.ст. или 1.3 гПа (мБ)).

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

Затем загрузим на него скетч, который будет отображать текущее время (ЧЧ:ММ) и в бегущей строке данные с метеостанции:

iot/informer/esp8266/informer/informer.ino

#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <Wire.h>
#include <U8g2lib.h>
#include <virtuabotixRTC.h>  // https://ampermarket.kz/files/rtc_virtualbotix.zip


// RTC
virtuabotixRTC myRTC(14, 12, 13);


// OLED
U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0);
u8g2_uint_t offset;            // current offset for the scrolling text
u8g2_uint_t width;             // pixel width of the scrolling text (must be lesser than 128 unless U8G2_16BIT is defined
const int string_length = 80;  // maximum count of symbols in marquee
char text[string_length];      // text buffer to scroll

// Wi-Fi
const char* wifi_ssid = "YOUR_SSID";
const char* wifi_password = "YOUR_PASSWORD";

// API
const String ip_address = "YOUR_IP_OF_SERVER";
const String port = "YOUR_PORT";
const String api_endpoint = "/api/v1/add_weather_data";
const String api_url = "http://" + ip_address + ":" + port + api_endpoint;
const int max_retries = 5;  // number of retries to send packet

// Timers and delays
const long data_retrieve_delay = 300000;
const int cycle_delay = 5;
unsigned long last_measurement = 0;


void setup(void) 
{
    Serial.begin(9600);
    init_OLED();
    init_RTC();
}


/* Init functions */
void init_OLED()
{
    u8g2.begin();  
    u8g2.setFont(u8g2_font_inb30_mr); // set the target font to calculate the pixel width
    u8g2.setFontMode(0);              // enable transparent mode, which is faster
}


void init_RTC()
{
    // seconds, minutes, hours, day of the week, day of the month, month, year
    // раскомментируйте при прошивке, заполнив текущую дату и время, затем снова закомментируйте и прошейте ещё раз
    //myRTC.setDS1302Time(30, 03, 22, 5, 19, 2, 2021); // set RTC time
    myRTC.updateTime(); // update of variables for time or accessing the individual elements.
}


""" <...> """

  
String get_data()
{
    check_connection();

    #ifdef DEBUG
    Serial.println("Obtaining data from server");
    #endif
    HTTPClient http;    //Declare object of class HTTPClient
  
    int http_code = 404;
    int retries = 0;
    String payload = "Data retrieve error";
    while (http_code != 200)
    {
        http.begin(api_url);                // connect to request destination
        http_code = http.GET();             // send the request
        String answer = http.getString();   // get response payload
        http.end();                         // close connection    

        retries++;
        if (retries > max_retries)
        {
            break;
            #ifdef DEBUG
            Serial.println("Couldn't get the data!");
            #endif
        }
                
        if (http_code == 200)
        {
            payload = answer;
        }
    }
    return payload;
}


void loop(void) 
{
    // Check that new data is needed to be retrieved from server
    if (((millis() - last_measurement) > data_retrieve_delay) or last_measurement == 0)
    {
        String stext = get_data();
        stext.toCharArray(text, string_length);
        last_measurement = millis();
        width = u8g2.getUTF8Width(text);    // calculate the pixel width of the text
        offset = 0;
    }

    // Update RTC
    myRTC.updateTime(); 

    // Now update OLED
    u8g2_uint_t x;
    u8g2.firstPage();
    do 
    {
        // draw the scrolling text at current offset
        x = offset;
        u8g2.setFont(u8g2_font_inb16_mr);       // set the target font
        do 
        {                                       // repeated drawing of the scrolling text...
            u8g2.drawUTF8(x, 58, text);         // draw the scrolling text
            x += width;                         // add the pixel width of the scrolling text
        } while (x < u8g2.getDisplayWidth());   // draw again until the complete display is filled
    
        u8g2.setFont(u8g2_font_inb30_mr);       // choose big font for clock
        u8g2.setCursor(0, 30);                  // set position of clock
        char buf[8];                            // init bufer to formatted string
        sprintf_P(buf, PSTR("%02d:%02d"), myRTC.hours, myRTC.minutes); // format clock with leading zeros
        u8g2.print(buf);                        // display clock
    } while (u8g2.nextPage());
  
    offset-=2;                       // scroll by two pixels
    if ((u8g2_uint_t)offset < ((u8g2_uint_t) - width))
    {  
        offset = 0;                  // start over again
    }  
    delay(cycle_delay);              // do some small delay
}

В итоге результат работы выглядит так:

Соответственно в Raspberry Pi:

home_server/routes/api.py

@app.route('/api/v1/get_weather_data', methods=['GET'])
def store_wind_data():
    return send_data_to_informer()
  

pages/weather_station/send.data

def send_data_to_informer():
    data_in = get_last_measurement_pack('weather_data', '0', '0')
    data_out = get_last_measurement_pack('weather_data', '0', '1')
    pressure = int((data_in['pressure']+data_out['pressure'])/2)
    formatted_string = f"IN: T={data_in['temperature']}*C, "                        f"H={data_in['humidity']}% | "                        f"OUT: T={data_out['temperature']}*C, "                        f"H={data_out['humidity']}%, "                        f"DP={data_out['dew_point']}*C | "                        f"P={pressure} mmhg"
    return formatted_string

Радиодатчики

Там, где не дотянуться Wi-Fi, нужно использовать альтернативные варианты передачи данных. В моём случае - это использование LoRa-модулей (в связке, например, с Arduino Nano.

Таких устройств у меня два - это датчик скорости и направления ветра (компас). Пока не буду останавливаться на этом в текущей статье, если будет интерес - напишу отдельно. Второе устройство - это вольтметр и два амперметра, для контроля работы ветряка, зарядки АКБ и потребления.

SX1278

Arduino Nano

3.3V

3.3V

GROUND

GROUND

MOSI

D10

MISO

D2

SCK

D13

NSS/ENABLE

D12

DIO0

D11

RST

D9

И, код, соответственно:

iot/arduino/*_meter/*_meter.ino

// Required includes
#include <SPI.h>
#include <LoRa.h>

// LoRA config
const int LORA_SEND_RETRIES = 5; // сколько раз посылать сообщение
const int LORA_SEND_DELAY = 20;  // задержка между пакетами
const int LORA_POWER = 20;       // мощность передатчика на максимум 
const int LORA_RETRIES = 12;     // сколько раз пытаться инициализировать модуль
const int LORA_DELAY = 500;      // задержка между попыткой инициализации


// Инициализируем модуль
void init_LoRa() 
{
    bool success = false;
    for (int i=0; i < LORA_RETRIES; i++)
    
    {
        if (LoRa.begin(433E6)) // используем 433Мгц
        {
            success = true;
            break;
        }
        delay(LORA_DELAY);
    }
    if (!success)
    {
        #ifdef DEBUG
        Serial.println("LoRa init failed.");
        #endif
        stop(4);
    }
    
    LoRa.setTxPower(LORA_POWER);  // aplify TX power
    #ifdef DEBUG
    Serial.println("LoRa started!");
    #endif  
}
#endif

// Посылаем пакет с данными строкой
void LoRa_send(power_data data)
{
    String packet = DEVICE_ID + "," + String(data.avg_voltage,2) + ",";
    packet += String(data.avg_current,2) + "," + String(data.avg_power,2) + "," +String(data.avg_consumption,2);
    for (int i=0; i < LORA_SEND_RETRIES; i++)
    {
        LoRa.beginPacket();  // just open packet
        LoRa.print(packet);  // send whole data
        LoRa.endPacket();    // end packet
        delay(LORA_SEND_DELAY);
    }  
}

Достаточно просто, не правда ли?

Облачные сервисы

Изначально у меня не было цели делиться данными со сторонними сервисами, но во время отладки появилась мысль о том, что неплохо было бы иметь референсные данные с местных метеостанций, для контроля корректности работы своей. Первым, и с самой большой коллекцией примеров и API оказалась WeatherUnderground.

from wunderground_pws import WUndergroundAPI, units

from secure_data import wu_api_key, wu_reference_station_id

""" ... """

wu_current = wu.current()

""" ... """

wu_humidity=wu_current['observations'][0]['humidity'],
wu_pressure=int(int(wu_current['observations'][0]['metric_si']['pressure'])/1.33),
wu_dew_point=wu_current['observations'][0]['metric_si']['dewpt'],
wu_wind_speed=wu_current['observations'][0]['metric_si']['windSpeed'],
wu_wind_gust=wu_current['observations'][0]['metric_si']['windGust'],
wu_wind_direction=wu_current['observations'][0]['winddir'],
wu_wind_heading=deg_to_heading(int(wu_current['observations'][0]['winddir']))

Однако, если была возможность быстро получать данные, то почему бы ими не поделиться, подумал я? Данные в WU передаются через GET-запрос, поэтому для удобства предварительно подготавливаем данные

def prepare_wu_format(data, timestamp=None):
    payload = f"&dateutc={timestamp}" if timestamp else "&dateutc=now"
    payload += "&action=updateraw"
    payload += "&humidity=" + "{0:.2f}".format(data['humidity'])
    payload += "&tempf=" + str(celsius_to_fahrenheit(data['temperature']))
    payload += "&baromin=" + str(mmhg_to_baromin(data['pressure']))
    payload += "&dewptf=" + str(celsius_to_fahrenheit(data['dew_point']))
    payload += "&heatindex=" + str(celsius_to_fahrenheit(heat_index(temp=data['temperature'], hum=data['humidity'])))
    payload += "&humidex=" + str(celsius_to_fahrenheit(humidex(t=data['temperature'], d=data['dew_point'])))
    payload += "&precip=" + str(data['precip'])
    payload += "&uv" + str(data['uv'])
    return payload

затем отправляем:

import requests

""" ... """

def send_data_to_wu(data):
    wu_url = "https://weatherstation.wunderground.com/weatherstation/updateweatherstation.php?"
    wu_creds = "ID=" + wu_station_id + "&PASSWORD=" + wu_station_pwd
    response = requests.get(f'{wu_url}{wu_creds}{data}')
    return response.content

В результате мы должны увидеть данные на своей метеостанции.

Тут нужно сделать ремарку, что все сервисы, включая WU требуют регистрации и часто получения API ключей. Вся конфиденциальная информация хранится в secure_data.py

# Geo Data
latitude =
longitude =
altitude =
cur_location =

# WEATHER UNDERGROUND DATA
wu_api_key =
wu_station_id =
wu_station_pwd =
wu_reference_station_id =

# OPEN WEATHER DATA
ow_api_key =
ow_station_id =

# PWSWEATHER DATA
pwsw_station_id =
pwsw_api_key =

# NARODMON DATA
narodmon_name = 
narodmon_owner = 
narodmon_mac = 
narodmon_api_key = 

Заполняем значения и продолжаем :)

WeatherUnderground увы, работает на платной основе и полученный мной ключ действует всего лишь год. Поэтому, поискав альтернативы, я наткнулся на PWS Weather.

Несмотря на то, что на сайте нет описания публичного API для сторонних устройств (не промышленных метеостанций), разработчики охотно по запросу присылают примеры, впрочем, за одну ночь и без их помощи решил задачу по отправке данных, благо формат данных сайт использует такой же, как и WU.

def send_data_to_pwsw(data):
    pwsw_url = "http://www.pwsweather.com/pwsupdate/pwsupdate.php?"
    pwsw_creds = "ID=" + pwsw_station_id + "&PASSWORD=" + pwsw_api_key
    response = requests.get(f'{pwsw_url}{pwsw_creds}{data}')
    return response.content

Этот сервис мне показался более дружелюбным, в нём есть переключатель с имперской на метрическую систему. Плюс красивые графики, прогнозы.

Ещё можно послать данные на OpenWeatherMap. Персональной страницы тут нет, а в ответ на исторические данные мы получим "средние" по больнице данные, но, почему бы и нет? Энтузиастам надо помогать. Для передачи показаний у OWM для PWS (personal weather station) своё API, но что-то я не нашёл готовой обёртки для него на python, поэтому написал свою.

В отличие американских WeatherUnderground и PWS Weather, использующих имперскую систему, разраработчики OpenWeatherMap из Латвии и используют метрическую систему (Си), поэтому для передачи показаний для них не используем конвертеры, а пишем данные сразу из базы данных, которые мы собрали с датчиков.

from openweather_pws import Station

def send_data_to_ow(data):
    pws = Station(api_key=ow_api_key, station_id=ow_station_id)
    response = pws.measurements.set(temperature=data['temperature'], humidity=data['humidity'],
                                    dew_point=data['dew_point'], pressure=data['pressure'],
                                    heat_index=fahrenheit_to_celsius(heat_index(temp=data['temperature'],
                                                                                hum=data['humidity'])),
                                    humidex=humidex(t=data['temperature'], d=data['dew_point']))
    return response

И, наконец, как вариант импортозамещения самый функциональный, позволяющий хранить в том числе данные с закрытых датчиков (например те же данные о температуре внутри дома) - Narodmon.

На сервисе достаточно богатое API, позволяющее не только передавать показания с датчиков, но и собирать информацию по геолокации, управлять самим устройством удалённо, так и социальные фишки вроде "поставить лайк" или отправить сообщение. Особо здорово, что сервис шлёт email'ы в случае проблем (например датчик не вышел на связь час), так и настраиваемые "проблемы" вроде превышения лимита на конкретном датчике. Но, как и в случае OWM я не нашёл полного API-wrapper для python, и опять написал свой. Теперь, чтобы отправить данные с датчиков, зовём:

def send_data_to_nardmon(data):
    nm = Narodmon(mac=narodmon_mac, name=narodmon_name, owner=narodmon_owner,
                  lat=latitude, lon=longitude, alt=altitude)
    temperature = nm.via_json.prepare_sensor_data(id_in="TEMPC", value=data['temperature'])
    pressure = nm.via_json.prepare_sensor_data(id_in="MMHG", value=(data['pressure']))
    humidity = nm.via_json.prepare_sensor_data(id_in="HUM", value=data['humidity'])
    dew_point = nm.via_json.prepare_sensor_data(id_in="DEW", value=data['dew_point'])
    sensors = [temperature, pressure, humidity, dew_point]
    response = nm.via_json.send_short_data(sensors=sensors)
    return response

У данного сервиса есть одна дурацкая отличительная особенность, состоящая в том, что нельзя отправлять данные чаще чем раз в пять минут. Но на практике то ли у нас разные пять минут, то ли сайт подвисает, реально данные отправляются раз в 10-15 минут. Если всё сделано правильно, то увидим данные на сайте.

Немаловажным будет сказать, что для отправки данных следует "дёргать" ручку /api/v1/send_data пустым GET-запросом. Чтобы не изобретать велосипед, просто поручим это делать cron. Добавляем ещё одну строку:

*/5 * * * * /usr/bin/wget -O - -q -t 1 http://0.0.0.0:80/api/v1/send_data

А как же камера?

Пока никак. Сделанные фото можно передавать на WeatherUnderground. Это сделать несложно через ftp

from ftplib import FTP

def send_image_to_wu(image):
    session = FTP('webcam.wunderground.com', wu_cam_id, wu_cam_pwd)
    file = open(image, 'rb')
    session.storbinary('image.jpg', file)
    file.close()
    session.quit()

Однако, даже просто вручную передать данные на WU у меня не получилось ни разу. Судя по форумам техподдержки, данная фича работает плохо и сбоит.

Альтернативной является передача изображения на narodmon.ru,

Собственно, время от времени (раз в полчаса) дёргаем ручку /api/v1/capture_photo (которая зовёт take_photo). Например, будем звать через cron этот bash-скрипт:

#!/bin/bash

PATH_TO_PHOTO=`/usr/bin/wget -O - -q -t 1 http://0.0.0.0/api/v1/capture_photo`
REQUEST='curl -F YOUR_CAM_KEY=@'$PATH_TO_PHOTO' http://narodmon.ru/post'
RESULT=`$REQUEST` >/dev/null 2>&1

На сервисе сразу появится снимок:

Плюс, не забываем время от времени (например раз в семь дней) чистить старые изображения:

#!/bin/bash bash

# Notes:
# This file will remove all files in camera folder older than 7 days, just run in via cron periodically (i.e. daily).
find /home/pi/web-server/camera/ -type f -mtime +7 -name '*.jpg' -execdir rm -- '{}' \;

Что дальше?

В планах довести станцию до ума, как минимум оборудовать метеобудку, когда сойдёт снег, а так же поднять датчик ветра по весне на нужную высоту. Из софта явно нужно будет ещё допиливать по ходу дела. Неплохо будет так же на так же на основе собираемых данных организовать управление приборами (балласт на ветряке, отопление).

Предвосхищая вопросы и комментарии в духе "а где же корпуса? опять колхоз". Каюсь, пока ничего "красивого" не могу показать, жду когда пройдут морозы, и хотя бы будет близко к нулю, чтобы пойти в мастерскую выпиливать будку, корпуса и прочая.