# 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 `-1` reset 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)**: ```cpp 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**: ```cpp 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)**: ```cpp 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**: ```cpp 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 `/test` topic. - Normal behavior is excluded from test builds. **Test sender (pseudo-code)**: ```cpp 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)**: ```cpp 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): ```json { "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: ```cpp 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-` - AP password: `changeme123` - Endpoints: - `/`: status overview - `/wifi`: WiFi/MQTT/NTP config (AP and STA) - `/sender/`: per-sender details ## MQTT - Topic: `smartmeter//state` - QoS 0 - Test mode: `smartmeter//test` - Client ID: `dd3-bridge-` (stable, derived from MAC) ## NTP - NTP servers are configurable in the web UI (`/wifi`). - Defaults: `pool.ntp.org` and `time.nist.gov`. ## Build Environments - `lilygo-t3-v1-6-1`: production build - `lilygo-t3-v1-6-1-test`: test build with `ENABLE_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 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`: SML/OBIS 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: ```bash pio run -e lilygo-t3-v1-6-1 -t upload --upload-port COMx ``` Test mode: ```bash pio run -e lilygo-t3-v1-6-1-test -t upload --upload-port COMx ```