⚠️ 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 (похож).

  • Три DS18B20 в герметичных капсулах.

  • Два твердотельных реле на 220 В разной мощности (раз и два).

  • Патроны и лампочки накаливания, оставшиеся со времён до отделки дома.

Идея была в следующем. Конструирую из XPS плит размером 1×1×0.5 шт. небольшой ящик. Подгоняю под него каркас из ОСП, стягиваю уголками. Внутри обшиваю отражающей плёнкой. Внутри размещаю ESP8266, он по протоколу 1-wire опрашивает температуру с датчиков, через ШИМ управляет лампочкой накаливания, которая греет воздух. Плюс небольшой USB-вентилятор, который будет гонять внутри воздух.

Фото с этапа сборки
Вот она стоит и мешается посреди прохода.
Вот она стоит и мешается посреди прохода.
Обрезал у XPS пазы, стянул скотчем.
Обрезал у XPS пазы, стянул скотчем.
Начинаю собирать каркас по размеру.
Начинаю собирать каркас по размеру.
В него вставляю короб из XPS.
В него вставляю короб из XPS.
Проклеиваю все стыки алюминиевой лентой. Её немного не хватило, поэтому пара десятков сантиметров проклеены алюминизированным скотчем.
Проклеиваю все стыки алюминиевой лентой. Её немного не хватило, поэтому пара десятков сантиметров проклеены алюминизированным скотчем.
Купил в Леруа Мерлене за ~ 300 рублей 5 кв.м. отражающей плёнки толщиной 3 мм. Кстати, мой первый просчёт — плёнку невозможно прицепить степлером к XPS, скобы просто выпадают. Пока держится и так, но в будущем нужно иметь ввиду, что лучше проклеить.
Купил в Леруа Мерлене за ~ 300 рублей 5 кв.м. отражающей плёнки толщиной 3 мм. Кстати, мой первый просчёт — плёнку невозможно прицепить степлером к XPS, скобы просто выпадают. Пока держится и так, но в будущем нужно иметь ввиду, что лучше проклеить.

Базовая часть ящика собрана, примеряемся:

В будущем по ребру ОСП и со стороны ящика, и со стороны крышки наклеена полоска белого мягкого утеплителя. С ней закрывается достаточно герметично.
В будущем по ребру ОСП и со стороны ящика, и со стороны крышки наклеена полоска белого мягкого утеплителя. С ней закрывается достаточно герметично.

На этом моменте выяснилось, что расположенная картошка занимает всё доступное пространство. А ведь нужно ещё предусмотреть воздушное пространство для обогревателя, электроники, а также подумать про хранение морковки и свёклы!

Хорошо, что в остатках ещё лежит немного обрезков ОСП и пара листов XPS. Делаю объёмную крышку, а заодно небольшую паллету внизу для циркуляции воздуха и защиты от потенциально стекающей влаги.

Ещё пара фото сборки
Палета будет стоять на небольших ножках. В целом она хлипкая и прогибается под нагрузкой, но выдержала даже меня.
Палета будет стоять на небольших ножках. В целом она хлипкая и прогибается под нагрузкой, но выдержала даже меня.
Порожняком. XPS стен и крышки не на 100% плотно прилегает друг к другу, имеется небольшой зазор, но это не выглядит проблемой.
Порожняком. XPS стен и крышки не на 100% плотно прилегает друг к другу, имеется небольшой зазор, но это не выглядит проблемой.

Собрано, скручено, установлено на место, подрезано. Загружено.
Собрано, скручено, установлено на место, подрезано. Загружено.

Теперь внутри достаточно свободного места.

Начинаем кодить

Построив ящик, начинаю формировать требования к софту.

Во-первых, в наличии имеется три термодатчика, два с проводами покороче, один подлиннее. Два покороче планирую разместить внутри ящика, а длинный вывести на улицу. Напишем код, который будет заниматься сбором данных с датчиков:

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 все три термодатчика.

К задней стенке крышки ящика прикрутил кусок ГВЛ.

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

Последние фотографии сборки в этой статье
????????Пожалуйста, развидьте  это как-нибудь.
????????Пожалуйста, развидьте это как-нибудь.
Один из DS18B20 вывел на улицу.
Один из DS18B20 вывел на улицу.

Если честно, на данный момент всё это выглядит максимально уродливо. Вместо того, чтобы разделать провода питания Wemos и вентилятора и запитать от одного блока питания, я просто вставил в розетку тройник и две зарядки ????‍♂️. С другой стороны какая-то независимость по питанию. Правильнее было бы купить щиток побольше и пару источников питания на DIN. Также болтаются мотки кабелей, которые было лениво обрезать, плюс грела мысль "а вдруг я буду что-то внутри переигрывать по расположению". Вся остальная эстетика тоже страдает. Если этой зимой эксперимент окажется хотя бы частично успешным, к следующему году его будет ждать хороший апгрейд.

Ещё мне не нравится, что вентилятор включён постоянно, и сейчас есть желание подключить его через реле и тоже управлять им с микроконтроллера. Но у меня кончились реле, так что оставим и этот пункт для v2.

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

Люблю Aliexpress за это

Как я писал выше, мне самому не нравится идея использовать лампочки накаливания. Поэтому я отправился искать что-нибудь интересное на просторах. Много неожиданного мне попалось на пути к тому товару, который меня заинтересует.

Рука-лицо
Решил остановиться как раз на тех нагревательных элементах, что справа.
Решил остановиться как раз на тех нагревательных элементах, что справа.
Теперь их так и называю — керамический рептилический нагреватель. Спасибо китайскому брату за расширение словарного запаса.
Теперь их так и называю — керамический рептилический нагреватель. Спасибо китайскому брату за расширение словарного запаса.

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

Вот они доехали до меня. Очень маленькие. На всякий случай 4 шт. разных номиналов: 25 Вт, 50 Вт, 75 Вт и 100 Вт.
Вот они доехали до меня. Очень маленькие. На всякий случай 4 шт. разных номиналов: 25 Вт, 50 Вт, 75 Вт и 100 Вт.

На данный момент я уже попробовал заменить свою 75 Вт лампочку на ШИМ-канале на 25 Вт и на 75 Вт нагреватели. Температура внутри держится стабильно. Немного снизилась температура внизу, где поддон, но это может быть связано с тем, что я с каждым открытием ящика наталкиваю в него больше продуктов и становится меньше места для циркуляции воздуха.

Подбираемся к облакам

Мне давно хотелось познакомиться ближе с такими облачными технологиями, как Cloud Functions, Serverless и т.п. Я использовал S3 для хранения данных, неплохо знаком с Kubernetes, писал Helm-чарты, но текущая задача этого не требует. К тому же я знал, что при регистрации в Yandex Cloud выдаётся стартовый грант, которым можно оплачивать свои эксперименты. Поэтому я выбрал Яндекс, пошёл читать их документацию и строить архитектуру.

Архитектура получилась следующая:

  • Публично-доступный serverless-контейнер, который будет подниматься при поступлении данных от устройства, немного преобразовывать их и сохранять в пользовательские метрики в Мониторинге.

  • Дашборд для отображения пользовательских метрик с настроенными алертами.

  • Ещё один serverless-контейнер, запускаемый по временному триггеру, который будет на основании последних данных решать, отправить ли уведомление в Телеграмм.

Уведомления мне нужны вполне стандартные: слишком низкая/высокая температура (кроме уличной), данные не поступают.

Первый микросервис

Важным критерием для serverless-контейнеров является скорость запуска. Чем быстрее запускается контейнер и чем быстрее он обрабатывает входящий запрос, тем меньше я за это плачу. Учёт идёт условно в тактах CPU и поэтому вариант “насыпать больше ядер” прожорливый сервис не спасёт, хоть и позволит ему запуститься быстрее. Устройство в ящике присылает данные раз в 30 секунд, и мне хотелось бы, чтобы за это время контейнер успевал не только обработать запрос, но и преимущественно постоять в idle, причём на минимально возможных ресурсах CPU / ОЗУ. В консоли Яндекс.Cloud минимальные ресурсы можно выставить вот такие:

50 mCPU и 128 Мб памяти — минимум. Это намного меньше, чем минимум для виртуальной машины (2 ядра с гарантируемой долей 20% и 1 Гб ОЗУ).
50 mCPU и 128 Мб памяти — минимум. Это намного меньше, чем минимум для виртуальной машины (2 ядра с гарантируемой долей 20% и 1 Гб ОЗУ).

Наверное, я без проблем бы написал микросервис и на node.js, но поскольку я преимущественно Java-программист, я хотел бы остаться на этом стеке. Вдруг мои хотелки будут расти, а реализовывать новые фичи на знакомом языке и с использованием знакомых библиотек всё-таки быстрее. Варианты уложиться с JVM в минимальные ресурсы есть, я увидел два следующих:

  1. Не использовать никаких фреймворков типа Spring, писать всё на голой Java, возможно в контейнере ужать JVM, вырезав из неё ненужные модули.

  2. Использовать нативную компиляцию (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

Чувствительные данные, такие как пароль YA_REGISTRY_PASSWORD, никогда не должны быть зафиксированы в VCS, поэтому сохраним его в Settings >> CI/CD >> Variables.
Чувствительные данные, такие как пароль YA_REGISTRY_PASSWORD, никогда не должны быть зафиксированы в VCS, поэтому сохраним его в Settings >> CI/CD >> Variables.

Использование сервиса serversideup/docker-utility обусловлено вот этим Issue, а что за образ такой rcktsci/java-tooling:17-code-coverage — я рассказывал в одной из своих прошлых статей.

Единственная странность, которую я заметил, это иногда для обновления запускаемого образа нужно зайти в Serverless >> Редактор и, ничего не меняя, нажать Создать ревизию. Хотя образ для запуска указан именно по тегу, а не по хэшу. Возможно, это потому, что контейнер не успевает прибиваться каждый раз после обработки запроса от устройства. Опять же, для варианта "написал и запустил на всю зиму" — не проблема.

Настраиваем dashboard

С того момента, как в разделе Обзор метрик появились загруженные мной данные, дальше всё легко. Создаём новый дашборд, на нём добавляем две панели для данных термодатчиков и для состояний нагревательных элементов:

Установка и подключение устройства
Установка и подключение устройства

Я также установил мобильное приложение Yandex.Cloud и настроил канал для алертов в виде пушей. Пока мне его хватает. Пуши приходят и когда срабатывают алерты (есть уровни предупреждения и критический), и когда нет данных. Правда, добираться до дашборда приходится через очень много тапов, могли бы сделать возможность вынести его виджетом на экран. Или добавить дашборд в избранное для быстрого перехода. И нельзя выделением менять масштаб. В общем, в приложении тоже ещё есть над чем поработать :)

В какой-то момент через несколько недель после запуска прилетел предупредительный алерт, что температура низка. Хорошо, что у меня есть вторая плата Wemos, я подключил её и съэмулировал текущее состояние. Нашёл баг, исправил, сбегал за платой в ящике, залил новую прошивку, отнёс обратно. На второй плате ещё не всё распаяно, как нужно, так можно было бы просто отнести и поменять их местами.

Баг исправлен — произошло включение нагревателя.
Баг исправлен — произошло включение нагревателя.

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

В районе 16:00 немного потеплело и стало видно колебания между режимами "ШИМ" и "выкл". Значения усреднённые из-за включенного прореживания данных в Мониторинге.
В районе 16:00 немного потеплело и стало видно колебания между режимами "ШИМ" и "выкл". Значения усреднённые из-за включенного прореживания данных в Мониторинге.

В будущем хочется передавать в запросе статус обогревателей не как 0/1/2, а как скважность ШИМа, вещественным числом от 0 до 1. Потому что сейчас вроде как 1 означает работу ШИМ, но какая там по факту частота непонятно. Я сам видел, как при формально включённом ШИМе лампочка не горела, видимо где-то при округлении число периодов включения сводится к нулю. Пока из этой ситуации я временно вышел дублированием расчётов в микросервисе по аналогии с микроконтроллером:

См. на зелёную линию: за время наблюдений менее 3 суток скважность не превышает 20%.
Розовая прямая — фейк.
См. на зелёную линию: за время наблюдений менее 3 суток скважность не превышает 20%. Розовая прямая — фейк.

Ещё из неприятного поведения дашбордов: можно умножить текущее значение метрики на любое число, но нельзя сложить две метрики. Например, я хотел выводить расчётную температуру по формуле, которая заложена и в МК, но это приводит к ошибке построения графика.

(
  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)


  1. safrtula
    11.11.2022 12:50

    Керамический обогреватель (плита) + датчик/реле температуры (ставится на DIN рейку). Собирается за 10 мин. Все работает полностью автономно без всяких алертов в яндексы


    1. SimSonic Автор
      11.11.2022 12:54

      Абсолютно согласен с тем, что вариантов решений может быть очень много. Облачные технологии — это то, что как раз хотелось использовать, чтобы получить какой-то опыт. Алерты нужны именно мне и можно считать это частью исходной задачи — сделать с алертами, а не без них.


  1. sandersru
    11.11.2022 13:25
    +1

    https://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


    1. SimSonic Автор
      11.11.2022 13:32

      https://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

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


      1. sandersru
        11.11.2022 13:39

        >>> Использовать вручную native-image не хотелось

        Я про то, что не надо заводить кваркус, надо завести Грааль. Кваркус то раз в день по CI в натив собирается.

        >> Очень интересная ссылка

        А вот пример по работе со всеми интерфейсами и разными датчиками.

        Если осилите написать какую то документацию на это(мне пока лениво) то добавлю в quarkus repo


  1. sandersru
    11.11.2022 13:39

    Del


  1. vassabi
    11.11.2022 14:44
    +1

     я просто вставил в розетку тройник и две зарядки ????‍♂️

    вы это .... там рядом поставьте еще пару иконок и огнетушитель (ну, он тоже будет как иконка)


  1. t3hk0d3
    11.11.2022 15:22
    +1

    Овер-инжиниринг в софте

    Андер-инжиниринг в железе

    Вот так и живем :D


  1. VT100
    12.11.2022 14:18

    Почти 30 лет назад делал такое для подогрева погреба в нежилом ещё доме. На терморезисторе, 140УД1 (LM702) в режиме компаратора, КУ202 с диодным мостом и ТЭНе. Работало отлично и было склонировано лет через 10 для балконной овощехранилки с лампой накаливания.
    "Облака"… прикольно, но будет заброшено с высокой вероятностью. КМК.


  1. ArkadiyShuvaev
    12.11.2022 17:21

    А я бы тоже попробовал сделать так, как автор. Это же Пэт проджект, в котором можно получить навыки построения IoT инфраструктуры.

    Ну разве это не IoT :)? Даже Java присутствует, которая ещё 20 лет назад как язык для каждого утюга разрабатывалась :)