Представьте: яблоки уже в холодильной камере, дверь закрыта, термометр на стене показывает нужную температуру. А что происходит в середине штабеля? Воздух между ящиками успел остыть или туда ещё плохо доходит холод? Одного настенного датчика для такого вопроса мало.
Поставим вторую точку наблюдения прямо в ящик. Маленькая плата измерит температуру и влажность, передаст их по 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 % влажности стоит расследовать: причина может быть в условиях, воде на датчике или неисправности. Осмотрите головку и соединения; очистку и сушку выполняйте по инструкции производителя. Новый соседний модуль тоже нуждается в проверке.
Дальше проведите три простых опыта:
- Отключите SHT41 от маяка. Должен появиться sensor_error, а график сохранит последнее настоящее измерение. Нулевая температура на нём возникнуть не должна.
- Отключите сеть и включите снова. Посмотрите, какие точки сохранились, есть ли рост queue_dropped и осталось ли у старой точки её исходное время. Повторите опыт с перезапуском плат.
- Перенесите сборку в рабочее место. Проверьте её в окончательном корпусе, с выбранным питанием, закрытой дверью и заполненными штабелями. Сравните ближний и дальний ящики, затем выберите место шлюза.
Самый интересный опыт — поставить точки у прохода и глубже в штабеле и проследить за одной загрузкой. Сравните, когда изменились показания и где они установились позже. Если появляется разница, у вас уже есть конкретный вопрос к размещению ящиков и движению воздуха — и данные, с которыми можно его разбирать.
Потом можно добавить температурный щуп, накопление данных в самом маяке или вторую точку приёма возле весов. Для неподвижного ящика стоит сравнить BLE с проводным датчиком. Выбирайте следующий шаг по тому, чего не хватило в первом опыте.
Держите под рукой распиновку XIAO и документацию библиотеки SHT4x. А начать можно с одного ящика, одного номера на бирке и первой точки на графике.





