⚠️ Disclaimer: обычно я заканчиваю свои статьи фразой о том, что всё написанное может оказаться дичайшим овер-инжинирингом. В случае с этой статьёй я вынужден предупредить читателя об этой опасности заранее.
Если коротко, то это история о том, как я попытался сохранить выращенный урожай при помощи подручных средств: ОСП, утеплителя, ESP8266, керамического рептилического нагревателя и сервисов Yandex.Cloud. Успешно ли — покажет только весна.
Всем привет. Меня зовут Станислав, и я Java-разработчик. Два года назад я переехал в частный дом. Если в первый год земля возле него по большей степени простаивала, то этим летом мы с женой решили добавить в нашу семейную жизнь чуть больше посадок, и в особенности картофеля. Картошка выросла безо всякой интриги, не хорошая, не плохая, а просто обычная. Но после сбора и подсчёта (совсем немного, так-то: примерно 13-15 ведер отборной) возник вопрос об её зимней сохранности.
Имеющиеся варианты были не без минусов: родительский погреб находится на расстоянии 45 км проездом через весь центр города, в доме внутри слишком тепло — прорастёт, а в неутеплённом хозяйственном блоке слишком холодно (зимой до -35 °C). У меня есть желание в перспективе сделать где-то на участке полноценный погреб, но к этой зиме оно реализоваться не успело.
Мысли о том, что пора что-то решать, сидели в моей голове уже вторую неделю, и усиливались с приближением зимы. Как вдруг, оглядевшись в сарае на те стройматериалы, что у меня есть, меня посетил план…
“У меня как раз всё для этого есть”
Коллеги надо мной иногда даже шутят мемами. Не раз и не два проскакивала в голове у меня эта мысль, при взгляде на подручные средства. Так произошло и в этот раз. Что же было у меня под рукой?
Полтора больших листа ОСП (OSB, oriented strand board) размером 2,5м×1,25м×12мм и ещё немного обрезков.
Семь плит технониколевского XPS утеплителя толщиной 50мм.
Два Wemos D1 Mini Pro (похож).
Два твердотельных реле на 220 В разной мощности (раз и два).
Патроны и лампочки накаливания, оставшиеся со времён до отделки дома.
Идея была в следующем. Конструирую из XPS плит размером 1×1×0.5 шт. небольшой ящик. Подгоняю под него каркас из ОСП, стягиваю уголками. Внутри обшиваю отражающей плёнкой. Внутри размещаю ESP8266, он по протоколу 1-wire опрашивает температуру с датчиков, через ШИМ управляет лампочкой накаливания, которая греет воздух. Плюс небольшой USB-вентилятор, который будет гонять внутри воздух.
Фото с этапа сборки
Базовая часть ящика собрана, примеряемся:
На этом моменте выяснилось, что расположенная картошка занимает всё доступное пространство. А ведь нужно ещё предусмотреть воздушное пространство для обогревателя, электроники, а также подумать про хранение морковки и свёклы!
Хорошо, что в остатках ещё лежит немного обрезков ОСП и пара листов XPS. Делаю объёмную крышку, а заодно небольшую паллету внизу для циркуляции воздуха и защиты от потенциально стекающей влаги.
Ещё пара фото сборки
Теперь внутри достаточно свободного места.
Начинаем кодить
Построив ящик, начинаю формировать требования к софту.
Во-первых, в наличии имеется три термодатчика, два с проводами покороче, один подлиннее. Два покороче планирую разместить внутри ящика, а длинный вывести на улицу. Напишем код, который будет заниматься сбором данных с датчиков:
temperature.h
#ifndef TEMPERATURE_H
#define TEMPERATURE_H
#include <OneWire.h>
#include <DallasTemperature.h>
// Используется схема подключения с подтягивающим резистором на линии питания.
#define ONE_WIRE_PULLUP 3
// Максимальное количество работающих одновременно датчиков температуры.
#define TEMPERATURE_SENSORS_COUNT 4
// Температура, когда сенсор не найден.
#define ERRONEOUS_VALUE -150.0f
class TEMPERATURE_RESULT {
private:
uint8_t devices;
uint64_t addresses[TEMPERATURE_SENSORS_COUNT];
float values[TEMPERATURE_SENSORS_COUNT];
public:
inline TEMPERATURE_RESULT(uint8_t devices, uint64_t addresses[], float values[]) {
this->devices = std::min((int) devices, TEMPERATURE_SENSORS_COUNT);
for(uint8_t idx = 0; idx < this->devices; idx += 1) {
this->addresses[idx] = addresses[idx];
this->values[idx] = values[idx];
}
}
inline uint8_t getDeviceCount() {
return devices;
}
inline uint64_t getAddress(uint8_t idx) {
return 0 <= idx && idx < devices
? addresses[idx]
: -1;
}
inline float getValue(uint8_t idx) {
return 0 <= idx && idx < devices
? values[idx]
:ERRONEOUS_VALUE;
}
inline float getValueByAddress(uint64_t address) {
for(uint8_t idx = 0; idx < devices; idx += 1) {
if (addresses[idx] == address) {
return values[idx];
}
}
return ERRONEOUS_VALUE;
}
};
class OneWireDS18B20Measurer {
private:
DallasTemperature *sensors;
public:
inline OneWireDS18B20Measurer(DallasTemperature *dallasTemperature) {
this->sensors = dallasTemperature;
}
void setup();
TEMPERATURE_RESULT measure();
};
#endif // TEMPERATURE_H
temperature.cpp
#include "common.h"
#include "temperature.h"
void OneWireDS18B20Measurer::setup() {
this->sensors->begin();
}
TEMPERATURE_RESULT OneWireDS18B20Measurer::measure() {
DallasTemperature & DT = * this->sensors;
DT.requestTemperatures();
uint8_t devices = DT.getDeviceCount();
uint64_t addresses[TEMPERATURE_SENSORS_COUNT];
float values[TEMPERATURE_SENSORS_COUNT];
for(uint8_t idx = 0; idx < devices; idx += 1) {
if (TEMPERATURE_SENSORS_COUNT <= idx) {
break;
}
DT.getAddress((uint8_t *) & addresses[idx], idx);
values[idx] = DT.getTempCByIndex(idx);
Serial.printf("Sensor %llX temperature is %.1f C.\n", addresses[idx], values[idx]);
}
return TEMPERATURE_RESULT(devices, addresses, values);
}
Снятые данные считаем сырыми. На основе их по достаточно наивной модели рассчитываем некоторую условную температуру внутри ящика. На всякий случай учитываем вероятность выхода любого из датчиков из строя (знакомый поделился своим опытом, что DS18B20 могут случайно инвертировать биты в своём адресе).
model.h
#ifndef MODEL_H
#define MODEL_H
#include "temperature.h"
extern float calcApproximateInternalTemperature(TEMPERATURE_RESULT raw);
#endif // MODEL_H
model.cpp
#include "model.h"
const uint64_t OUTSIDE_SENSOR_ADDRESS = 0xA40119282FCCCC28; // Самый длинный провод.
const uint64_t HEATERS_SENSOR_ADDRESS = 0xA50300A279D11E28; // Короткий, без узелка.
const uint64_t BOTTOM_SENSOR_ADDRESS = 0x250300A2799A4F28; // Короткий, завязан узелком.
float calcApproximateInternalTemperature(TEMPERATURE_RESULT raw) {
float outsideTemperature = raw.getValueByAddress(OUTSIDE_SENSOR_ADDRESS);
float heatersTemperature = raw.getValueByAddress(HEATERS_SENSOR_ADDRESS);
float bottomTemperature = raw.getValueByAddress(BOTTOM_SENSOR_ADDRESS);
// Внезапно оба сенсора вышли из строя?
if (heatersTemperature <= ERRONEOUS_VALUE && bottomTemperature <= ERRONEOUS_VALUE) {
// TODO: Поменять на статус "у нас мега-проблема".
// Пока выключил обогреватели, чтобы не сжечь всё нахрен.
return 123.0f;
}
// В наличии только работающий сенсор под поддоном.
if (heatersTemperature <= ERRONEOUS_VALUE) {
return bottomTemperature;
}
// В наличии только работающий сенсор около нагревателей.
if (bottomTemperature <= ERRONEOUS_VALUE) {
return heatersTemperature;
}
// Оба сенсора в порядке, измеряем среднее на треть ближе к поддону.
return (2.0f * bottomTemperature + heatersTemperature) / 3.0f;
}
В том далёком родительском погребе у нас уже был опыт в самые сильные морозы использовать лампочку накаливания для подогрева. Картошка от неё, кстати, не прорастает. Но там площадь большая, это не закрытый ящик, её можно было без опаски оставить на целый месяц. В небольшом замкнутом пространстве всё иначе — не хочется включать её лишний раз без необходимости, нужно постараться продлить срок службы, а в идеале вообще заменить на какой-нибудь нагревательный прибор.
Первая итерация по реализации высокочастотного ШИМ, к сожалению, рассыпалась в прах из-за поддержки zero-crossing на обоих моих реле — при подаче управляющего фронта происходит задержка включения до тех пор, пока напряжение между линиями не пройдёт через ноль. Это сразу же ограничило меня в частоте ШИМа до минимальных значений около 10 Гц. На более высоких частотах из-за неравномерности частоты сети проявляются пакетные эффекты и управления яркостью, по сути, никакого нет. Зато на частоте 10 Гц это не уже не ШИМ, а противный стробоскоп — лампочка на такой частоте успевает полностью загореться и потухнуть. Это исключает задуманное мной "продление жизни лампочки", ну кроме самого ZC — включение при проходе нуля это всё-таки более щадящий режим работы.
Поскольку я увидел повышенный риск перегорания лампочки, я решил задублировать её. Тем более, что у меня есть второе реле. не имея достоверных сведений о времени жизни моргающей лампы, для второй, резервной, я решил использовать альтернативный алгоритм — просто включение и выключение по гистерезису. Его нижняя граница находится немного ниже нижней границы ШИМа, чтобы без необходимости вообще не приходилось использовать этот канал.
Код управления условными нагревательными элементами получился такой:
hysteresis.h
#ifndef HYSTERESIS_H
#define HYSTERESIS_H
enum LastState {
FULLY_OFF = 0, // Полностью выключен.
SOFT_ON = 1, // Включён с использованием ШИМ.
FULLY_ON = 2, // Полностью включён.
};
extern void setupHysteresisPWM();
class HysteresisSwitcher {
private:
uint8_t pin;
float min;
float max;
LastState lastState;
bool supportsPWM;
int pwmChannel;
void updateSoft(float value);
void updateHard(float value);
void stopPwm();
void turnHardOn();
void turnHardOff();
public:
inline HysteresisSwitcher(uint8_t targetPin, float minTemperature, float maxTemperature, bool supportsPWM) {
this->pin = targetPin;
pinMode(targetPin, OUTPUT);
this->min = minTemperature;
this->max = maxTemperature;
this->supportsPWM = supportsPWM;
}
void updateCurrentTemperature(float temperature);
inline LastState getState() {
return lastState;
}
};
#endif // HYSTERESIS_H
hysteresis.cpp
#include <cmath>
#include <ESP.h>
#define USING_MICROS_RESOLUTION true
#include <ESP8266_PWM.h>
#include "hysteresis.h"
#define HW_TIMER_INTERVAL_US 20L
#define PWM_FREQUENCE 10.0f
#define LOW 0
#define HIGH 1
ESP8266Timer pwmTimer;
ESP8266_PWM pwmController;
void IRAM_ATTR pwmTimerHandler() {
pwmController.run();
}
void setupHysteresisPWM() {
pwmTimer.attachInterruptInterval(HW_TIMER_INTERVAL_US, pwmTimerHandler);
}
void HysteresisSwitcher::updateCurrentTemperature(float value) {
if (this->supportsPWM) {
this->updateSoft(value);
} else {
this->updateHard(value);
}
}
void HysteresisSwitcher::updateSoft(float value) {
float workingDiapason = max - min;
Serial.printf("PIN %d, workingDiapason = %f\n", this->pin, workingDiapason);
float valueInDiapason = value - min;
Serial.printf("PIN %d, valueInDiapason = %f\n", this->pin, valueInDiapason);
float proportional = valueInDiapason / workingDiapason;
float duty = 1.0f - proportional;
Serial.printf("PIN %d, Value = %f, prop = %f, duty = %f\n", this->pin, value, proportional, duty);
if (duty <= 0.0f) {
if (pwmChannel >= 0) {
pwmController.deleteChannel((unsigned) pwmChannel);
pwmChannel = -1;
}
turnHardOff();
return;
}
if (duty >= 1.f) {
if (pwmChannel >= 0) {
pwmController.deleteChannel((unsigned) pwmChannel);
pwmChannel = -1;
}
turnHardOn();
return;
}
float percentage = 100.0f * duty;
if (pwmChannel >= 0) {
pwmController.modifyPWMChannel(pwmChannel, pin, PWM_FREQUENCE, percentage);
} else {
pwmChannel = pwmController.setPWM(pin, PWM_FREQUENCE, percentage);
}
lastState = SOFT_ON;
}
void HysteresisSwitcher::updateHard(float value) {
bool turnOn = value <= min;
bool turnOff = max <= value;
// Выключено, нужно включить.
if (FULLY_OFF == lastState && turnOn) {
turnHardOn();
return;
}
if (FULLY_ON == lastState && turnOff) {
turnHardOff();
return;
}
}
void HysteresisSwitcher::turnHardOn() {
pinMode(pin, OUTPUT);
digitalWrite(pin, HIGH);
this->lastState = FULLY_ON;
Serial.println("Heater on pin " + String(pin) + " is turned on.");
}
void HysteresisSwitcher::turnHardOff() {
pinMode(pin, OUTPUT);
digitalWrite(pin, LOW);
this->lastState = FULLY_OFF;
Serial.println("Heater on pin " + String(pin) + " is turned off.");
}
Фактически я просто сделал реле контроля температуры из того, что было под рукой. При том, что такие вещи вполне себе продаются в исполнении на DIN-рейку. Теперь нужно сделать его "умным".
Я вообще ни разу не специалист в области, в которую сейчас залез, поэтому для меня важно всё контролировать и максимально быстро реагировать на инциденты. Если что-то пойдёт не так, то инцидент может обернуться или потерей урожая, или даже каким-нибудь пожаром. Я совершенно не хочу сидеть и думать, всё ли у меня правильно работает. Я хочу знать об этом.
Если говорить конкретнее, идея состоит в том, что нужно передавать с устройства всё его внутреннее состояние на надёжный сервер, агрегировать данные во времени и получать уведомления, когда какой-то параметр выходит за установленные рамки (или данные вообще перестали приходить).
Целевых адреса, куда устройство должно отправлять данные, я решил сделать два: первый сервер будет на IP-адреса моего ПК в домашней сети, а второй будет в облаке. Облако удобно тем, что не зависит от наличия электричества во всём моём коттеджном посёлке, отключения которого редки, но случаются. Локальный сервер удобен для этапа пусконаладки и на случай, если что-то в облаке не будет оплачено вовремя.
Код отправки получился такой:
http.h
#ifndef HTTP_H
#define HTTP_H
#define WIFI_SSID "Тут моя Wi-Fi сеть"
#define WIFI_PASS "А тут пароль от неё ;)"
#include "temperature.h"
#include "hysteresis.h"
extern void setupWiFi();
extern void notifyServers(TEMPERATURE_RESULT & measurementResult, float modellingResult, LastState state0, LastState state1);
#endif // HTTP_H
http.cpp
#include "HardwareSerial.h"
#include "http.h"
#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <ESP8266WiFiMulti.h>
#include <ESP8266HTTPClient.h>
#include <WiFiClientSecureBearSSL.h>
#include <ArduinoJson.h>
ESP8266WiFiMulti WiFiMulti;
// Отпечаток сертификата.
const uint8_t fingerprint[] = { 0x00, };
struct SERVER_ENDPOINT {
bool secure;
String address;
} ENDPOINTS[] = {
{
false,
String("http://<IP-адрес в моей LAN>:9700/api/potato"),
}, {
true,
String("https://<облачный адрес>/api/potato"),
},
};
void setupWiFi() {
for (uint8_t t = 4; t > 0; t -= 1) {
Serial.printf("[SETUP] WAIT %d...\n", t);
Serial.flush();
delay(1000);
}
WiFi.mode(WIFI_STA);
WiFiMulti.addAP(WIFI_SSID, WIFI_PASS);
Serial.println("MAC adddress: " + WiFi.macAddress());
}
String serializeRequest(TEMPERATURE_RESULT & measurementResult, float modellingResult, LastState state0, LastState state1);
void sendToSecureServer(String requestPayload, String address);
void sendToInsecureServer(String requestPayload, String address);
void onExchangeComplete(int httpStatus, String responsePayload);
void notifyServers(TEMPERATURE_RESULT & measurementResult, float modellingResult, LastState state0, LastState state1) {
wl_status_t status = WiFiMulti.run();
if (WiFiMulti.run() != WL_CONNECTED) {
Serial.println("Not connected to WiFi station.");
return;
}
String requestBody = serializeRequest(measurementResult, modellingResult, state0, state1);
for (uint8_t serverId = 0; serverId < sizeof (ENDPOINTS) / sizeof (SERVER_ENDPOINT); serverId += 1) {
SERVER_ENDPOINT & endpoint = ENDPOINTS[serverId];
Serial.println("POST " + endpoint.address);
if (endpoint.secure) {
sendToSecureServer(requestBody, endpoint.address);
} else {
sendToInsecureServer(requestBody, endpoint.address);
}
}
}
String serializeRequest(TEMPERATURE_RESULT & measurementResult, float modellingResult, LastState state0, LastState state1) {
StaticJsonDocument<1024> json;
json["deviceId"] = WiFi.macAddress();
JsonObject raw = json.createNestedObject("raw");
for(uint8_t sensorId = 0; sensorId < measurementResult.getDeviceCount(); sensorId += 1) {
char key[20];
sprintf(key, "%llX", measurementResult.getAddress(sensorId));
raw[key] = measurementResult.getValue(sensorId);
}
json["indoorTemperature"] = modellingResult;
JsonArray heaters = json.createNestedArray("heaters");
heaters[0] = state0;
heaters[1] = state1;
String result;
serializeJson(json, result);
Serial.println("JSON: " + result);
return result;
}
void sendToSecureServer(String requestPayload, String address) {
std::unique_ptr<BearSSL::WiFiClientSecure> client(new BearSSL::WiFiClientSecure);
client->setInsecure();
// client->setFingerprint(fingerprint);
HTTPClient http;
if (http.begin(*client, address)) {
http.addHeader("Content-Type", "application/json");
int httpStatus = http.POST(requestPayload);
if (httpStatus > 0) {
onExchangeComplete(httpStatus, http.getString());
} else {
Serial.printf("[HTTPS] Failed, error: %s\n", http.errorToString(httpStatus).c_str());
}
http.end();
} else {
Serial.printf("[HTTPS] Unable to connect.\n");
}
}
void sendToInsecureServer(String requestPayload, String address) {
WiFiClient client;
HTTPClient http;
if (http.begin(client, address)) {
http.addHeader("Content-Type", "application/json");
int httpStatus = http.POST(requestPayload);
if (httpStatus > 0) {
onExchangeComplete(httpStatus, http.getString());
} else {
Serial.printf("[HTTP] Failed, error: %s\n", http.errorToString(httpStatus).c_str());
}
http.end();
} else {
Serial.printf("[HTTP] Unable to connect.\n");
}
}
void onExchangeComplete(int httpStatus, String responsePayload) {
if (httpStatus != HTTP_CODE_OK) {
return;
}
Serial.println(responsePayload);
}
Дальше просто собираем готовые кирпичики вместе:
esp8266_potato_firmware.ino
#include "common.h"
#include "temperature.h"
#include "model.h"
#include "hysteresis.h"
#include "http.h"
// Как часто отрабатывает цикл измерение-решение-уведомление.
#define CONTROL_LOOP_DELAY_MS 30000
#define STRONG_HEATER_TEMPERATURE_HIGH 5.5f
#define WEAK_HEATER_TEMPERATURE_HIGH 5.5f
#define WEAK_HEATER_TEMPERATURE_LOW 3.0f
#define STRONG_HEATER_TEMPERATURE_LOW 1.5f
OneWire oneWireMaster(ONE_WIRE_PIN);
DallasTemperature temperatureSensors(&oneWireMaster, ONE_WIRE_PULLUP);
OneWireDS18B20Measurer temperatureMeasurer(&temperatureSensors);
HysteresisSwitcher weakHeaterSwitcher(WEAK_HEATER_PIN,
WEAK_HEATER_TEMPERATURE_LOW,
WEAK_HEATER_TEMPERATURE_HIGH,
true);
HysteresisSwitcher strongHeaterSwitcher(STRONG_HEATER_PIN,
STRONG_HEATER_TEMPERATURE_LOW,
STRONG_HEATER_TEMPERATURE_HIGH,
false);
void setup() {
Serial.begin(115200);
Serial.println();
temperatureMeasurer.setup();
setupHysteresisPWM();
setupWiFi();
}
float debugTemp = -50.0f;
float enterDebugTemp() {
if (Serial.available() > 0) {
char buffer[100] = { 0 };
Serial.readBytesUntil('\n', buffer, sizeof(buffer));
sscanf(buffer, "%f", &debugTemp);
Serial.println("Debug temp has been changed to " + String(debugTemp));
}
return debugTemp;
}
void loop() {
TEMPERATURE_RESULT measurementResult = temperatureMeasurer.measure();
float modellingResult = calcApproximateInternalTemperature(measurementResult);
Serial.println("Modelled internal temperature is " + String(modellingResult) + " C.");
float overridden = enterDebugTemp();
if (overridden > -50.0f) {
modellingResult = overridden;
}
weakHeaterSwitcher.updateCurrentTemperature(modellingResult);
strongHeaterSwitcher.updateCurrentTemperature(modellingResult);
notifyServers(
measurementResult,
modellingResult,
weakHeaterSwitcher.getState(),
strongHeaterSwitcher.getState());
delay(CONTROL_LOOP_DELAY_MS);
}
Конечно, такой код хочется отрефакторить, да ещё и не раз. Но пока он работает, а меня поджимало время.
Установка
Параллельно с написанием прошивки всё это железо начинало принимать какую-то форму у меня на столе. Я купил самый маленький 4-юнитовый пластиковый щиток, установил оба реле, копеечную din-розетку, вывел нужные кабели. Подключил к Wemos все три термодатчика.
К задней стенке крышки ящика прикрутил кусок ГВЛ.
После того, как код был отлажен, а лампа накаливания проморгала суммарно сутки на разных частотах и не сгорела, я убедился, что всё работает ожидаемо и установил.
Последние фотографии сборки в этой статье
Если честно, на данный момент всё это выглядит максимально уродливо. Вместо того, чтобы разделать провода питания Wemos и вентилятора и запитать от одного блока питания, я просто вставил в розетку тройник и две зарядки ????♂️. С другой стороны какая-то независимость по питанию. Правильнее было бы купить щиток побольше и пару источников питания на DIN. Также болтаются мотки кабелей, которые было лениво обрезать, плюс грела мысль "а вдруг я буду что-то внутри переигрывать по расположению". Вся остальная эстетика тоже страдает. Если этой зимой эксперимент окажется хотя бы частично успешным, к следующему году его будет ждать хороший апгрейд.
Ещё мне не нравится, что вентилятор включён постоянно, и сейчас есть желание подключить его через реле и тоже управлять им с микроконтроллера. Но у меня кончились реле, так что оставим и этот пункт для v2.
Я заказал внешние Wi-Fi антенны, но приём и так получился достаточно стабильный, переподключений устройства к моему Keenetic-у всего несколько в день, все они происходят моментально, и на графиках метрик практически нет разрывов. Я думал, что будет намного хуже.
Люблю Aliexpress за это
Как я писал выше, мне самому не нравится идея использовать лампочки накаливания. Поэтому я отправился искать что-нибудь интересное на просторах. Много неожиданного мне попалось на пути к тому товару, который меня заинтересует.
Рука-лицо
Такие нагреватели не перегорят с такой же вероятностью, как лампочка, а также не будут мучать мои глаза морганием при открывании в мороз. Заказал несколько штук разных номиналов, по мере похолодания буду подбирать максимально подходящую конфигурацию.
На данный момент я уже попробовал заменить свою 75 Вт лампочку на ШИМ-канале на 25 Вт и на 75 Вт нагреватели. Температура внутри держится стабильно. Немного снизилась температура внизу, где поддон, но это может быть связано с тем, что я с каждым открытием ящика наталкиваю в него больше продуктов и становится меньше места для циркуляции воздуха.
Подбираемся к облакам
Мне давно хотелось познакомиться ближе с такими облачными технологиями, как Cloud Functions, Serverless и т.п. Я использовал S3 для хранения данных, неплохо знаком с Kubernetes, писал Helm-чарты, но текущая задача этого не требует. К тому же я знал, что при регистрации в Yandex Cloud выдаётся стартовый грант, которым можно оплачивать свои эксперименты. Поэтому я выбрал Яндекс, пошёл читать их документацию и строить архитектуру.
Архитектура получилась следующая:
Публично-доступный serverless-контейнер, который будет подниматься при поступлении данных от устройства, немного преобразовывать их и сохранять в пользовательские метрики в Мониторинге.
Дашборд для отображения пользовательских метрик с настроенными алертами.
Ещё один serverless-контейнер, запускаемый по временному триггеру, который будет на основании последних данных решать, отправить ли уведомление в Телеграмм.
Уведомления мне нужны вполне стандартные: слишком низкая/высокая температура (кроме уличной), данные не поступают.
Первый микросервис
Важным критерием для serverless-контейнеров является скорость запуска. Чем быстрее запускается контейнер и чем быстрее он обрабатывает входящий запрос, тем меньше я за это плачу. Учёт идёт условно в тактах CPU и поэтому вариант “насыпать больше ядер” прожорливый сервис не спасёт, хоть и позволит ему запуститься быстрее. Устройство в ящике присылает данные раз в 30 секунд, и мне хотелось бы, чтобы за это время контейнер успевал не только обработать запрос, но и преимущественно постоять в idle, причём на минимально возможных ресурсах CPU / ОЗУ. В консоли Яндекс.Cloud минимальные ресурсы можно выставить вот такие:
Наверное, я без проблем бы написал микросервис и на node.js, но поскольку я преимущественно Java-программист, я хотел бы остаться на этом стеке. Вдруг мои хотелки будут расти, а реализовывать новые фичи на знакомом языке и с использованием знакомых библиотек всё-таки быстрее. Варианты уложиться с JVM в минимальные ресурсы есть, я увидел два следующих:
Не использовать никаких фреймворков типа Spring, писать всё на голой Java, возможно в контейнере ужать JVM, вырезав из неё ненужные модули.
Использовать нативную компиляцию (GraalVM). Тогда можно использовать фреймворки (Quarkus, Helidon, Micronaut и даже Spring Native). Должно также быть эффективнее первого варианта.
Quarkus и ????♂️
Я решил начать с Quarkus. Я начал новый пустой проект, подключил туда реализации http-сервера и http-клиента, написал метод-приёмник с реализацией-заглушкой для обработки сообщения от устройства.
Для компиляции в нативный код Quarkus попросил установить Visual C++. Я установил. Потом он всё равно не нашёл cl.exe, потому что не были установлены правильные переменные окружения. Их устанавливает батник x64 Native Tools Command Prompt. Насколько я помню, в каких-то старых версиях VS он был устроен достаточно просто, настолько просто, что можно было взять пару значений и просто прописать в переменные окружения пользователя. Сейчас же это для меня совершенно нечитаемый комбайн. То есть вариант для сборки один – сперва запустить именно этот батник, а затем из его командной строки запускать сборку Quarkus.
На самом деле про это написано в документации Quarkus-а:
Потратив примерно час, я-таки не нашёл быстрого решения, как запустить сборку из IDEA, подсунув в ней куда-либо что-либо, и попробовал мириться с положением вещей.
Но дальше я продвинуться всё равно не смог. Компиляция нативного файла падала на каком-то промежуточном шаге через пару минут после запуска без внятных ошибок. Для меня выводимый текст выглядел просто как “что-то не получилось”. Извините, сейчас я его уже не смогу привести. Возможно дело было в несовпадении версий VS (у меня локально была 2022, а Quarkus обозначал необходимую 2017), но проверять это у меня желания уже не было. Я расстроился и решил попрощаться с Quarkus-ом.
Spring спешит на помощь
Я постоянно читаю новости в блоге spring.io и в курсе развития такого подпроекта, как Spring Native. Всё выглядит так, что команда Spring действительно старается всеми силами сделать максимальную поддержку нативной компиляции в Framework 6 / Boot 3, но как получится на самом деле — покажет время.
Я воспользовался Initializer и создал новый Maven-проект. От обычного проекта от отличается только одной дополнительной зависимостью org.springframework.experimental:spring-native и парой maven-плагинов org.springframework.experimental:spring-aot-maven-plugin и org.graalvm.buildtools:native-maven-plugin. Последний, скорее всего, нужен только для локальной нативной сборки. Мы же целимся сразу получить docker-образ с нативным бинарником, в этом случае используются Cloud Native Buildpacks. Всё, что нужно от программиста – выполнить цель spring-boot:build-image. Поскольку компиляция происходит внутри и с помощью контейнеров, нужен только docker, зато не нужен Visual C++.
Снова набросав dummy-код с контроллером, сервисом и http-клиентом, я попробовал скомпилировать проект и натолкнулся на следующие проблемы.
Проблема 1. Jackson не может сериализовать/десериализовать DTO. Например:
/**
* Полное текущее состояние картофельного домика. Сырые и вычисленные данные.
*/
@Value
@Builder
@Jacksonized
public class PotatoHouseStateRequestDto {
/**
* Уникальный идентификатор устройства.
*/
String deviceId;
/**
* Сырые показания датчиков температуры.
*/
@NotNull
Map<@NotNull String, @NotNull Float> raw;
/**
* Температура на улице.
*/
float outdoorTemperature;
/**
* Усреднённая температура в ящике.
*/
float indoorTemperature;
/**
* Статус нагревательных элементов.
*/
@NotNull
List<@NotNull Integer> heaters;
}
Естественно, это проявляется только в нативном образе. Эта проблема решается достаточно легко — нужно добавить немного подсказок для плагина нативной сборки, а именно о каких классах сохранить полные данные рефлексии, чтобы Jackson смог воспользоваться ей в рантайме. Подсказки пишутся в аннотации над классом приложения:
@NativeHint(
types = @TypeHint(
types = {
PotatoHouseStateRequestDto.class,
IamTokenProvider.IamResponse.class,
MetricsRequestDto.class,
MetricsRequestDto.MetricDto.class,
MetricsRequestDto.TimeSeries.class,
},
access = {
TypeAccess.PUBLIC_CONSTRUCTORS,
TypeAccess.PUBLIC_METHODS,
}))
@SpringBootApplication(proxyBeanMethods = false)
@EnableConfigurationProperties(PotatoHouseProperties.class)
public class PotatoHouseApplication {
// ...
}
Минус только в том, что в одном месте нужно сидеть и перечислять все классы используемых DTO, в том числе отдельно и inner-классы. В большом проекте это прям анти-паттерн, но ведь у нас миниатюрный serverless-контейнер, имеющий только одну ответственность, и для него это не является проблемой.
Не проблема, просто факт 2. Я не хотел, чтобы лог писался в файл, поэтому переопределил конфиг logback на такой же файл, какой спрятан в глубинах Spring-а, но без FileAppender. Это полностью всё сломало и никаких логов ни в файле, ни в консоли не было. Приложение, похоже, даже не запустилось. Но, поскольку контейнер должен жить невероятно небольшое кол-во времени, я не придал этому какого-либо внимания, просто не стал переопределять настройки.
Не проблема, просто факт 3. Не получилось переключить реализацию сервлет-контейнера на Undertow или Jetty, вероятно максимально поддержан только Tomcat.
Логика работы
В целом всё, что от приложения требуется — принять событие от устройства и отправить его данные в Пользовательские метрики Мониторинга. Внезапно здесь у меня случилось очень много R&D итераций.
С одной стороны пришлось подумать про авторизацию. У Яндекса несколько разных видов токенов, и для вызова метода write нужно использовать IAM-токен. Дальше удивительное несовпадение желаемого (запустил на всю зиму и забыл) и действительности (токен живёт 12 часов). Варианты получения его через CLI yc или JWT не реже, чем каждые 12 часов, звучат на самом деле как "а зачем оно мне надо?".
Через какое-то время я нашёл другую страницу документации, где было сказано, что запущенный под service-account-ом контейнер может куда-то сходить и получить там текущий IAM-токен. Как хорошо, что это сработало! Считаю, что ссылки на эту и похожие страницы (для Cloud Functions, например) должны иметься в разделе со способами получения IAM-токенов.
Второй затык случился на отправке метрик. В описании метода write описана структура, но полностью отсутствует понятный пример необходимого JSON. Некоторые поля ввели меня в ступор: чем отличается ts от metrics[].ts и от metrics[].timeseries[].ts? Все ли они обязательны, или обязательно нужно передать хотя бы одно из них? Или вот: если я передаю только одно текущее значение метрики, я заполнил metrics[].name / labels / type / ts / value, нужно ли мне заполнять metrics[].timeseries вообще хоть как-то? Если данные берутся только из metrics[].timeseries[], решительно непонятно назначение metrics[].value. Осложнялось это тем, что метод write выполнялся успешно, но метрик в Мониторинге я своих не видел. Я уже подумал, что просто скрыты и получить их можно также через API. Но нет, они появились когда я заполнил вообще все возможные поля (все три ts, и все оба value) дубликатами данных. Думаю, команде проекта тут есть над чем поработать (документацией или валидацией на входе метода).
Если кому-то интересно, весь исходный код этого микросервиса расположен здесь.
CI / CD
Традиционно я храню все свои проекты в GitLab. Я относительно внимательно слежу за их блогом с 2015 года, когда сам поднимал и администрировал omnibus инстанс, по крайней мере, пробегаюсь глазами по ежемесячному релизному выпуску. Поэтому считаю, что неплохо разбираюсь в возможностях и ограничениях его CI/CD.
Для сборки образа и публикации в Container Registry Яндекса нам нужно будет всего две стадии сборки:
.gitlab-ci.yml
stages:
- prepare image
- deploy to yandex
variables:
GIT_STRATEGY: clone
IMAGE_TAG: $CI_PROJECT_NAME:$CI_COMMIT_REF_SLUG
CI_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
YA_REGISTRY: cr.yandex
YA_REGISTRY_USER: oauth
YA_IMAGE: cr.yandex/crp4rmi9sm7kt5e4hp67/potato-house:$CI_COMMIT_REF_SLUG
.job-with-docker:
services:
- name: serversideup/docker-utility
alias: docker
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_TLS_VERIFY: 1
DOCKER_CERT_PATH: "/certs/client"
build and test:
stage: prepare image
image: rcktsci/java-tooling:17-code-coverage
extends:
- .job-with-docker
variables:
MAVEN_OPTS: "-Dmaven.repo.local=${CI_PROJECT_DIR}/.m2/repository -Dmaven.compiler.forceJavacCompilerUse=true"
MAVEN_CLI_OPTS: "--batch-mode --settings ${CI_PROJECT_DIR}/.gitlab-ci-maven-settings.xml"
JACOCO_MAVEN: target/site/jacoco/jacoco.xml
script:
- mvn $MAVEN_CLI_OPTS versions:set -DartifactId='*' -DoldVersion='*' -DnewVersion="$CI_COMMIT_REF_SLUG"
- mvn $MAVEN_CLI_OPTS verify spring-boot:build-image
- docker login $CI_REGISTRY --username $CI_REGISTRY_USER --password $CI_REGISTRY_PASSWORD
- docker tag $IMAGE_TAG $CI_IMAGE
- docker push $CI_IMAGE
interruptible: true
after_script:
- python3 /opt/cover2cover.py ${JACOCO_MAVEN} src/main/java > cobertura.xml
- python3 /opt/source2filename.py cobertura.xml
cache:
key: .m2
paths:
- .m2
when: always
artifacts:
paths:
- target/*.jar
expire_in: 30 mins
reports:
junit: target/*-reports/TEST-*.xml
coverage_report:
coverage_format: cobertura
path: cobertura.xml
tag and push:
stage: deploy to yandex
image: rcktsci/java-tooling:17
extends:
- .job-with-docker
script:
- docker login $CI_REGISTRY --username $CI_REGISTRY_USER --password $CI_REGISTRY_PASSWORD
- docker login $YA_REGISTRY --username $YA_REGISTRY_USER --password $YA_REGISTRY_PASSWORD
- docker pull $CI_IMAGE
- docker tag $CI_IMAGE $YA_IMAGE
- docker push $YA_IMAGE
interruptible: true
only:
refs:
- main
Использование сервиса serversideup/docker-utility обусловлено вот этим Issue, а что за образ такой rcktsci/java-tooling:17-code-coverage — я рассказывал в одной из своих прошлых статей.
Единственная странность, которую я заметил, это иногда для обновления запускаемого образа нужно зайти в Serverless >> Редактор и, ничего не меняя, нажать Создать ревизию. Хотя образ для запуска указан именно по тегу, а не по хэшу. Возможно, это потому, что контейнер не успевает прибиваться каждый раз после обработки запроса от устройства. Опять же, для варианта "написал и запустил на всю зиму" — не проблема.
Настраиваем dashboard
С того момента, как в разделе Обзор метрик появились загруженные мной данные, дальше всё легко. Создаём новый дашборд, на нём добавляем две панели для данных термодатчиков и для состояний нагревательных элементов:
Я также установил мобильное приложение Yandex.Cloud и настроил канал для алертов в виде пушей. Пока мне его хватает. Пуши приходят и когда срабатывают алерты (есть уровни предупреждения и критический), и когда нет данных. Правда, добираться до дашборда приходится через очень много тапов, могли бы сделать возможность вынести его виджетом на экран. Или добавить дашборд в избранное для быстрого перехода. И нельзя выделением менять масштаб. В общем, в приложении тоже ещё есть над чем поработать :)
В какой-то момент через несколько недель после запуска прилетел предупредительный алерт, что температура низка. Хорошо, что у меня есть вторая плата Wemos, я подключил её и съэмулировал текущее состояние. Нашёл баг, исправил, сбегал за платой в ящике, залил новую прошивку, отнёс обратно. На второй плате ещё не всё распаяно, как нужно, так можно было бы просто отнести и поменять их местами.
Видно, что даже одна лампочка при необходимости может согреть ящик внутри очень быстро.
В будущем хочется передавать в запросе статус обогревателей не как 0/1/2, а как скважность ШИМа, вещественным числом от 0 до 1. Потому что сейчас вроде как 1 означает работу ШИМ, но какая там по факту частота непонятно. Я сам видел, как при формально включённом ШИМе лампочка не горела, видимо где-то при округлении число периодов включения сводится к нулю. Пока из этой ситуации я временно вышел дублированием расчётов в микросервисе по аналогии с микроконтроллером:
Ещё из неприятного поведения дашбордов: можно умножить текущее значение метрики на любое число, но нельзя сложить две метрики. Например, я хотел выводить расчётную температуру по формуле, которая заложена и в МК, но это приводит к ошибке построения графика.
(
2.0 * temperature{folderId="<folderId>", service="custom", address="250300A2799A4F28"}
+ temperature{folderId="<folderId>", service="custom", address="A50300A279D11E28"}
) / 3.0
Суммирование sum (метрика) by (address) ничего не дало. Увы, это не PromQL. Не критично, но придётся решать этот вопрос дописыванием кода микросервиса.
Про деньги
К сожалению, Яндекс выдаёт грант на достаточно короткий срок. Мой истекает уже 20 ноября. К текущему моменту из выданных 4000 ₽ я при всём желании смог потратить только 3 ₽:
Конечно, это никак не сравнится со стоимостью владения каким-нибудь слабым виртуальным сервером, где самому пришлось бы устанавливать/настраивать ОС, устанавливать docker, gitlab-runner для развёртывания, строить мониторинг из привычных Prometheus + Grafana, и всё равно он бы простаивал и прожигал деньги.
Выводы и про будущее
Я потрогал руками то, что давно хотел потрогать — новую концепцию облачных ресурсов. В границах свой применимости это очень классная штука. Также, как и сопутствующие облачные инфраструктурные компоненты.
Всё ещё не реализован обработчик Telegram-бота, но там нет никакого know-how. Ещё один serverless-контейнер, запускаемый не только по http-запросу, но и по временному триггеру. Может быть я использую опять Spring Native, а может быть попробую написать его на node.js.
В какой-то степени хочется дождаться морозов. Есть надежда, что при большом наборе данных получится построить модель скорости падения температуры в ящике в зависимости от уличной температуры. Представляю себе, что это может помочь скорректировать температуру внутри более точно, а также работать как резервный канал данных о температуре внутри, если по какой-нибудь причине выйдут из строя два внутренних термодатчика. Тут надо будет подключать математику.
Комментарии (10)
sandersru
11.11.2022 13:25+1https://www.graalvm.org/22.3/docs/getting-started/windows/
В самом низу ссылочка как под виндой с native-image жить. Но честно говоря проще все оттестировать под JVM, а дальше в WLS скомпилять под Линукс. На серверах у вас все равно не винда будет, по этому компиляция в Натив под виндой не имеет смысла. А кросс-компиляцию graal не поддерживает.
P.S. на конечном железе(если на него натягивается jdk11) вы прям на кваркусе можете писать и дергать тот же onewire https://github.com/quarkiverse/quarkus-jef то есть embedded to cloud
SimSonic Автор
11.11.2022 13:32https://www.graalvm.org/22.3/docs/getting-started/windows/
В самом низу ссылочка как под виндой с native-image жить. Но честно говоря проще все оттестировать под JVM, а дальше в WLS скомпилять под Линукс. На серверах у вас все равно не винда будет, по этому компиляция в Натив под виндой не имеет смысла. А кросс-компиляцию graal не поддерживает.
Использовать вручную native-image не хотелось, если честно. У него там куча флагов, пойди научи его, где нужна рефлексия, где нет. Фреймворки как раз пытаются спрятать за собой всю возню по подготовке входных данных для native-image. WSL, конечно, вариант, но для развёртывания всё равно будет нужен образ, значит нужен docker, чтобы его собрать. А в случае со Spring Native раз есть докер — больше ничего уже не нужно, даже WSL, native-image будет скачан и запущен билдпаками.
P.S. На конечном железе (если на него натягивается jdk11) вы прям на кваркусе можете писать и дергать тот же onewire https://github.com/quarkiverse/quarkus-jef то есть embedded to cloud
Очень интересная ссылка, спасибо, особенно в свете того, что у меня лежит малинка без дела :)
sandersru
11.11.2022 13:39>>> Использовать вручную native-image не хотелось
Я про то, что не надо заводить кваркус, надо завести Грааль. Кваркус то раз в день по CI в натив собирается.
>> Очень интересная ссылка
А вот пример по работе со всеми интерфейсами и разными датчиками.
Если осилите написать какую то документацию на это(мне пока лениво) то добавлю в quarkus repo
vassabi
11.11.2022 14:44+1я просто вставил в розетку тройник и две зарядки ????♂️
вы это .... там рядом поставьте еще пару иконок и огнетушитель (ну, он тоже будет как иконка)
VT100
12.11.2022 14:18Почти 30 лет назад делал такое для подогрева погреба в нежилом ещё доме. На терморезисторе, 140УД1 (LM702) в режиме компаратора, КУ202 с диодным мостом и ТЭНе. Работало отлично и было склонировано лет через 10 для балконной овощехранилки с лампой накаливания.
"Облака"… прикольно, но будет заброшено с высокой вероятностью. КМК.
ArkadiyShuvaev
12.11.2022 17:21А я бы тоже попробовал сделать так, как автор. Это же Пэт проджект, в котором можно получить навыки построения IoT инфраструктуры.
Ну разве это не IoT :)? Даже Java присутствует, которая ещё 20 лет назад как язык для каждого утюга разрабатывалась :)
safrtula
Керамический обогреватель (плита) + датчик/реле температуры (ставится на DIN рейку). Собирается за 10 мин. Все работает полностью автономно без всяких алертов в яндексы
SimSonic Автор
Абсолютно согласен с тем, что вариантов решений может быть очень много. Облачные технологии — это то, что как раз хотелось использовать, чтобы получить какой-то опыт. Алерты нужны именно мне и можно считать это частью исходной задачи — сделать с алертами, а не без них.