Initial project: XIAO ESP32S3 + SX1262 LoRa sensor link hytte–hjem

- cabin_node: subscribes to local MQTT (ESPHome sensors), sends
  temperature, battery voltage/SOC and switch states over LoRa SF12
- cabin_gw: receives LoRa packets, publishes JSON to home MQTT broker
- Bidirectional: gateway forwards ON/OFF commands from home HA to node
- cabin_node always in RX mode (12V powered) — commands arrive instantly
- ESPHome config for ESP32 on teknisk rom: Victron MPPT BLE + JBD BMS
  BLE + DS18B20 temperatures + GPIO switches for varme/VVB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 23:11:35 +01:00
commit a544594769
11 changed files with 756 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.pio/
include/secrets.h
+86
View File
@@ -0,0 +1,86 @@
# hytte_link
LoRa-basert sensorlink mellom hytte og hjemme. Sender temperatur (inne/ute),
batterispenning og SOC fra 12V-anlegg på hytta til MQTT-broker hjemme (alu).
## Arkitektur
```
Hytte:
DS18B20 (inne/ute) ──┐
Victron MPPT (BLE) ──┤
JBD BMS (BLE) ────── ESP32 (ESPHome, cabin_tech) ──WiFi──► MQTT (lokal HA)
XIAO ESP32S3 + Wio-SX1262 ◄──MQTT────┘
(cabin_node)
│ LoRa SF12 868 MHz
▼ 1520 km
Hjemme: XIAO ESP32S3 + Wio-SX1262
(cabin_gw)
│ MQTT publish
mosquitto (alu, 192.168.86.31)
Home Assistant hjemme
```
## Bygging (PlatformIO)
```bash
pio run -e cabin_node # hytte-node
pio run -e cabin_gw # hjemme-gateway
pio run -e cabin_node -t upload
pio run -e cabin_gw -t upload
```
## ESPHome (cabin_tech)
```bash
esphome run esphome/cabin_tech.yaml
```
## Secrets
Kopier `include/secrets.example.h``include/secrets.h` og fyll inn.
Kopier `esphome/secrets.yaml.example``esphome/secrets.yaml` og fyll inn.
## LoRa-parametere
| Parameter | Verdi |
|-------------|--------|
| Frekvens | 868 MHz |
| SF | 12 |
| BW | 125 kHz |
| CR | 4/5 |
| Sync word | 0xAB |
| Airtime | ~1.3 s |
## Pakkeformat (CabinPacket, 12 bytes)
| Felt | Type | Enhet |
|--------------|---------|-----------|
| magic | uint8 | 0xCA |
| seq | uint8 | rullende |
| temp_in_x10 | int16 | °C × 10 |
| temp_out_x10 | int16 | °C × 10 |
| batt_mv | uint16 | mV |
| batt_pct | uint8 | 0100 |
| sig | uint32 | HMAC-SHA256 (trunkert) |
## Pinner (Wio-SX1262 på XIAO ESP32S3)
Verifiser mot Seeed-skjema før flashing:
- NSS: GPIO44 (D7)
- RST: GPIO43 (D6)
- DIO1: GPIO2 (D1)
- BUSY: GPIO1 (D0)
## MQTT-topics
| Topic | Retning |
|------------------------|----------------------|
| hytte/sensor/temp_inne | ESPHome → cabin_node |
| hytte/sensor/temp_ute | ESPHome → cabin_node |
| hytte/sensor/batt_mv | ESPHome → cabin_node |
| hytte/sensor/batt_pct | ESPHome → cabin_node |
| hytte/lora/status | cabin_gw → alu |
+169
View File
@@ -0,0 +1,169 @@
substitutions:
device_name: cabin-tech
friendly_name: "Hytte teknisk"
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
esp32:
board: esp32dev
framework:
type: arduino
logger:
api:
encryption:
key: !secret api_key
ota:
- platform: esphome
password: !secret ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
mqtt:
broker: !secret mqtt_broker
port: 1883
username: !secret mqtt_username
password: !secret mqtt_password
topic_prefix: hytte
# ── BLE ──────────────────────────────────────────────────────────────────────
esp32_ble_tracker:
scan_parameters:
active: false # passive scanning — don't connect, just listen
# Victron MPPT (SmartSolar) — reads BLE advertisements
# Get the encryption key from Victron Connect app:
# Settings → Product info → Encryption key
victron_ble:
- id: victron_mppt
mac_address: !secret victron_mac
bindkey: !secret victron_bindkey
sensor:
- platform: victron_ble
victron_ble_id: victron_mppt
battery_voltage:
name: "Batteri spenning"
id: batt_voltage
on_value:
then:
- mqtt.publish:
topic: hytte/sensor/batt_mv
payload: !lambda 'return to_string((int)(x * 1000));'
battery_current:
name: "Batteri strøm"
pv_power:
name: "Solcelle effekt"
load_power:
name: "Last effekt"
charging_mode:
name: "Lademodus"
# JBD/JBD BMS — active BLE client connection
# External component: https://github.com/syssi/esphome-jbd-bms
external_components:
- source: github://syssi/esphome-jbd-bms@main
refresh: 0d
ble_client:
- mac_address: !secret jbd_mac
id: jbd_ble_client
jbd_bms:
- ble_client_id: jbd_ble_client
id: jbd
update_interval: 60s
sensor:
- platform: jbd_bms
jbd_bms_id: jbd
state_of_charge:
name: "Batteri SOC"
id: batt_soc
on_value:
then:
- mqtt.publish:
topic: hytte/sensor/batt_pct
payload: !lambda 'return to_string((int)x);'
total_voltage:
name: "Total spenning"
current:
name: "Strøm"
power:
name: "Effekt"
temperature_1:
name: "BMS temperatur"
# ── Temperatur ────────────────────────────────────────────────────────────────
# DS18B20 one-wire — juster pin etter installasjon
one_wire:
- platform: gpio
pin: GPIO4
sensor:
- platform: dallas_temp
name: "Inne temperatur"
id: temp_in
address: !secret ds18b20_indoor_addr
update_interval: 60s
on_value:
then:
- mqtt.publish:
topic: hytte/sensor/temp_inne
payload: !lambda 'return to_string(x);'
- platform: dallas_temp
name: "Ute temperatur"
id: temp_out
address: !secret ds18b20_outdoor_addr
update_interval: 60s
on_value:
then:
- mqtt.publish:
topic: hytte/sensor/temp_ute
payload: !lambda 'return to_string(x);'
# ── Bryterkanaler (styres via LoRa-kommandoer) ───────────────────────────────
switch:
- platform: gpio
name: "Varme"
id: switch_varme
pin: GPIO16 # juster pin etter installasjon
restore_mode: RESTORE_DEFAULT_OFF
- platform: gpio
name: "Varmtvannsbereder"
id: switch_vvb
pin: GPIO17 # juster pin etter installasjon
restore_mode: RESTORE_DEFAULT_OFF
# Lytter på kommandoer fra cabin_node (publisert til lokal MQTT)
# HA hjemme → MQTT på alu → LoRa → cabin_node → lokal MQTT → her
mqtt:
on_message:
- topic: hytte/cmd/varme
then:
- if:
condition:
lambda: 'return x == "ON";'
then:
- switch.turn_on: switch_varme
else:
- switch.turn_off: switch_varme
- topic: hytte/cmd/vvb
then:
- if:
condition:
lambda: 'return x == "ON";'
then:
- switch.turn_on: switch_vvb
else:
- switch.turn_off: switch_vvb
+15
View File
@@ -0,0 +1,15 @@
wifi_ssid: "din-wifi-ssid"
wifi_password: "din-wifi-passord"
mqtt_broker: "192.168.x.x" # lokal IP til HA/Mosquitto på hytta
mqtt_username: "mqtt"
mqtt_password: "ditt-mqtt-passord"
api_key: "" # generer i ESPHome UI
ota_password: "ota-passord"
victron_mac: "AA:BB:CC:DD:EE:FF" # finn i Victron Connect → enhet
victron_bindkey: "aabbccddeeff..." # Settings → Product info → Encryption key
jbd_mac: "AA:BB:CC:DD:EE:FF" # finn med BLE-scanner (nRF Connect e.l.)
ds18b20_indoor_addr: "0xAABBCCDDEEFF0028" # finn med dallas_temp i ESPHome
ds18b20_outdoor_addr: "0xAABBCCDDEEFF0128"
+62
View File
@@ -0,0 +1,62 @@
#pragma once
// LoRa radio parameters — must match exactly on node and gateway
#define LORA_FREQ 868.0 // MHz
#define LORA_SF 12
#define LORA_BW 125.0 // kHz
#define LORA_CR 5
#define LORA_SYNC_WORD 0xAB // distinct from ice-track (0x34)
#define LORA_POWER 22 // dBm (SX1262 max)
// Wio-SX1262 pins for XIAO ESP32S3
// Verify against https://wiki.seeedstudio.com/wio_sx1262_with_xiao_esp32s3
#define LORA_NSS 44 // D7
#define LORA_RST 43 // D6
#define LORA_DIO1 2 // D1
#define LORA_BUSY 1 // D0
// Send interval (node) — 30 min fills one packet; adjust 14x/hour
#define SEND_INTERVAL_MS (30UL * 60UL * 1000UL)
// MQTT broker (home, shared with mitt_lora)
#define MQTT_HOST "192.168.86.31"
#define MQTT_PORT 1883
#define MQTT_USER "mqtt"
#ifndef MQTT_PASS
#define MQTT_PASS ""
#endif
// MQTT topics (cabin ESPHome → node subscribes to these)
#define TOPIC_TEMP_IN "hytte/sensor/temp_inne"
#define TOPIC_TEMP_OUT "hytte/sensor/temp_ute"
#define TOPIC_BATT_MV "hytte/sensor/batt_mv"
#define TOPIC_BATT_PCT "hytte/sensor/batt_pct"
// MQTT topic gateway publishes received packets to (home broker)
#define TOPIC_GW_STATUS "hytte/lora/status"
// Command topics (home HA publishes → cabin_gw → LoRa → cabin_node → ESPHome)
// Payload: "ON" or "OFF"
#define TOPIC_CMD_VARME "hytte/cmd/varme"
#define TOPIC_CMD_VVB "hytte/cmd/vvb"
// ESPHome publishes switch states here (cabin_node subscribes)
#define TOPIC_STATE_VARME "hytte/switch/varme/state"
#define TOPIC_STATE_VVB "hytte/switch/vvb/state"
// WiFi credentials
#ifndef WIFI_SSID
#define WIFI_SSID "your-ssid"
#endif
#ifndef WIFI_PASS
#define WIFI_PASS "your-pass"
#endif
// HMAC key — 32+ chars, keep secret
#ifndef HMAC_KEY
#define HMAC_KEY "hytte-link-hmac-key-replace-me"
#endif
#if __has_include("secrets.h")
#include "secrets.h"
#endif
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include "mbedtls/md.h"
#include "config.h"
// Returns a 4-byte truncated HMAC-SHA256 over `data` of `len` bytes.
inline uint32_t hmac_sig(const uint8_t *data, size_t len)
{
static const char *key = HMAC_KEY;
uint8_t out[32];
mbedtls_md_hmac(
mbedtls_md_info_from_type(MBEDTLS_MD_SHA256),
reinterpret_cast<const uint8_t *>(key), strlen(key),
data, len, out);
uint32_t sig;
memcpy(&sig, out, 4);
return sig;
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <stdint.h>
#define CABIN_PACKET_MAGIC 0xCA
#define CABIN_CMD_MAGIC 0xCB
// Command IDs
#define CMD_VARME 0x01
#define CMD_VVB 0x02 // varmtvannsbereder
// Wire format: 13 bytes total
// At SF12 BW125: ~1.3s airtime
// flags bits: 0=varme, 1=vvb (1=on, 0=off)
#define CABIN_FLAG_VARME (1 << 0)
#define CABIN_FLAG_VVB (1 << 1)
struct __attribute__((packed)) CabinPacket {
uint8_t magic; // = CABIN_PACKET_MAGIC
uint8_t seq; // rolling counter
int16_t temp_in_x10; // indoor temp * 10 (213 = 21.3 °C), INT16_MIN = no data
int16_t temp_out_x10; // outdoor temp * 10, INT16_MIN = no data
uint16_t batt_mv; // battery voltage mV (12V system: ~1000014400)
uint8_t batt_pct; // SOC 0100, 0xFF = no data
uint8_t flags; // switch states: bit0=varme, bit1=vvb
uint32_t sig; // truncated HMAC-SHA256
};
// Command packet: home → gateway → node → ESPHome switch
// 8 bytes total
struct __attribute__((packed)) CabinCmdPacket {
uint8_t magic; // = CABIN_CMD_MAGIC
uint8_t seq;
uint8_t cmd_id; // CMD_VARME, CMD_VVB, ...
uint8_t value; // 0 = off, 1 = on
uint32_t sig;
};
+7
View File
@@ -0,0 +1,7 @@
#pragma once
// Copy to secrets.h and fill in before building
#define WIFI_SSID "your-wifi-ssid"
#define WIFI_PASS "your-wifi-password"
#define MQTT_PASS "your-mqtt-password"
#define HMAC_KEY "your-secret-hmac-key-at-least-32-chars"
+26
View File
@@ -0,0 +1,26 @@
[platformio]
default_envs = cabin_node
[env:cabin_node]
platform = espressif32
board = seeed_xiao_esp32s3
framework = arduino
build_src_filter = +<cabin_node/> -<cabin_gw/>
build_flags = -I include
monitor_speed = 115200
lib_deps =
jgromes/RadioLib@^7.1.2
knolleary/PubSubClient@^2.8
bblanchon/ArduinoJson@^7.0.4
[env:cabin_gw]
platform = espressif32
board = seeed_xiao_esp32s3
framework = arduino
build_src_filter = +<cabin_gw/> -<cabin_node/>
build_flags = -I include
monitor_speed = 115200
lib_deps =
jgromes/RadioLib@^7.1.2
knolleary/PubSubClient@^2.8
bblanchon/ArduinoJson@^7.0.4
+158
View File
@@ -0,0 +1,158 @@
#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <RadioLib.h>
#include <ArduinoJson.h>
#include "config.h"
#include "packets.h"
#include "crypto.h"
WiFiClient wifi_client;
PubSubClient mqtt(wifi_client);
SX1262 radio = new Module(LORA_NSS, LORA_DIO1, LORA_RST, LORA_BUSY);
// Pending command — last-write-wins; sent on next data packet received from node
struct PendingCmd {
bool valid = false;
uint8_t cmd_id = 0;
uint8_t value = 0;
};
static PendingCmd pending;
static uint8_t cmd_seq = 0;
static void on_mqtt(const char *topic, byte *payload, unsigned int len)
{
char buf[8];
if (len >= sizeof(buf)) return;
memcpy(buf, payload, len);
buf[len] = '\0';
bool on = (strcasecmp(buf, "ON") == 0 || strcmp(buf, "1") == 0);
if (strcmp(topic, TOPIC_CMD_VARME) == 0) { pending = {true, CMD_VARME, (uint8_t)(on ? 1 : 0)}; }
else if (strcmp(topic, TOPIC_CMD_VVB) == 0) { pending = {true, CMD_VVB, (uint8_t)(on ? 1 : 0)}; }
Serial.printf("[cmd] queued cmd_id=%u value=%u\n", pending.cmd_id, pending.value);
}
static void mqtt_connect()
{
while (!mqtt.connected()) {
Serial.println("[mqtt] connecting...");
if (mqtt.connect("hytte-lora-gw", MQTT_USER, MQTT_PASS)) {
mqtt.subscribe(TOPIC_CMD_VARME);
mqtt.subscribe(TOPIC_CMD_VVB);
Serial.println("[mqtt] connected");
} else {
Serial.printf("[mqtt] failed rc=%d, retry in 5s\n", mqtt.state());
delay(5000);
}
}
}
static void send_pending_cmd()
{
if (!pending.valid) return;
CabinCmdPacket pkt{};
pkt.magic = CABIN_CMD_MAGIC;
pkt.seq = cmd_seq++;
pkt.cmd_id = pending.cmd_id;
pkt.value = pending.value;
pkt.sig = hmac_sig(reinterpret_cast<const uint8_t *>(&pkt), sizeof(pkt) - sizeof(pkt.sig));
int state = radio.transmit(reinterpret_cast<uint8_t *>(&pkt), sizeof(pkt));
if (state == RADIOLIB_ERR_NONE) {
Serial.printf("[lora] cmd sent cmd_id=%u value=%u\n", pkt.cmd_id, pkt.value);
pending.valid = false;
} else {
Serial.printf("[lora] cmd tx error %d\n", state);
}
// back to receive mode
radio.startReceive();
}
static void handle_data_packet(uint8_t *buf, size_t len)
{
if (len != sizeof(CabinPacket)) return;
CabinPacket pkt;
memcpy(&pkt, buf, sizeof(pkt));
if (pkt.magic != CABIN_PACKET_MAGIC) return;
uint32_t expected = hmac_sig(reinterpret_cast<const uint8_t *>(&pkt), sizeof(pkt) - sizeof(pkt.sig));
if (pkt.sig != expected) {
Serial.println("[lora] bad signature");
return;
}
float rssi = radio.getRSSI();
float snr = radio.getSNR();
Serial.printf("[lora] rx seq=%u in=%.1f out=%.1f batt=%umV %u%% flags=0x%02X rssi=%.0f snr=%.1f\n",
pkt.seq,
pkt.temp_in_x10 != INT16_MIN ? pkt.temp_in_x10 / 10.0f : NAN,
pkt.temp_out_x10 != INT16_MIN ? pkt.temp_out_x10 / 10.0f : NAN,
pkt.batt_mv, pkt.batt_pct, pkt.flags, rssi, snr);
JsonDocument doc;
doc["seq"] = pkt.seq;
doc["rssi"] = (int)rssi;
doc["snr"] = snr;
doc["batt_mv"] = pkt.batt_mv;
doc["batt_pct"] = pkt.batt_pct != 0xFF ? (int)pkt.batt_pct : -1;
doc["varme"] = (pkt.flags & CABIN_FLAG_VARME) ? true : false;
doc["vvb"] = (pkt.flags & CABIN_FLAG_VVB) ? true : false;
if (pkt.temp_in_x10 != INT16_MIN) doc["temp_in"] = pkt.temp_in_x10 / 10.0f;
if (pkt.temp_out_x10 != INT16_MIN) doc["temp_out"] = pkt.temp_out_x10 / 10.0f;
char json[256];
serializeJson(doc, json);
mqtt.publish(TOPIC_GW_STATUS, json, true);
// Send any queued command while node is listening
send_pending_cmd();
}
void setup()
{
Serial.begin(115200);
delay(100);
Serial.println("[gw] boot");
if (radio.begin(LORA_FREQ) != RADIOLIB_ERR_NONE) {
Serial.println("[lora] init failed — halting");
while (true) delay(1000);
}
radio.setSpreadingFactor(LORA_SF);
radio.setBandwidth(LORA_BW);
radio.setCodingRate(LORA_CR);
radio.setSyncWord(LORA_SYNC_WORD);
radio.startReceive();
Serial.println("[lora] listening sf=12 bw=125 freq=868");
WiFi.begin(WIFI_SSID, WIFI_PASS);
Serial.print("[wifi] connecting");
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.printf("\n[wifi] connected ip=%s\n", WiFi.localIP().toString().c_str());
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(on_mqtt);
mqtt_connect();
Serial.println("[gw] setup done");
}
void loop()
{
if (!mqtt.connected()) mqtt_connect();
mqtt.loop();
if (radio.available()) {
uint8_t buf[sizeof(CabinPacket)];
radio.readData(buf, sizeof(buf));
handle_data_packet(buf, radio.getPacketLength());
}
}
+175
View File
@@ -0,0 +1,175 @@
#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <RadioLib.h>
#include "config.h"
#include "packets.h"
#include "crypto.h"
// Cached sensor values from local MQTT (ESPHome/HA)
static int16_t g_temp_in_x10 = INT16_MIN;
static int16_t g_temp_out_x10 = INT16_MIN;
static uint16_t g_batt_mv = 0;
static uint8_t g_batt_pct = 0xFF;
static uint8_t g_flags = 0; // switch states
static uint8_t g_seq = 0;
static uint32_t g_last_send_ms = 0;
WiFiClient wifi_client;
PubSubClient mqtt(wifi_client);
SX1262 radio = new Module(LORA_NSS, LORA_DIO1, LORA_RST, LORA_BUSY);
static void on_mqtt(const char *topic, byte *payload, unsigned int len)
{
char buf[32];
if (len >= sizeof(buf)) return;
memcpy(buf, payload, len);
buf[len] = '\0';
// Sensor values
if (strcmp(topic, TOPIC_TEMP_IN) == 0) {
g_temp_in_x10 = (int16_t)(atof(buf) * 10.0f);
} else if (strcmp(topic, TOPIC_TEMP_OUT) == 0) {
g_temp_out_x10 = (int16_t)(atof(buf) * 10.0f);
} else if (strcmp(topic, TOPIC_BATT_MV) == 0) {
g_batt_mv = (uint16_t)atof(buf);
} else if (strcmp(topic, TOPIC_BATT_PCT) == 0) {
g_batt_pct = (uint8_t)atof(buf);
// Switch states from ESPHome
} else if (strcmp(topic, TOPIC_STATE_VARME) == 0) {
if (strcasecmp(buf, "ON") == 0) g_flags |= CABIN_FLAG_VARME;
else g_flags &= ~CABIN_FLAG_VARME;
} else if (strcmp(topic, TOPIC_STATE_VVB) == 0) {
if (strcasecmp(buf, "ON") == 0) g_flags |= CABIN_FLAG_VVB;
else g_flags &= ~CABIN_FLAG_VVB;
}
}
static void mqtt_connect()
{
while (!mqtt.connected()) {
Serial.println("[mqtt] connecting...");
if (mqtt.connect("hytte-lora-node", MQTT_USER, MQTT_PASS)) {
mqtt.subscribe(TOPIC_TEMP_IN);
mqtt.subscribe(TOPIC_TEMP_OUT);
mqtt.subscribe(TOPIC_BATT_MV);
mqtt.subscribe(TOPIC_BATT_PCT);
mqtt.subscribe(TOPIC_STATE_VARME);
mqtt.subscribe(TOPIC_STATE_VVB);
Serial.println("[mqtt] connected");
} else {
Serial.printf("[mqtt] failed rc=%d, retry in 5s\n", mqtt.state());
delay(5000);
}
}
}
static void handle_cmd(const uint8_t *buf, size_t len)
{
if (len != sizeof(CabinCmdPacket)) return;
CabinCmdPacket pkt;
memcpy(&pkt, buf, sizeof(pkt));
if (pkt.magic != CABIN_CMD_MAGIC) return;
uint32_t expected = hmac_sig(reinterpret_cast<const uint8_t *>(&pkt), sizeof(pkt) - sizeof(pkt.sig));
if (pkt.sig != expected) {
Serial.println("[cmd] bad signature");
return;
}
const char *value = pkt.value ? "ON" : "OFF";
const char *topic = nullptr;
if (pkt.cmd_id == CMD_VARME) topic = TOPIC_CMD_VARME;
else if (pkt.cmd_id == CMD_VVB) topic = TOPIC_CMD_VVB;
if (topic) {
mqtt.publish(topic, value, true);
Serial.printf("[cmd] rx cmd_id=%u → %s = %s\n", pkt.cmd_id, topic, value);
} else {
Serial.printf("[cmd] unknown cmd_id=%u\n", pkt.cmd_id);
}
}
static void send_lora()
{
if (g_temp_in_x10 == INT16_MIN) {
Serial.println("[lora] skip — no sensor data yet");
return;
}
CabinPacket pkt{};
pkt.magic = CABIN_PACKET_MAGIC;
pkt.seq = g_seq++;
pkt.temp_in_x10 = g_temp_in_x10;
pkt.temp_out_x10 = g_temp_out_x10;
pkt.batt_mv = g_batt_mv;
pkt.batt_pct = g_batt_pct;
pkt.flags = g_flags;
pkt.sig = hmac_sig(reinterpret_cast<const uint8_t *>(&pkt), sizeof(pkt) - sizeof(pkt.sig));
// Pause RX, transmit, resume RX
radio.standby();
int state = radio.transmit(reinterpret_cast<uint8_t *>(&pkt), sizeof(pkt));
radio.startReceive();
if (state == RADIOLIB_ERR_NONE) {
Serial.printf("[lora] sent seq=%u in=%.1f out=%.1f batt=%umV %u%% flags=0x%02X\n",
pkt.seq, pkt.temp_in_x10 / 10.0f, pkt.temp_out_x10 / 10.0f,
pkt.batt_mv, pkt.batt_pct, pkt.flags);
} else {
Serial.printf("[lora] tx error %d\n", state);
}
}
void setup()
{
Serial.begin(115200);
delay(100);
Serial.println("[node] boot");
if (radio.begin(LORA_FREQ) != RADIOLIB_ERR_NONE) {
Serial.println("[lora] init failed — halting");
while (true) delay(1000);
}
radio.setSpreadingFactor(LORA_SF);
radio.setBandwidth(LORA_BW);
radio.setCodingRate(LORA_CR);
radio.setSyncWord(LORA_SYNC_WORD);
radio.setOutputPower(LORA_POWER);
radio.startReceive(); // always listening — 12V powered
Serial.println("[lora] ready + listening sf=12 bw=125 freq=868");
WiFi.begin(WIFI_SSID, WIFI_PASS);
Serial.print("[wifi] connecting");
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.printf("\n[wifi] connected ip=%s\n", WiFi.localIP().toString().c_str());
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(on_mqtt);
mqtt_connect();
g_last_send_ms = millis() - SEND_INTERVAL_MS;
Serial.println("[node] setup done");
}
void loop()
{
if (!mqtt.connected()) mqtt_connect();
mqtt.loop();
// Handle incoming commands (radio always in RX)
if (radio.available()) {
uint8_t buf[sizeof(CabinCmdPacket)];
radio.readData(buf, sizeof(buf));
handle_cmd(buf, radio.getPacketLength());
radio.startReceive();
}
if (millis() - g_last_send_ms >= SEND_INTERVAL_MS) {
send_lora();
g_last_send_ms = millis();
}
}