cfbab84f97309251e1f63596ca30cf83b48ed9d1
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 433 MHz + SSD1306 128x64 + LiPo)
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
-1reset pin) - I2C address: 0x3C
- 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 DD3 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
- Phase voltage: 32.7 / 52.7 / 72.7
- Reads battery voltage and estimates SoC.
- Builds JSON payload, compresses, wraps in LoRa packet, transmits.
- Deep sleeps between cycles.
- Listens for LoRa time sync packets to set UTC clock.
- OLED shows status + meter data pages.
Sender flow (pseudo-code):
void sender_cycle() {
meter_read(data); // SML/OBIS -> MeterData
read_battery(data); // VBAT + SoC
data.ts_utc = time_get_utc_or_uptime();
json = meterDataToJson(data);
compressed = compressBuffer(json);
lora_send(packet(MeterData, compressed));
display_set_last_meter(data);
display_set_last_read(ok);
display_set_last_tx(ok);
display_tick();
lora_receive_time_sync(); // optional
deep_sleep(SENDER_WAKE_INTERVAL_SEC);
}
Key sender functions:
bool meter_read(MeterData &data); // parse SML frame, set 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.
- 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) && pkt.type == MeterData) {
json = decompressBuffer(pkt.payload);
if (jsonToMeterData(json, data)) {
update_sender_status(data);
mqtt_publish_state(data);
}
}
if (time_to_send_timesync()) {
time_send_timesync(self_short_id);
}
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 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
/testtopic. - 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)
[5..N-3] compressed payload
[N-2..N-1] CRC16 (bytes 0..N-3)
LoRa radio settings:
- 433 MHz, SF12, BW 125 kHz, CR 4/5, CRC on, Sync Word 0x34
Data Format
JSON payload (sender + MQTT):
{
"id": "dd3-01",
"ts": 1737200000,
"energy_kwh": 1234.567,
"p_total_w": 950.0,
"p1_w": 500.0,
"p2_w": 450.0,
"p3_w": 0.0,
"v1_v": 230.1,
"v2_v": 229.8,
"v3_v": 231.0,
"bat_v": 3.92,
"bat_pct": 78
}
Device IDs
- Derived from WiFi STA MAC.
short_id = (MAC[4] << 8) | MAC[5]device_id = dd3-%04X
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
- After reset, OLED stays ON for 10 minutes regardless of switch.
- After that:
- GPIO14 HIGH: OLED forced ON.
- GPIO14 LOW: start 10-minute auto-off timer.
- Pages rotate every 10s.
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.organdtime.nist.gov.
Build Environments
lilygo-t3-v1-6-1: production buildlilygo-t3-v1-6-1-test: test build withENABLE_TEST_MODE
Limits & Known Constraints
- Compression: uses lightweight RLE (good for JSON but not optimal).
- OBIS parsing: heuristic SML parser; may need tuning for some DD3 meters.
- Payload size: JSON < 256 bytes (enforced by ArduinoJson static doc).
- Battery ADC: uses simple linear calibration constant in
power_manager.cpp. - OLED: no hardware reset line is used (matches working reference).
Files & Modules
include/config.h,src/config.cpp: pins, radio settings, sender IDsinclude/data_model.h,src/data_model.cpp: MeterData + ID initinclude/json_codec.h,src/json_codec.cpp: JSON encode/decodeinclude/compressor.h,src/compressor.cpp: RLE compressioninclude/lora_transport.h,src/lora_transport.cpp: LoRa packet + CRCinclude/meter_driver.h,src/meter_driver.cpp: SML/OBIS parseinclude/power_manager.h,src/power_manager.cpp: ADC + sleepinclude/time_manager.h,src/time_manager.cpp: NTP + time syncinclude/wifi_manager.h,src/wifi_manager.cpp: NVS config + WiFiinclude/mqtt_client.h,src/mqtt_client.cpp: MQTT publishinclude/web_server.h,src/web_server.cpp: AP/STA web pagesinclude/display_ui.h,src/display_ui.cpp: OLED pages + controlinclude/test_mode.h,src/test_mode.cpp: test sender/receiversrc/main.cpp: role detection and main loop
Quick Start
- Set role jumper on GPIO13:
- LOW: sender
- HIGH: receiver
- OLED control on GPIO14:
- HIGH: always on
- LOW: auto-off after 10 minutes
- 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
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.
Languages
C++
92.2%
C
7.8%