From a54459476984dfaa74484743fa7d30cfebeeceff Mon Sep 17 00:00:00 2001 From: steinhelge Date: Tue, 10 Mar 2026 23:11:35 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20project:=20XIAO=20ESP32S3=20+=20SX126?= =?UTF-8?q?2=20LoRa=20sensor=20link=20hytte=E2=80=93hjem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 2 + CLAUDE.md | 86 +++++++++++++++++ esphome/cabin_tech.yaml | 169 +++++++++++++++++++++++++++++++++ esphome/secrets.yaml.example | 15 +++ include/config.h | 62 +++++++++++++ include/crypto.h | 20 ++++ include/packets.h | 36 +++++++ include/secrets.example.h | 7 ++ platformio.ini | 26 ++++++ src/cabin_gw/main.cpp | 158 +++++++++++++++++++++++++++++++ src/cabin_node/main.cpp | 175 +++++++++++++++++++++++++++++++++++ 11 files changed, 756 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 esphome/cabin_tech.yaml create mode 100644 esphome/secrets.yaml.example create mode 100644 include/config.h create mode 100644 include/crypto.h create mode 100644 include/packets.h create mode 100644 include/secrets.example.h create mode 100644 platformio.ini create mode 100644 src/cabin_gw/main.cpp create mode 100644 src/cabin_node/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0c2c4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.pio/ +include/secrets.h diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f601a19 --- /dev/null +++ b/CLAUDE.md @@ -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 + ▼ 15–20 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 | 0–100 | +| 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 | diff --git a/esphome/cabin_tech.yaml b/esphome/cabin_tech.yaml new file mode 100644 index 0000000..db845e3 --- /dev/null +++ b/esphome/cabin_tech.yaml @@ -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 diff --git a/esphome/secrets.yaml.example b/esphome/secrets.yaml.example new file mode 100644 index 0000000..6ae5dd2 --- /dev/null +++ b/esphome/secrets.yaml.example @@ -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" diff --git a/include/config.h b/include/config.h new file mode 100644 index 0000000..51b098b --- /dev/null +++ b/include/config.h @@ -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 1–4x/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 diff --git a/include/crypto.h b/include/crypto.h new file mode 100644 index 0000000..eb05109 --- /dev/null +++ b/include/crypto.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include +#include +#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(key), strlen(key), + data, len, out); + uint32_t sig; + memcpy(&sig, out, 4); + return sig; +} diff --git a/include/packets.h b/include/packets.h new file mode 100644 index 0000000..e47a398 --- /dev/null +++ b/include/packets.h @@ -0,0 +1,36 @@ +#pragma once +#include + +#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: ~10000–14400) + uint8_t batt_pct; // SOC 0–100, 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; +}; diff --git a/include/secrets.example.h b/include/secrets.example.h new file mode 100644 index 0000000..a3f9ae9 --- /dev/null +++ b/include/secrets.example.h @@ -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" diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..190563b --- /dev/null +++ b/platformio.ini @@ -0,0 +1,26 @@ +[platformio] +default_envs = cabin_node + +[env:cabin_node] +platform = espressif32 +board = seeed_xiao_esp32s3 +framework = arduino +build_src_filter = + - +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 = + - +build_flags = -I include +monitor_speed = 115200 +lib_deps = + jgromes/RadioLib@^7.1.2 + knolleary/PubSubClient@^2.8 + bblanchon/ArduinoJson@^7.0.4 diff --git a/src/cabin_gw/main.cpp b/src/cabin_gw/main.cpp new file mode 100644 index 0000000..f57eae5 --- /dev/null +++ b/src/cabin_gw/main.cpp @@ -0,0 +1,158 @@ +#include +#include +#include +#include +#include + +#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(&pkt), sizeof(pkt) - sizeof(pkt.sig)); + + int state = radio.transmit(reinterpret_cast(&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(&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()); + } +} diff --git a/src/cabin_node/main.cpp b/src/cabin_node/main.cpp new file mode 100644 index 0000000..f7a128f --- /dev/null +++ b/src/cabin_node/main.cpp @@ -0,0 +1,175 @@ +#include +#include +#include +#include + +#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(&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(&pkt), sizeof(pkt) - sizeof(pkt.sig)); + + // Pause RX, transmit, resume RX + radio.standby(); + int state = radio.transmit(reinterpret_cast(&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(); + } +}