Своими рукамипрактическая инструкция

Логгер в ящике: собираем свой датчик для холодильной камеры

Редакция Фермозавра·16 сентября 2026 г.·17 мин чтения

Что происходит с воздухом в глубине штабеля, пока термометр на стене показывает норму? Соберём маяк с датчиком, выведем показания на график и проведём первые опыты с ящиками.

Логгер в ящике: собираем свой датчик для холодильной камеры

Представьте: яблоки уже в холодильной камере, дверь закрыта, термометр на стене показывает нужную температуру. А что происходит в середине штабеля? Воздух между ящиками успел остыть или туда ещё плохо доходит холод? Одного настенного датчика для такого вопроса мало.

Поставим вторую точку наблюдения прямо в ящик. Маленькая плата измерит температуру и влажность, передаст их по Bluetooth, а на графике появится история: как менялись условия после загрузки, открытия двери или перестановки ящиков. Мы измеряем воздух возле плодов; температуру мякоти проверяют отдельным щупом.

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

Первую сборку сделаем на USB-питании и с частыми измерениями. Так проще увидеть результат, разобраться в работе двух плат и поймать ошибку подключения. Когда появится понятный график, займёмся корпусом, размещением и батареей.

Маяк в ящике, приёмник над проходом

У системы два участника. Маяк — Seeed XIAO nRF52840 с датчиком SHT41 — стоит в выбранной точке ящика. Шлюз — постоянно включённый ESP32 — слушает Bluetooth и отправляет показания через Wi-Fi на сервер. История накапливается в InfluxDB, график рисует Grafana.

Для первого опыта положите обе платы рядом. В камере начните поиск места для шлюза над проходом между штабелями: там есть шанс принимать сигнал вдоль рядов. Затем проверьте дальние ящики при закрытой двери и рабочей загрузке. Металл, влажные плоды и расположение штабелей влияют на связь — окончательное место подскажет именно этот опыт.

Проверьте и второй участок пути: видит ли шлюз Wi-Fi из камеры. Для проводного выхода наружу понадобится Ethernet-плата или модуль с соответствующей прошивкой. Кабель проводят через подходящий ввод с сохранением герметичности. Если позже добавите второй приёмник, на сервере потребуется объединять одинаковые сообщения от обоих.

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

Собираем первый экземпляр на столе

  • Seeed XIAO nRF52840 обычной версии и USB-кабель с передачей данных.
  • Модуль SHT41, совместимый с питанием 3,3 В и уровнями I²C платы, провода и макетная плата.
  • Классический ESP32 DevKit с BLE и флеш-памятью от 4 МБ, USB-питание.
  • Для графиков — MQTT-брокер с TLS, Telegraf, InfluxDB 2 и Grafana.

Подключите SHT41 к XIAO: питание — к 3V3, землю — к GND, SDA — к D4, SCL — к D5. Сверьте маркировку модуля и наличие подтяжек I²C. Датчик отодвиньте от нагревающихся компонентов. Обе платы пока питаются через USB.

Для воспроизводимой сборки маяка выберите пакет Seeed nRF52 Boards 1.1.13 и плату Seeeduino:nrf52:xiaonRF52840. Нужны встроенная Bluefruit и Adafruit SHT4x Library 1.0.5 с зависимостями. У Seeed есть также пакет mbed-enabled — для нашего скетча нужен именно Seeed nRF52 Boards.

Шлюз собран с Arduino-ESP32 3.3.12 и PubSubClient 2.8. Выберите ESP32 Dev Module и разделы Huge APP: BLE вместе с TLS требуют больше места, чем даёт стандартный раздел программы. Эта сборка использует плату с 4 МБ флеш-памяти и загружается по USB. Если на плате уже был другой проект, сохраните его перед изменением разделов.

Датчику нужен воздух, плате — сухое место

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

Расположите окно так, чтобы на нём не задерживалась вода, а на проводе сделайте каплеотводящую петлю. Защиту конкретного модуля проверьте перед покупкой: мембрана и герметичный корпус решают разные задачи. Лак, герметик и их испарения должны оставаться вдали от чувствительной поверхности. Эти детали стоит отработать до установки в камеру.

Съёмная головка удобна при обслуживании. Начните с коротких проводов: длинная линия I²C может ухудшить обмен. Если хочется одновременно следить за воздухом и температурой в плоде, следующим расширением станет отдельный подходящий щуп — например, температурный канал на DS18B20. Для SHT85, HDC3022 и других датчиков понадобятся свои подключение и драйвер.

Сначала ускорим время

На столе ждать полчаса между точками неудобно. Поэтому маяк измеряет каждые 30 секунд, а шлюз замечает тишину после 90 секунд. Вы быстро увидите и нормальную работу, и исчезновение датчика.

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

После измерения маяк повторяет пакет в течение 1,5 секунды с настроенным интервалом 100 мс и случайной добавкой BLE. Несколько копий повышают шанс приёма. Шлюз узнаёт их по счётчику и оставляет одну точку.

Нагреватель SHT41 пока выключен. По документации Sensirion, вода на чувствительной поверхности мешает отслеживать изменения влажности. Если понадобится прогрев, подберите его режим по документации и измеряйте воздух после восстановления теплового равновесия. В библиотеке setHeater() выбирает режим, а getEvent() отправляет команду датчику.

Как узнать наш ящик среди телефонов и часов

В эфире много устройств, поэтому дадим каждому ящику постоянный номер. В примере это 17 и 18. В сообщение также входят идентификатор запуска маяка, счётчик, температура, влажность и признак успешного измерения. Шлюз знает ожидаемые номера заранее и может заметить даже ящик, который ещё ни разу не вышел на связь.

Общий файл Frame.h описывает язык двух плат. Сохраните его в обоих проектах. Поле manufacturer data занимает 22 байта, с оболочкой и flags — 27. Первые два байта — лабораторное значение 0xFFFF, затем метка формата. Температура и влажность передаются в сотых долях единицы. Заряд батареи пока остаётся неизвестным.

Если хочется быстрее собрать первый экземпляр, скопируйте файл целиком. Когда заработает график, вернитесь к функциям encode и decode: одна упаковывает числа в байты, другая читает их обратно. Это удобная точка, чтобы разобраться, что именно летит по Bluetooth.

#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>

// Laboratory manufacturer data: not an authenticated radio protocol.
namespace crate {
constexpr size_t FRAME_SIZE = 22;
struct Reading {
  uint16_t id;
  uint8_t boot[8];
  uint32_t sequence;
  int16_t temperature100;
  uint16_t humidity100;
  bool valid;
};
inline uint16_t get16(const uint8_t *p) { return uint16_t(p[0]) | (uint16_t(p[1]) << 8); }
inline uint32_t get32(const uint8_t *p) { return uint32_t(get16(p)) | (uint32_t(get16(p + 2)) << 16); }
inline void put16(uint8_t *p, uint16_t v) { p[0] = v; p[1] = v >> 8; }
inline void put32(uint8_t *p, uint32_t v) { put16(p, v); put16(p + 2, v >> 16); }
inline bool sane(const Reading &r) {
  bool any = false; for (uint8_t b : r.boot) any |= b != 0;
  if (!r.id || !r.sequence || !any) return false;
  if (!r.valid) return r.temperature100 == 0 && r.humidity100 == 0;
  return r.temperature100 >= -4000 && r.temperature100 <= 8500 && r.humidity100 <= 10000;
}
inline bool encode(const Reading &r, uint8_t *p) {
  if (!sane(r)) return false;
  p[0] = 0xff; p[1] = 0xff; p[2] = 0xc1;
  put16(p + 3, r.id); memcpy(p + 5, r.boot, 8); put32(p + 13, r.sequence);
  put16(p + 17, uint16_t(r.temperature100)); put16(p + 19, r.humidity100);
  p[21] = r.valid ? 1 : 0;
  return true;
}
inline bool decode(const uint8_t *p, size_t n, Reading &r) {
  if (n != FRAME_SIZE || p[0] != 0xff || p[1] != 0xff || p[2] != 0xc1 || p[21] > 1) return false;
  r.id = get16(p + 3); memcpy(r.boot, p + 5, 8); r.sequence = get32(p + 13);
  const uint16_t t = get16(p + 17);
  r.temperature100 = int16_t(t <= 32767 ? int32_t(t) : int32_t(t) - 65536);
  r.humidity100 = get16(p + 19); r.valid = p[21] == 1;
  return sane(r);
}
struct State {
  bool seen = false;
  Reading last{};
  uint64_t receivedMs = 0;
  int64_t receivedAt = 0;
  bool accept(const Reading &r, uint64_t nowMs, int64_t epoch) {
    if (seen && memcmp(last.boot, r.boot, 8) == 0 && r.sequence <= last.sequence) return false;
    last = r; seen = true; receivedMs = nowMs; receivedAt = epoch; return true;
  }
  const char *status(uint64_t nowMs, uint64_t silenceMs) const {
    if (!seen) return "no_data";
    if (nowMs < receivedMs || nowMs - receivedMs > silenceMs) return "stale";
    return last.valid ? "ok" : "sensor_error";
  }
};
}

Проверка диапазона −40…85 °C отсекает неподходящие для этого примера значения. При ошибке измерения передаётся отдельный признак; служебные нули шлюз распознаёт и в график температуры не записывает.

Прошиваем маяк и даём ящику номер

Создайте каталог crate-node. Положите туда Frame.h и следующий скетч crate-node.ino. Первый маяк уже получил номер 17. Для второго измените CRATE_ID на 18 и нанесите этот же номер на бирку: потом будет проще сопоставить график с конкретным ящиком.

#include <bluefruit.h>
#include <Adafruit_SHT4x.h>
#include <math.h>
#include "Frame.h"

constexpr uint16_t CRATE_ID = 17;
// Bench: 30 seconds. Production interval is a separate, measured choice.
constexpr uint32_t SAMPLE_MS = 30000;
constexpr uint32_t BURST_MS = 1500;
Adafruit_SHT4x sensor;
crate::Reading reading{};
bool ready = false;
uint32_t lastStart = 0;
bool first = true;

bool newBoot() {
  for (int tries = 0; tries < 20; ++tries) {
    if (sd_rand_application_vector_get(reading.boot, sizeof(reading.boot)) == NRF_SUCCESS) {
      for (uint8_t b : reading.boot) if (b) { reading.sequence = 0; return true; }
    }
    delay(10);
  }
  return false;
}
void setup() {
  Serial.begin(115200);
  if (!Bluefruit.begin()) { while (true) delay(1000); }
  Bluefruit.autoConnLed(false);
  Bluefruit.setTxPower(0);
  Bluefruit.Advertising.setType(BLE_GAP_ADV_TYPE_NONCONNECTABLE_NONSCANNABLE_UNDIRECTED);
  reading.id = CRATE_ID;
  while (!newBoot()) delay(1000);
}
void loop() {
  const uint32_t now = millis();
  if (!first && uint32_t(now - lastStart) < SAMPLE_MS) { delay(10); return; }
  first = false; lastStart = now;
  if (reading.sequence == UINT32_MAX && !newBoot()) return;
  ++reading.sequence;
  if (!ready) ready = sensor.begin();
  reading.valid = false; reading.temperature100 = 0; reading.humidity100 = 0;
  if (ready) {
    sensor.setPrecision(SHT4X_HIGH_PRECISION);
    sensor.setHeater(SHT4X_NO_HEATER);
    sensors_event_t h{}, t{};
    if (sensor.getEvent(&h, &t) && isfinite(t.temperature) && isfinite(h.relative_humidity) &&
        t.temperature >= -40 && t.temperature <= 85 && h.relative_humidity >= 0 && h.relative_humidity <= 100) {
      reading.temperature100 = int16_t(lroundf(t.temperature * 100));
      reading.humidity100 = uint16_t(lroundf(h.relative_humidity * 100));
      reading.valid = true;
    } else ready = false;
  }
  uint8_t data[crate::FRAME_SIZE];
  if (!crate::encode(reading, data)) return;
  Bluefruit.Advertising.clearData();
  if (!Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE) ||
      !Bluefruit.Advertising.addManufacturerData(data, sizeof(data))) return;
  Bluefruit.Advertising.setInterval(160, 160); // 100 ms, plus BLE random delay.
  Bluefruit.Advertising.setFastTimeout(1);
  if (!Bluefruit.Advertising.start(0)) return;
  delay(BURST_MS);
  Bluefruit.Advertising.stop();
  // This bench example does not claim a measured low-power sleep mode.
}

Каждый цикл начинается с попытки прочитать SHT41. При сбое маяк сообщает об ошибке, а в следующем цикле пробует инициализировать датчик заново. После включения создаётся новый идентификатор запуска; внутри запуска растёт счётчик сообщений. Именно эта пара помогает шлюзу отличать новое измерение от копий.

Шлюз: одна точка вместо пачки копий

Создайте второй каталог — crate-gateway. В него положите общий Frame.h, настройки gateway-config.h и скетч ниже. Если у вас пока один маяк, оставьте в EXPECTED только 17. С двумя номерами шлюз будет ждать оба устройства.

В настройках укажите Wi-Fi, MQTT-брокер, имя пользователя и пароль. Вставьте полный CA-сертификат брокера; имя MQTT_HOST должно совпадать с сертификатом. Пользователю crate-gw1 выдайте право записи только в темы этих ящиков, отдельному читателю — право чтения. Файл с паролями оставьте у себя.

#pragma once
const char *WIFI_SSID = "REPLACE_WIFI_SSID";
const char *WIFI_PASSWORD = "REPLACE_WIFI_PASSWORD";
const char *MQTT_HOST = "mqtt.example.com";
const char *MQTT_USER = "crate-gw1";
const char *MQTT_PASSWORD = "REPLACE_MQTT_PASSWORD";
const char MQTT_CA[] = R"PEM(-----BEGIN CERTIFICATE-----
REPLACE_WITH_YOUR_BROKER_CA_PEM
-----END CERTIFICATE-----
)PEM";
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
#include <BLEDevice.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
#include <esp_timer.h>
#include <atomic>
#include <time.h>
#include "Frame.h"
#include "gateway-config.h"

constexpr uint16_t EXPECTED[] = {17, 18};
constexpr size_t COUNT = sizeof(EXPECTED) / sizeof(EXPECTED[0]);
// Bench: node sends every 30 s; stale after 90 s. Change both for storage.
constexpr uint64_t SILENCE_MS = 90000;
struct Event { crate::Reading value; uint64_t ms; int64_t epoch; int rssi; };
QueueHandle_t events;
std::atomic<uint32_t> dropped{0};
crate::State states[COUNT];
bool pending[COUNT]{};
int rssiValues[COUNT]{};
WiFiClientSecure tls;
PubSubClient mqtt(tls);
BLEScan *scanner;
uint64_t nextConnect = 0, nextStatus = 0;
uint64_t monotonicMs() { return uint64_t(esp_timer_get_time()) / 1000; }
int indexOf(uint16_t id) {
  for (size_t i = 0; i < COUNT; ++i) if (EXPECTED[i] == id) return int(i);
  return -1;
}
class Sink : public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice d) override {
    if (!d.haveManufacturerData()) return;
    const auto bytes = d.getManufacturerData();
    Event e{};
    if (!crate::decode(reinterpret_cast<const uint8_t *>(bytes.c_str()), bytes.length(), e.value)) return;
    if (indexOf(e.value.id) < 0) return;
    e.ms = monotonicMs(); e.epoch = time(nullptr); e.rssi = d.getRSSI();
    if (e.epoch < 1700000000) e.epoch = 0;
    if (xQueueSend(events, &e, 0) != pdTRUE) ++dropped;
  }
};
void setup() {
  Serial.begin(115200);
  events = xQueueCreate(32, sizeof(Event));
  if (!events) { while (true) delay(1000); }
  WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true); WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  configTime(0, 0, "pool.ntp.org", "time.cloudflare.com");
  tls.setCACert(MQTT_CA); tls.setHandshakeTimeout(3); tls.setTimeout(3000);
  mqtt.setServer(MQTT_HOST, 8883); mqtt.setSocketTimeout(3); mqtt.setKeepAlive(30);
  if (!mqtt.setBufferSize(768)) { while (true) delay(1000); }
  BLEDevice::init("crate-gw1");
  scanner = BLEDevice::getScan();
  scanner->setAdvertisedDeviceCallbacks(new Sink(), true);
  scanner->setActiveScan(false); scanner->setInterval(100); scanner->setWindow(80);
  scanner->start(0, nullptr, false);
}
void loop() {
  Event e;
  while (xQueueReceive(events, &e, 0) == pdTRUE) {
    const int i = indexOf(e.value.id);
    if (i >= 0 && states[i].accept(e.value, e.ms, e.epoch)) {
      pending[i] = true; rssiValues[i] = e.rssi;
    }
  }
  uint64_t now = monotonicMs();
  if (WiFi.status() != WL_CONNECTED || time(nullptr) < 1700000000) { delay(10); return; }
  if (!mqtt.connected() && now >= nextConnect) {
    nextConnect = now + 10000;
    mqtt.connect("crate-gw1", MQTT_USER, MQTT_PASSWORD);
  }
  if (!mqtt.connected()) { delay(10); return; }
  mqtt.loop();
  now = monotonicMs();
  for (size_t i = 0; i < COUNT; ++i) {
    auto &s = states[i];
    if (!pending[i]) continue;
    if (!s.last.valid || !s.receivedAt || now < s.receivedMs || now - s.receivedMs > SILENCE_MS) {
      pending[i] = false; continue;
    }
    char boot[17];
    for (int k = 0; k < 8; ++k) snprintf(boot + k * 2, 3, "%02x", s.last.boot[k]);
    char topic[48], body[384];
    snprintf(topic, sizeof(topic), "crate/%u/observation", EXPECTED[i]);
    const int n = snprintf(body, sizeof(body),
      "{\"crate\":%u,\"boot\":\"%s\",\"seq\":%lu,\"temperature\":%.2f,\"humidity\":%.2f,\"battery\":null,\"rssi\":%d,\"observed_at\":%lld}",
      EXPECTED[i], boot, (unsigned long)s.last.sequence, s.last.temperature100 / 100.0,
      s.last.humidity100 / 100.0, rssiValues[i], (long long)s.receivedAt);
    if (n > 0 && size_t(n) < sizeof(body) && mqtt.publish(topic, body, false)) pending[i] = false;
  }
  if (now >= nextStatus) {
    nextStatus = now + 10000;
    for (size_t i = 0; i < COUNT; ++i) {
      char topic[48], body[256];
      snprintf(topic, sizeof(topic), "crate/%u/status", EXPECTED[i]);
      const auto &s = states[i];
      const char *state = s.status(now, SILENCE_MS);
      if (s.seen && !s.receivedAt && strcmp(state, "stale") != 0) state = "clock_unset";
      const int n = snprintf(body, sizeof(body),
        "{\"crate\":%u,\"state\":\"%s\",\"last_received_at\":%lld,\"reported_at\":%lld,\"queue_dropped\":%lu}",
        EXPECTED[i], state, (long long)s.receivedAt, (long long)time(nullptr), (unsigned long)dropped.load());
      if (n > 0 && size_t(n) < sizeof(body)) mqtt.publish(topic, body, false);
    }
  }
  delay(5);
}

Обработчик Bluetooth быстро проверяет пакет и кладёт его в очередь. Основной цикл забирает события и занимается сетью. При такой организации ожидание MQTT отделено от приёма сообщения. Очередь вмещает 32 события; поле queue_dropped покажет, если она переполнялась.

Важная деталь: копия пакета не делает показание свежее. Шлюз сохраняет время первого приёма и отбрасывает повторный или меньший счётчик текущего запуска. Состояния тоже разделены: no_data — ящик ещё не появился, ok — пришло успешное измерение, sensor_error — маяк ответил с ошибкой датчика, stale — давно тихо, clock_unset — часы шлюза ещё не настроены.

После короткого обрыва Wi-Fi шлюз может отправить последнее ещё свежее измерение с первоначальным observed_at. В памяти остаётся последнее событие каждого ящика; архив всех пропущенных точек здесь не копится. У отчёта о состоянии есть собственное время reported_at — его важно отличать от времени измерения. Нулевое last_received_at означает, что календарное время приёма пока неизвестно.

Первый график — и опыт с пропавшим ящиком

Теперь доведём показания до экрана. Если сервер ещё не собран, начните с руководств по MQTT-хабу и InfluxDB и Grafana. Для нашего формата используйте следующий crate-telegraf.conf.

Замените адреса, пароль MQTT-читателя, путь к CA и токен InfluxDB. В базе создайте организацию farm и bucket sensors, токену разрешите запись в этот bucket. Файл с секретами должен читать только пользователь службы. Для HTTPS нужен доверенный сертификат.

[agent]
  flush_interval = "10s"
  metric_batch_size = 100
  metric_buffer_limit = 1000
  omit_hostname = true

[[inputs.mqtt_consumer]]
  servers = ["ssl://mqtt.example.com:8883"]
  topics = ["crate/+/observation"]
  username = "crate-reader"
  password = "REPLACE_MQTT_PASSWORD"
  client_id = "crate-telegraf"
  qos = 0
  persistent_session = false
  tls_ca = "/etc/telegraf/mqtt-ca.crt"
  insecure_skip_verify = false
  data_format = "json_v2"
  [[inputs.mqtt_consumer.json_v2]]
    measurement_name = "crate_environment"
    timestamp_path = "observed_at"
    timestamp_format = "unix"
    [[inputs.mqtt_consumer.json_v2.tag]]
      path = "crate"
    [[inputs.mqtt_consumer.json_v2.field]]
      path = "temperature"
      type = "float"
    [[inputs.mqtt_consumer.json_v2.field]]
      path = "humidity"
      type = "float"
    [[inputs.mqtt_consumer.json_v2.field]]
      path = "rssi"
      type = "int"

[[outputs.influxdb_v2]]
  urls = ["https://influx.example.com"]
  token = "REPLACE_INFLUX_TOKEN"
  organization = "farm"
  bucket = "sensors"
  timeout = "10s"

Запустите telegraf --config crate-telegraf.conf --test --test-wait 60 и дождитесь сообщения маяка. Тест покажет разобранные данные. Затем запустите один обычный процесс или службу с этим конфигом — уже для записи в InfluxDB.

В Grafana добавьте панель и вставьте запрос температуры ящика 17 за последние сутки:

from(bucket: "sensors")
  |> range(start: -24h)
  |> filter(fn: (r) => r._measurement == "crate_environment" and r.crate == "17")
  |> filter(fn: (r) => r._field == "temperature")
  |> aggregateWindow(every: 1m, fn: mean, createEmpty: true)

Появилась первая точка? Положите датчик рядом с контрольным прибором и дождитесь устойчивых показаний. Затем перенесите оба в другое место и посмотрите, как меняется кривая. Для влажности замените temperature на humidity, для второго ящика — 17 на 18.

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

Следующий опыт полезнее ещё одного красивого графика: выключите маяк. Чтобы заметить тишину, добавьте отдельную панель возраста последнего успешного измерения:

from(bucket: "sensors")
  |> range(start: -7d)
  |> filter(fn: (r) => r._measurement == "crate_environment" and r.crate == "17")
  |> filter(fn: (r) => r._field == "temperature")
  |> last()
  |> map(fn: (r) => ({r with _value: float(v: int(v: now()) - int(v: r._time)) / 1000000000.0}))

Число секунд начнёт расти. Для нашего тридцатисекундного шага проверьте правило с порогом 90 секунд. Создайте его отдельно для каждого ожидаемого ящика и настройте No Data, а также ошибку запроса. Если ящик ни разу не прислал температуру или последняя точка старше семи суток, результат будет пустым.

Для поиска причины посмотрите crate/17/status: есть ли связь с маяком, исправно ли измерение? Затем включите маяк обратно и дождитесь новой точки. Температурные пороги и канал уведомлений настройте под свою задачу, после чего проверьте получение сообщения на телефоне. Исчезновение самого шлюза также должно быть заметно на сервере.

Когда захочется убрать USB-провод

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

Начните с измерения тока всей платы: во время ожидания, чтения датчика и передачи. По этим значениям выбирают режим сна, батарею и преобразователь. Обычный delay в нашем скетче ещё не даёт основания рассчитывать срок работы. Внешний выключатель питания, например TPL5110, потребует своей схемы и решения, какое состояние сохранять между включениями.

У XIAO есть зарядная схема: одноразовый элемент нельзя подключать как заряжаемый аккумулятор. Для одного AA на 1,5 В потребуется подходящее преобразование питания. Проверьте напряжения, импульсный ток и допустимые условия работы и зарядки выбранного элемента на холоде. Принцип расчёта энергобаланса разобран в статье об автономном питании ESP32; для XIAO схему подбирают отдельно.

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

Из первого графика — в полезный опыт

Перед установкой в камеру сравните показания с подходящим проверенным прибором. Постоянные 99,9 % влажности стоит расследовать: причина может быть в условиях, воде на датчике или неисправности. Осмотрите головку и соединения; очистку и сушку выполняйте по инструкции производителя. Новый соседний модуль тоже нуждается в проверке.

Дальше проведите три простых опыта:

  1. Отключите SHT41 от маяка. Должен появиться sensor_error, а график сохранит последнее настоящее измерение. Нулевая температура на нём возникнуть не должна.
  2. Отключите сеть и включите снова. Посмотрите, какие точки сохранились, есть ли рост queue_dropped и осталось ли у старой точки её исходное время. Повторите опыт с перезапуском плат.
  3. Перенесите сборку в рабочее место. Проверьте её в окончательном корпусе, с выбранным питанием, закрытой дверью и заполненными штабелями. Сравните ближний и дальний ящики, затем выберите место шлюза.

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

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

Держите под рукой распиновку XIAO и документацию библиотеки SHT4x. А начать можно с одного ящика, одного номера на бирке и первой точки на графике.

💬 Комментарии

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

Загрузка комментариев...

Похожие статьи