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:
@@ -0,0 +1,2 @@
|
|||||||
|
.pio/
|
||||||
|
include/secrets.h
|
||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: ~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;
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user