DD3-LoRa-Bridge-MultiSender

Firmware for LilyGO T3 v1.6.1 (ESP32 + SX1276 + SSD1306) that runs as either:

  • Sender (PIN GPIO14 HIGH): reads one IEC 62056-21 meter, batches samples, sends over LoRa.
  • Receiver (PIN GPIO14 LOW): receives/ACKs batches, publishes MQTT, serves web UI, logs to SD.

Current Architecture

  • Single codebase, role selected at boot via detect_role() (include/config.h, src/config.cpp).
  • LoRa link uses explicit CRC16 frame protection in firmware (src/lora_transport.cpp).
  • Sender batches up to 30 samples and retries on missing ACK (BATCH_MAX_RETRIES=2, retry policy Keep).
  • Sender meter parsing is decoupled from LoRa ACK waits using a dedicated FreeRTOS reader task + queue (src/main.cpp).
  • Batch payload codec is schema v3 and uses a sparse present_mask over a 30-second window (no schema v2 compatibility).
  • Sender derives epoch timestamps from meter Sekundenindex (0-0:96.8.0*255) using an epoch anchor when time is synced.
  • Receiver uses STA mode when config is valid, otherwise AP fallback with web config.
  • No debug auto-reboot timer is active in normal firmware loops.

LoRa Frame Protocol (Current)

On-air frame format:

[msg_kind:1][device_short_id:2][payload...][crc16:2]

msg_kind:

  • 0 = BatchUp
  • 1 = AckDown

BatchUp

Transport is chunked (batch_id, chunk_index, chunk_count, total_len) and reassembled before payload decode.

Payload codec (src/payload_codec.cpp) currently uses:

  • kMagic=0xDDB3
  • kSchema=3
  • metadata: sender, batch, t_last, present_mask, battery, fault counters
  • data arrays: energy_wh[], p1_w[], p2_w[], p3_w[]

present_mask is a 30-bit second map in the [t_last-29, t_last] window. Only set bits carry samples, so missing seconds are explicitly represented.

n == 0 is valid for sync request packets (present_mask == 0).

AckDown (7 bytes)

[flags:1][batch_id_be:2][epoch_utc_be:4]

  • flags bit0: time_valid
  • Receiver repeats ACK (ACK_REPEAT_COUNT=3, ACK_REPEAT_DELAY_MS=200).
  • Sender accepts time only if time_valid=1 and epoch >= MIN_ACCEPTED_EPOCH_UTC (2026-02-01 00:00:00 UTC).

Time Bootstrap Guardrail

On sender boot:

  • g_time_acquired=false
  • no normal sampling/transmit yet
  • sync request every SYNC_REQUEST_INTERVAL_MS (15s)

Only after valid ACK time is received:

  • system time is set
  • normal 1 Hz sampling and periodic LoRa batch transmit start

This blocks pre-threshold timestamps from MQTT/SD paths.

Timezone handling:

  • Local time rendering uses TIMEZONE_TZ from include/config.h.
  • Default value is CET-1CEST,M3.5.0/2,M10.5.0/3 and can be changed at compile time.

Sender Meter Path

Implemented in src/meter_driver.cpp + sender loop in src/main.cpp:

  • UART: Serial2, RX pin GPIO34 (PIN_METER_RX), 9600 7E1
  • ESP32 RX buffer is enlarged to 8192 bytes to survive long LoRa blocking sections.
  • Frame detection: starts at '/', ends at '!', timeout protection included (METER_FRAME_TIMEOUT_MS=3000).
  • Parsing runs in a dedicated sender task and is handed to the main sender loop via queue.
  • Parsed OBIS values:
    • 0-0:96.8.0*255 (meter Sekundenindex, 4-byte hex)
    • 1-0:1.8.0 (total energy)
    • 1-0:16.7.0 (total power)
    • 1-0:36.7.0, 56.7.0, 76.7.0 (phase powers)
  • 1-0:1.8.0*Wh is automatically scaled to kWh

Timestamping/validation on sender:

  • Anchor when time is valid: epoch_offset = epoch_now - meter_seconds.
  • Derived sample time: ts_utc = meter_seconds + epoch_offset.
  • Meter-time checks: monotonicity and delta_meter_seconds vs elapsed wall time (with tolerance) plus anchor drift checks.
  • On detected jump/drift, sender records meter fault and resulting timestamp discontinuities propagate through present_mask to receiver.

Sender samples every second and transmits batches every 30 seconds.

Receiver Behavior

For valid BatchUp decode:

  1. Reassemble chunks and decode payload.
  2. Send AckDown immediately.
  3. Drop duplicate batches per sender (batch_id tracking).
  4. Track duplicate stats per sender: absolute duplicates, total received, duplicate percentage, last duplicate timestamp.
  5. If n==0: treat as sync request only.
  6. Else reconstruct timestamps from t_last + present_mask, preserving skipped seconds, then log to SD, update web UI, publish MQTT.

MQTT Topics and Payloads

State topic:

  • smartmeter/<device_id>/state

Fault topic (retained):

  • smartmeter/<device_id>/faults

State JSON fields (src/json_codec.cpp):

  • id, ts, e_kwh
  • p_w, p1_w, p2_w, p3_w
  • bat_v, bat_pct
  • optional link fields: rssi, snr
  • fault/reject fields: err_last, rx_reject, rx_reject_text (+ non-zero counters)

Home Assistant discovery is enabled (ENABLE_HA_DISCOVERY=true) and publishes config topics under:

  • homeassistant/sensor/<device_id>/<key>/config

Web UI, Wi-Fi, Storage

  • Wi-Fi/MQTT/NTP/web-auth config persists in Preferences (wifi_manager).
  • AP fallback SSID prefix: DD3-Bridge-.
  • Default web credentials: admin/admin.
  • SD logging enabled (ENABLE_SD_LOGGING=true).
  • Sender-specific web status includes duplicate-batch counters and the last duplicate time.
  • Sender-specific OLED page shows duplicate rate as pct (absolute) and last duplicate as HH:MM.

Build Environments

From platformio.ini:

  • lilygo-t3-v1-6-1
  • lilygo-t3-v1-6-1-test
  • lilygo-t3-v1-6-1-868
  • lilygo-t3-v1-6-1-868-test
  • lilygo-t3-v1-6-1-payload-test
  • lilygo-t3-v1-6-1-868-payload-test
  • lilygo-t3-v1-6-1-prod
  • lilygo-t3-v1-6-1-868-prod

Example:

~/.platformio/penv/bin/pio run -e lilygo-t3-v1-6-1

Test Mode

ENABLE_TEST_MODE replaces normal sender/receiver loops with dedicated test loops (src/test_mode.cpp). It sends/receives JSON test frames and publishes to smartmeter/<device_id>/test.

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.7 MiB
Languages
C++ 95.6%
C 4.4%