Bump the batch payload codec to schema v4 with separate meter-time and UTC anchors, then use meter seconds for sparse batch slotting and receiver reconstruction. Update the current 868 MHz bench configuration, allow ACKs from configured receiver short IDs, improve AP-to-STA recovery, quiet the test build, and document the changed protocol in the README.
8.7 KiB
DD3-LoRa-Bridge-MultiSender
Firmware for LilyGO T3 v1.6.1 (ESP32 + SX1276 + SSD1306) that runs in two roles:
Sender(GPIO14HIGH): reads one IEC 62056-21 meter, builds 30-slot sparse batches, sends via LoRa.Receiver(GPIO14LOW): 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
v4with a 30-bitpresent_maskover[meter_t_last-29, meter_t_last]and a separate UTC anchor (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.
- Current bench defaults in
include/config.h: 868 MHz LoRa, sender short-ID0x6540, receiver short-ID0x7EB4.
LoRa Protocol
On-air frame:
[msg_kind:1][device_short_id:2][payload...][crc16:2]
msg_kind:
0:BatchUp1: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=4, magic 0xDDB3) carries:
- metadata: sender ID, batch ID,
meter_t_last,ts_utc_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.
Schema v4 is not wire-compatible with schema v3: both sender and receiver must be flashed with matching firmware.
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=1andepoch >= 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 calledg_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 UTCboundary)
Timezone:
TIMEZONE_TZfrominclude/config.his applied intime_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
/ ... !, timeoutMETER_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*255meter Sekundenindex (hex u32)1-0:1.8.0total energy (auto scales Wh -> kWh when unit is Wh)1-0:16.7.0total active power1-0:36.7.0,56.7.0,76.7.0phase 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 in meter-seconds space and sends every METER_SEND_INTERVAL_MS (30s).
Samples without a valid meter seconds value are rejected for normal batch transmission.
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)
- meter parser counters (
- diagnostics are local-only (serial); LoRa payload schema/fields are unchanged.
Receiver Behavior
For decoded BatchUp:
- Reassemble and decode.
- Validate sender identity (
EXPECTED_SENDER_IDSand payload sender ID mapping). - Reject unknown/mismatched senders before ACK and before SD/MQTT/web updates.
- Send
AckDownpromptly for accepted senders. - Track duplicates per configured sender.
- If duplicate: update duplicate counters/time, skip data write/publish.
- If
n==0: sync request path only. - Else reconstruct each sample from
meter_t_last + present_maskandts_utc_last + present_mask, then:- append to SD CSV
- publish MQTT state
- update web status and last batch table
ACK validation accepts only configured sender IDs, the sender's own short-ID, or configured receiver short-IDs (EXPECTED_RECEIVER_IDS) so receiver-originated ACKs are allowed without accepting arbitrary device IDs.
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_kwhp_w,p1_w,p2_w,p3_wbat_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_idformat is<device_id>_<key>(example:dd3-F19C_energy)- device metadata:
identifiers: ["<device_id>"]name: "<device_id>"model: "DD3-LoRa-Bridge"manufacturer: "AcidBurns"(fromHA_MANUFACTURERininclude/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(defaulttrue). - STA auth requirement is controlled by
WEB_AUTH_REQUIRE_STA(defaulttrue). - If the receiver boots into AP mode with saved Wi-Fi and MQTT config, it periodically retries STA mode and reinitializes NTP, MQTT, and the web server after a successful reconnect.
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_localis localHH:MM:SSderived fromTIMEZONE_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,...)
- current layout (
- daily file lookup prefers local-date filenames and falls back to legacy UTC-date filenames for backward compatibility
- requires full numeric parse for
ts_utcandp_w(rejects trailing junk)
OLED duplicate display:
- receiver sender-pages show duplicate rate as
pct (absolute)and last duplicate asHH:MM.
Build Environments
From platformio.ini:
production: serial debug off, light sleep ondebug: serial diagnostics on, real meter and real LoRatest: synthetic meter samples and payload codec self-test, serial debug off
Example:
python -m platformio run -e production
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