2026-01-20 01:39:06 +01:00
2026-01-23 17:10:04 +01:00
2026-01-20 01:39:06 +01:00
2026-02-01 19:34:28 +01:00
2026-01-20 01:39:06 +01:00
2026-01-20 01:39:06 +01:00

DD3 LoRa Bridge (Multi-Sender)

Unified firmware for LilyGO T3 v1.6.1 (ESP32 + SX1276 + SSD1306) that runs as Sender or Receiver based on a GPIO jumper. Senders read DD3 smart meter values, compress JSON, and transmit over LoRa. The receiver validates packets, publishes to MQTT, provides a web UI, and shows per-sender status on the OLED.

Hardware

Board: LilyGO T3 LoRa32 v1.6.1 (ESP32 + SX1276 + SSD1306 128x64 + LiPo) Variants:

  • SX1276 433 MHz module (default build)
  • SX1276 868 MHz module (use 868 build environments)

Pin Mapping

  • LoRa (SX1276)
    • SCK: GPIO5
    • MISO: GPIO19
    • MOSI: GPIO27
    • NSS/CS: GPIO18
    • RST: GPIO23
    • DIO0: GPIO26
  • OLED (SSD1306)
    • SDA: GPIO21
    • SCL: GPIO22
    • RST: not used (SSD1306 init uses -1 reset pin)
    • I2C address: 0x3C
  • I2C RTC (DS3231)
    • SDA: GPIO21
    • SCL: GPIO22
    • I2C address: 0x68
  • Battery ADC: GPIO35 (via on-board divider)
  • Role select: GPIO13 (INPUT_PULLDOWN)
    • LOW = Sender
    • HIGH = Receiver
  • OLED control: GPIO14 (INPUT_PULLDOWN)
    • HIGH = force OLED on
    • LOW = allow auto-off after timeout
  • Smart meter UART RX: GPIO34 (input-only, always connected)

Notes on GPIOs

  • GPIO34/35/36/39 are input-only and have no internal pullups/pulldowns.
  • Strap pins (GPIO0/2/4/5/12/15) can affect boot; avoid for role or control jumpers.

Firmware Roles

Sender (battery-powered)

  • Reads smart meter via optical IR (UART 9600 7E1).
  • Extracts OBIS values:
    • Energy total: 1-0:1.8.0*255
    • Total power: 1-0:16.7.0*255
    • Phase power: 36.7 / 56.7 / 76.7
  • Reads battery voltage and estimates SoC.
  • Builds JSON payload, compresses, wraps in LoRa packet, transmits.
  • Light sleeps between meter reads; batches are sent every 30s.
  • Listens for LoRa time sync packets to set UTC clock.
  • Uses DS3231 RTC after boot if no time sync has arrived yet.
  • OLED shows status + meter data pages.

Sender flow (pseudo-code):

void sender_loop() {
  meter_read_every_second(); // OBIS -> MeterData samples
  read_battery(data);        // VBAT + SoC

  if (time_to_send_batch()) {
    json = meterBatchToJson(samples, batch_id);
    compressed = compressBuffer(json);
    lora_send(packet(MeterBatch, compressed));
  }

  display_set_last_meter(data);
  display_set_last_read(ok);
  display_set_last_tx(ok);
  display_tick();

  lora_receive_time_sync(); // optional
  light_sleep_until_next_event();
}

Key sender functions:

bool meter_read(MeterData &data);        // parse OBIS fields
void read_battery(MeterData &data);      // ADC -> volts + percent
bool meterDataToJson(const MeterData&, String&);
bool compressBuffer(const uint8_t*, size_t, uint8_t*, size_t, size_t&);
bool lora_send(const LoraPacket &pkt);   // add header + CRC16 and transmit

Receiver (USB-powered)

  • WiFi STA connect using stored config; if not available/fails, starts AP.
  • NTP sync (UTC) and local display in Europe/Berlin.
  • Receives LoRa packets, verifies CRC16, decompresses, parses JSON.
  • Publishes meter JSON to MQTT.
  • Sends ACKs for MeterBatch packets and de-duplicates by batch_id.
  • Web UI:
    • AP mode: status + WiFi/MQTT config.
    • STA mode: status + per-sender pages.
  • OLED cycles through receiver status and per-sender pages.

Receiver loop (pseudo-code):

void receiver_loop() {
  if (lora_receive(pkt)) {
    if (pkt.type == MeterData) {
      json = decompressBuffer(pkt.payload);
      if (jsonToMeterData(json, data)) {
        update_sender_status(data);
        mqtt_publish_state(data);
      }
    } else if (pkt.type == MeterBatch) {
      json = reassemble_and_decompress_batch(pkt);
      for (sample in jsonToMeterBatch(json)) {
        update_sender_status(sample);
        mqtt_publish_state(sample);
      }
    }
  }

  if (time_to_send_timesync()) {
    time_send_timesync(self_short_id); // 60s for first 10 min, then hourly if RTC is present
  }

  mqtt_loop();
  web_server_loop();
  display_set_receiver_status(...);
  display_tick();
}

Key receiver functions:

bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms);
bool jsonToMeterData(const String &json, MeterData &data);
bool jsonToMeterBatch(const String &json, MeterData *samples, size_t max, size_t &count);
bool mqtt_publish_state(const MeterData &data);
void web_server_loop();                   // AP or STA UI
void time_send_timesync(uint16_t self_id);

Test Mode (compile-time)

Enabled by -DENABLE_TEST_MODE (see platformio.ini test environment).

  • Sender: sends 4-digit test code every ~30s in JSON.
  • Receiver: shows last test code per sender and publishes to /test topic.
  • Normal behavior is excluded from test builds.

Test sender (pseudo-code):

void test_sender_loop() {
  code = random_4_digits();
  json = {id, role:"sender", test_code: code, ts};
  lora_send(packet(TestCode, compress(json)));
  display_set_test_code(code);
}

Test receiver (pseudo-code):

void test_receiver_loop() {
  if (pkt.type == TestCode) {
    json = decompress(pkt.payload);
    update_sender_test_code(json);
    mqtt_publish_test(id, json);
  }
}

LoRa Protocol

Packet layout:

[0]    protocol_version (1)
[1]    role (0=sender, 1=receiver)
[2..3] device_id_short (uint16)
[4]    payload_type (0=meter, 1=test, 2=time_sync, 3=meter_batch, 4=ack)
[5..N-3] compressed payload
[N-2..N-1] CRC16 (bytes 0..N-3)

LoRa radio settings:

  • Frequency: 433 MHz or 868 MHz (set by build env via LORA_FREQUENCY_HZ)
  • SF12, BW 125 kHz, CR 4/5, CRC on, Sync Word 0x34

Data Format

JSON payload (sender + MQTT):

{
  "id": "F19C",
  "ts": 1737200000,
  "e_kwh": 1234.57,
  "p_w": 950.00,
  "p1_w": 500.00,
  "p2_w": 450.00,
  "p3_w": 0.00,
  "bat_v": 3.92,
  "bat_pct": 78
}

MeterBatch JSON (compressed over LoRa) uses per-field arrays with integer units for easier ingestion:

{
  "schema": 1,
  "sender": "s01",
  "batch_id": 1842,
  "t0": 1738288000,
  "dt_s": 1,
  "n": 3,
  "energy_wh": [123456700, 123456701, 123456701],
  "p_w": [930, 940, 950],
  "p1_w": [480, 490, 500],
  "p2_w": [450, 450, 450],
  "p3_w": [0, 0, 0],
  "meta": {
    "rssi": -92,
    "snr": 7.5,
    "rx_ts": 1738288031
  }
}

Notes:

  • sender maps to EXPECTED_SENDER_IDS order (s01 = first sender).
  • meta is injected by the receiver after batch reassembly.

Device IDs

  • Derived from WiFi STA MAC.
  • short_id = (MAC[4] << 8) | MAC[5]
  • device_id = dd3-%04X
  • JSON id uses only the last 4 hex digits (e.g., F19C) to save airtime.

Receiver expects known senders in include/config.h via:

constexpr uint8_t NUM_SENDERS = 1;
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C };

OLED Behavior

  • Sender: OLED stays ON for 10 seconds on each wake, then powers down for sleep.
  • Receiver: OLED follows the 10-minute auto-off behavior:
    • GPIO14 HIGH: OLED forced ON.
    • GPIO14 LOW: auto-off after 10 minutes.
  • Pages rotate every 4s.

Power & Battery

  • Sender disables WiFi/BLE, reads VBAT via ADC, uses linear SoC map:
    • 3.0 V = 0%
    • 4.2 V = 100%
  • Uses deep sleep between cycles (SENDER_WAKE_INTERVAL_SEC).

Web UI

  • AP SSID: DD3-Bridge-<short_id>
  • AP password: changeme123
  • Endpoints:
    • /: status overview
    • /wifi: WiFi/MQTT/NTP config (AP and STA)
    • /sender/<device_id>: per-sender details

MQTT

  • Topic: smartmeter/<deviceId>/state
  • QoS 0
  • Test mode: smartmeter/<deviceId>/test
  • Client ID: dd3-bridge-<device_id> (stable, derived from MAC)

NTP

  • NTP servers are configurable in the web UI (/wifi).
  • Defaults: pool.ntp.org and time.nist.gov.

RTC (DS3231)

  • Optional DS3231 on the I2C bus. Connect SDA to GPIO21 and SCL to GPIO22 (same bus as the OLED).
  • Enable/disable with ENABLE_DS3231 in include/config.h.
  • Receiver time sync packets set the RTC.
  • On boot, if no LoRa time sync has arrived yet, the sender uses the RTC time as the initial ts_utc.
  • When no RTC is present or enabled, the receiver keeps sending time sync every 60 seconds.

Build Environments

  • lilygo-t3-v1-6-1: production build
  • lilygo-t3-v1-6-1-test: test build with ENABLE_TEST_MODE
  • lilygo-t3-v1-6-1-868: production build for 868 MHz modules
  • lilygo-t3-v1-6-1-868-test: test build for 868 MHz modules

Config Knobs

Key timing settings in include/config.h:

  • METER_SAMPLE_INTERVAL_MS
  • METER_SEND_INTERVAL_MS
  • BATCH_ACK_TIMEOUT_MS
  • BATCH_MAX_RETRIES

Limits & Known Constraints

  • Compression: uses lightweight RLE (good for JSON but not optimal).
  • OBIS parsing: supports IEC 62056-21 ASCII (Mode D); may need tuning for some meters.
  • Payload size: single JSON frames < 256 bytes (ArduinoJson static doc); batch frames are chunked and reassembled.
  • Battery ADC: uses simple linear calibration constant in power_manager.cpp.
  • OLED: no hardware reset line is used (matches working reference).
  • Batch ACKs: sender waits for ACK after a batch and retries up to BATCH_MAX_RETRIES with BATCH_ACK_TIMEOUT_MS between attempts.

Files & Modules

  • include/config.h, src/config.cpp: pins, radio settings, sender IDs
  • include/data_model.h, src/data_model.cpp: MeterData + ID init
  • include/json_codec.h, src/json_codec.cpp: JSON encode/decode
  • include/compressor.h, src/compressor.cpp: RLE compression
  • include/lora_transport.h, src/lora_transport.cpp: LoRa packet + CRC
  • include/meter_driver.h, src/meter_driver.cpp: IEC 62056-21 ASCII parse
  • include/power_manager.h, src/power_manager.cpp: ADC + sleep
  • include/time_manager.h, src/time_manager.cpp: NTP + time sync
  • include/wifi_manager.h, src/wifi_manager.cpp: NVS config + WiFi
  • include/mqtt_client.h, src/mqtt_client.cpp: MQTT publish
  • include/web_server.h, src/web_server.cpp: AP/STA web pages
  • include/display_ui.h, src/display_ui.cpp: OLED pages + control
  • include/test_mode.h, src/test_mode.cpp: test sender/receiver
  • src/main.cpp: role detection and main loop

Quick Start

  1. Set role jumper on GPIO13:
    • LOW: sender
    • HIGH: receiver
  2. OLED control on GPIO14:
    • HIGH: always on
    • LOW: auto-off after 10 minutes
  3. Build and upload:
pio run -e lilygo-t3-v1-6-1 -t upload --upload-port COMx

Test mode:

pio run -e lilygo-t3-v1-6-1-test -t upload --upload-port COMx

868 MHz builds:

pio run -e lilygo-t3-v1-6-1-868 -t upload --upload-port COMx

868 MHz test mode:

pio run -e lilygo-t3-v1-6-1-868-test -t upload --upload-port COMx
Description
Unified Firmware for the LilyGO T3 v1.6.1 433MHz Version. Sender will read DD3 Smart Meter Data and send it to reciever as well as display on OLED. Reciever will publish to mqtt and show on a local website as well as OLED.
Readme 2.1 MiB
Languages
C++ 94.2%
C 5.8%