Files
DD3-LoRa-Bridge-MultiSender/README.md
acidburns 0577464ec5 refactor: stabilize legacy-core linking and header ownership
- Make include/ the canonical declarations for data_model/html_util/json_codec and convert dd3_legacy_core header copies to thin forwarders.
- Add stable public forwarders for app_context/receiver_pipeline/sender_state_machine and update refactor smoke test to stop using ../../src includes.
- Force-link dd3_legacy_core from setup() to ensure deterministic PlatformIO LDF linking across firmware envs.
- Refresh docs (README, Requirements, docs/TESTS.md) to reflect current module paths and smoke-test include strategy.
2026-02-20 23:29:50 +01:00

7.9 KiB

DD3-LoRa-Bridge-MultiSender

Firmware for LilyGO T3 v1.6.1 (ESP32 + SX1276 + SSD1306) that runs in two roles:

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

Architecture Summary

  • Single codebase, role selected at boot by detect_role() (src/config.cpp).
  • LoRa transport is wrapped with firmware-level CRC16-CCITT (src/lora_transport.cpp).
  • Sender meter ingest is decoupled from LoRa waits via FreeRTOS meter reader task + queue on ESP32 (src/sender_state_machine.cpp).
  • Batch payload codec is schema v3 with a 30-bit present_mask over [t_last-29, t_last] (lib/dd3_legacy_core/src/payload_codec.cpp).
  • Sender retries reuse cached encoded payload bytes (no re-encode on retry path).
  • Sender ACK receive windows adapt from observed ACK RTT + miss streak.
  • Sender catch-up mode drains backlog with immediate extra sends when more than one batch is queued (still ACK-gated, single inflight batch).
  • Sender only starts normal metering/transmit flow after valid time bootstrap from receiver ACK.
  • Sender fault counters are reset at first valid time sync and again at each UTC hour boundary.
  • Receiver runs STA mode if stored config is valid and connects, otherwise AP fallback.

LoRa Protocol

On-air frame:

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

msg_kind:

  • 0: BatchUp
  • 1: AckDown

BatchUp

Transport layer chunks payload into:

[batch_id_le:2][chunk_index:1][chunk_count:1][total_len_le:2][chunk_payload...]

Receiver reassembles all chunks before decode.

Payload codec (schema=3, magic 0xDDB3) carries:

  • metadata: sender ID, batch ID, t_last, present_mask, battery mV, error counters
  • arrays per present sample: energy_wh[], p1_w[], p2_w[], p3_w[]

n == 0 with present_mask == 0 is valid and used for sync request packets.

AckDown (7 bytes payload)

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

  • flags bit0: time_valid
  • ACK is repeated (ACK_REPEAT_COUNT=3, ACK_REPEAT_DELAY_MS=200)
  • Sender sets local time only if time_valid=1 and epoch >= MIN_ACCEPTED_EPOCH_UTC (2026-02-01 00:00:00 UTC)
  • Sender ACK wait windows are adaptive (short first window, expanded second window on miss)

Time Bootstrap and Timezone

Sender boot starts in sync-only mode:

  • g_time_acquired=false
  • sends sync requests every SYNC_REQUEST_INTERVAL_MS (15s)
  • does not run normal 1 Hz sample/batch flow yet

After valid ACK time:

  • time_set_utc() is called
  • g_time_acquired=true
  • sender fault counters are reset once (err_m, err_d, err_tx, last-error state)
  • normal 1 Hz sampling + periodic batch transmission starts

After initial sync:

  • sender fault counters are reset again once per UTC hour when the hour index changes (HH:00 UTC boundary)

Timezone:

  • TIMEZONE_TZ from include/config.h is applied in time_manager.
  • Web/OLED local-time rendering uses this timezone.
  • Default: CET-1CEST,M3.5.0/2,M10.5.0/3.

Sender Meter Path

Implemented by src/meter_driver.cpp and sender loop in src/sender_state_machine.cpp:

  • UART: Serial2, GPIO34, 9600 7E1
  • ESP32 RX buffer enlarged to 8192
  • Frame detection / ... !, timeout METER_FRAME_TIMEOUT_MS=3000
  • Single-pass OBIS line dispatch (no repeated multi-key scans per line)
  • Fixed-point decimal parser (dot/comma decimals), with early-exit once all required OBIS fields are captured
  • Parsed OBIS fields:
    • 0-0:96.8.0*255 meter Sekundenindex (hex u32)
    • 1-0:1.8.0 total energy (auto scales Wh -> kWh when unit is Wh)
    • 1-0:16.7.0 total active power
    • 1-0:36.7.0, 56.7.0, 76.7.0 phase powers

Timestamp derivation:

  • anchor offset: epoch_offset = epoch_now - meter_seconds
  • sample epoch: ts_utc = meter_seconds + epoch_offset
  • jump checks: rollback, wall-time delta mismatch, anchor drift

Sender builds sparse 30-slot windows and sends every METER_SEND_INTERVAL_MS (30s). When backlog is present (batch_q > 1), sender transmits additional queued batches immediately after ACK to reduce lag, while keeping stop-and-wait ACK semantics.

Sender diagnostics (serial debug mode):

  • periodic structured diag: line with:
    • meter parser counters (ok/parse_fail/overflow/timeout)
    • meter queue stats (depth/high-watermark/drops)
    • ACK stats (last RTT, EWMA RTT, miss streak, timeout/retry totals)
    • sender runtime totals (rx window ms, sleep ms)
  • diagnostics are local-only (serial); LoRa payload schema/fields are unchanged.

Receiver Behavior

For decoded BatchUp:

  1. Reassemble and decode.
  2. Validate sender identity (EXPECTED_SENDER_IDS and payload sender ID mapping).
  3. Reject unknown/mismatched senders before ACK and before SD/MQTT/web updates.
  4. Send AckDown promptly for accepted senders.
  5. Track duplicates per configured sender.
  6. If duplicate: update duplicate counters/time, skip data write/publish.
  7. If n==0: sync request path only.
  8. Else reconstruct each sample timestamp from t_last + present_mask, then:
    • append to SD CSV
    • publish MQTT state
    • update web status and last batch table

MQTT

State topic:

  • smartmeter/<device_id>/state

Fault topic (retained):

  • smartmeter/<device_id>/faults

State JSON (lib/dd3_legacy_core/src/json_codec.cpp) includes:

  • id, ts, e_kwh
  • p_w, p1_w, p2_w, p3_w
  • bat_v, bat_pct
  • optional link: rssi, snr
  • err_last, rx_reject, rx_reject_text
  • non-zero fault counters when available

Sender fault counter lifecycle:

  • counters are cumulative only within the current UTC-hour window after first sync
  • counters reset on first valid sender time sync and at each subsequent UTC hour boundary

Home Assistant discovery:

  • enabled by ENABLE_HA_DISCOVERY=true
  • publishes to homeassistant/sensor/<device_id>/<key>/config
  • unique_id format is <device_id>_<key> (example: dd3-F19C_energy)
  • device metadata:
    • identifiers: ["<device_id>"]
    • name: "<device_id>"
    • model: "DD3-LoRa-Bridge"
    • manufacturer: "AcidBurns" (from HA_MANUFACTURER in include/config.h)
    • single source of truth: change manufacturer only in include/config.h

Web UI, Wi-Fi, SD

  • Wi-Fi/MQTT/NTP/web-auth config is stored in Preferences.
  • AP fallback SSID prefix: DD3-Bridge-.
  • Default web credentials: admin/admin.
  • AP auth requirement is controlled by WEB_AUTH_REQUIRE_AP (default true).
  • STA auth requirement is controlled by WEB_AUTH_REQUIRE_STA (default true).

Web timestamp display:

  • human-facing timestamps show epoch (HH:MM:SS TZ) in local configured timezone.

SD CSV logging (src/sd_logger.cpp):

  • header: ts_utc,ts_hms_local,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last
  • ts_hms_local is local HH:MM:SS derived from TIMEZONE_TZ
  • per-day file partition uses local date from TIMEZONE_TZ: /dd3/<device_id>/YYYY-MM-DD.csv

History parser (src/web_server.cpp):

  • accepts both:
    • current layout (ts_utc,ts_hms_local,p_w,...)
    • legacy layout (ts_utc,p_w,...)
  • daily file lookup prefers local-date filenames and falls back to legacy UTC-date filenames for backward compatibility
  • requires full numeric parse for ts_utc and p_w (rejects trailing junk)

OLED duplicate display:

  • receiver sender-pages show 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:

python -m platformio run -e lilygo-t3-v1-6-1

Test Mode

ENABLE_TEST_MODE replaces normal loops with test_sender_loop / test_receiver_loop (src/test_mode.cpp):

  • Sender emits periodic JSON test payloads over LoRa.
  • Receiver decodes test payloads, updates display test codes, publishes MQTT to:
    • smartmeter/<device_id>/test