40 Commits

Author SHA1 Message Date
eb80f49046 test: align test mode with ACK/time flow and expose ack metrics 2026-02-04 20:12:02 +01:00
acidburns
31a3eea5dd docs: rewrite README for current multi-meter behavior 2026-02-04 19:03:43 +01:00
c62f07bf44 Document multi-meter UART mapping and energy-only sender behavior 2026-02-04 15:22:30 +01:00
938f490a32 Add multi-meter energy sender schema with UART0/1/2 mode split 2026-02-04 15:22:24 +01:00
290ca55b8b Reset RX signal state at start of each receive window 2026-02-04 15:11:07 +01:00
f177e5562d Drain oversized LoRa packets to prevent RX FIFO corruption 2026-02-04 15:10:37 +01:00
cb6929bdc1 Add detailed sender ACK RX diagnostics with reject context 2026-02-04 14:42:44 +01:00
c3e5ba3a53 Use protocol constants for ACK airtime window sizing 2026-02-04 14:40:34 +01:00
373667ab8a Document minimal batch/ack protocol and timestamp safety rules 2026-02-04 11:57:59 +01:00
f0503af8c7 Refactor LoRa protocol to batch+ack with ACK-based time bootstrap 2026-02-04 11:57:49 +01:00
f08d9a34d3 Normalize power/energy output formatting 2026-02-04 02:33:43 +01:00
7e5e23e56c Scale ACK RX window to LoRa airtime
- Compute ACK receive window from airtime with bounds and margin
- Retry once if initial window misses
- Document ACK window sizing
2026-02-04 01:21:42 +01:00
1024aa3dd0 Add RX reject reasons to telemetry and UI
BACKWARD-INCOMPATIBLE: MeterBatch schema bumped to v2 with err_rx_reject.
- Track and log RX reject reasons (CRC/protocol/role/payload/length/id/batch)
- Include rx_reject in sender telemetry JSON and receiver web UI
- Add lora_receive reject reason logging under SERIAL_DEBUG_MODE
2026-02-04 01:01:49 +01:00
0e7214d606 Repeat batch ACKs to cover RX latency
- Add ACK_REPEAT_COUNT/ACK_REPEAT_DELAY_MS and repeat ACK sends
- Update README with repeat-ACK behavior
2026-02-04 00:53:06 +01:00
5a86d1bd30 Add LoRa TX timing diagnostics
- Log idle/begin/write/end timing for LoRa TX under SERIAL_DEBUG_MODE
- Document TX timing logs in README
2026-02-04 00:48:20 +01:00
0a99bf3268 Send batch ACKs immediately after reassembly
- Move ACK ahead of MQTT/web work to meet sender 400ms window
- Update ACK log format and document early-ACK behavior
2026-02-04 00:36:40 +01:00
4e06f7a96d Log ACK transmit and reject cases
- Add debug log for ACK TX with batch/sender/receiver ids
- Log rejected ACKs to help diagnose mismatched ids or batches
2026-02-04 00:35:01 +01:00
fde4719a50 Improve timesync acquisition and logging
- Add boot acquisition mode with wider RX windows until first TimeSync
- Log sender TimeSync RX results and receiver TX events
- Document acquisition behavior
2026-02-04 00:33:05 +01:00
e0d35d49bc Validate RTC epoch before setting time
- Reject out-of-range DS3231 epochs and log accept/reject under SERIAL_DEBUG_MODE
- Document RTC validation so LoRa TimeSync can recover
2026-02-04 00:31:10 +01:00
e8fb8680cb Gate slow timesync on LoRa reception
- Keep sender in fast TimeSync listen mode until it receives a LoRa beacon
- Reset scheduler when interval changes to avoid stuck timing
2026-02-04 00:03:38 +01:00
cbf0f7d9b9 Expose timesync error in MQTT and web UI
BACKWARD-INCOMPATIBLE: MQTT faults payload now always includes err_last/err_last_text and err_last_age (schema change).
2026-02-04 00:01:38 +01:00
f7a2503d7a Add timesync burst handling and sender-only timeout
- Add TimeSync fault code and labels in UI/SD/web docs
- Trigger receiver beacon bursts on sender drift, but keep errors sender-local
- Sender flags TimeSync only after TIME_SYNC_ERROR_TIMEOUT_MS
2026-02-03 23:40:11 +01:00
43893c24d1 Keep receiver timesync fast and extend sender fast window
- Receiver now sends time sync every 60s indefinitely (mains powered)
- Sender stays in fast timesync listen mode for first 60s even with RTC
2026-02-03 22:28:36 +01:00
cd4c99f125 Calibrate battery ADC and document LiPo curve
- Add BATTERY_CAL config and debug logging for raw ADC samples
- Use LiPo voltage curve (4.2V full, 2.9V empty) for % mapping
- Document battery calibration, curve, and debug output in README
2026-02-03 22:12:48 +01:00
b8a4c27daa Average battery ADC samples
- Read battery 5 times and average for a steadier voltage estimate
2026-02-02 23:28:54 +01:00
2199627a35 Fix OLED autosleep timing and battery sampling cadence
- Track last OLED activity to avoid double timeout; keep power gating on transitions
- Copy TZ before setenv() in timegm_fallback to avoid invalid pointer reuse
- Add BATTERY_SAMPLE_INTERVAL_MS and only refresh cache at batch start when due
- Keep battery sampling to a single ADC read (Arduino core lacks explicit ADC power gating)
2026-02-02 23:01:55 +01:00
90d830da6f Keep receiver LoRa in continuous RX
- Add lora_receive_continuous() helper and use it after init and TX (ACK/time sync)

- Ensure receiver returns to RX immediately after lora_send

- Document continuous RX behavior in README
2026-02-02 22:17:09 +01:00
237e392c02 Make IEC 62056-21 meter input non-blocking
- Add RX state machine with frame buffer, timeouts, and debug counters

- Expose meter_poll_frame/meter_parse_frame and reuse existing OBIS parsing

- Use cached last-valid frame at 1 Hz sampling to avoid blocking

- Document non-blocking meter handling in README
2026-02-02 22:03:58 +01:00
8e6c64a18e Reduce sender power draw (RX windows + CPU/WiFi/ADC/pins)
- Add LoRa idle/sleep/receive-window helpers and use short RX windows for ACK/time sync

- Schedule sender time-sync windows (fast/slow) and track RX vs sleep time in debug

- Lower sender power (80 MHz CPU, WiFi/BT off, reduced ADC sampling, unused pins pulldown)

- Make SERIAL_DEBUG_MODE a build flag, add prod envs with debug off, and document changes
2026-02-02 21:44:04 +01:00
a4d9be1903 Harden history device ID validation and SD download filename 2026-02-02 21:19:44 +01:00
0e12b406de Harden web UI auth, input handling, and SD path validation
- Add optional Basic Auth with NVS-backed credentials and STA/AP flags; protect status, wifi, history, and download routes

- Stop pre-filling WiFi/MQTT/Web UI password fields; keep stored secrets on blank and add clear-password checkboxes

- Add HTML escaping + URL encoding helpers and apply to user-controlled strings; add unit test

- Harden /sd/download path validation (prefix, length, dotdot, slashes) and log rejections

- Enforce protocol version in LoRa receive and release GPIO14 before SD init

- Update README security, SD, and GPIO sharing notes
2026-02-02 21:08:05 +01:00
b5477262ea Add SD history UI and pin remap
- Add SD history chart + download listing to web UI
- Use HSPI for SD and fix SD pin mapping
- Swap role/OLED control pins and update role detection
- Update README pin mapping and SD/history docs
2026-02-02 01:43:54 +01:00
d32ae30014 Move AP credentials to config and clarify STA UI access 2026-02-02 00:23:52 +01:00
f3af5b3f1c Add SD logging and update docs
- Add optional microSD CSV logging per sender/day on receiver
- Wire logger into receiver packet handling
- Document new batch header fields, build envs, and SD logging
- Make sender links open in a new tab
2026-02-02 00:22:35 +01:00
5085b9ad3d Improve receiver web UI fields and manual 2026-02-02 00:00:55 +01:00
a03c2cdb07 Include sender error counters in batch payload 2026-02-02 00:00:29 +01:00
13f2f02e42 Tidy sender page layout and use SF12 2026-02-01 23:38:43 +01:00
16c1b90b1e Add payload codec test envs and enable serial debug 2026-02-01 22:54:07 +01:00
e5c4e04ff9 Update README for binary batch payload and SF11 2026-02-01 22:42:26 +01:00
e24798eb55 Use compact binary payload for LoRa batches 2026-02-01 22:37:21 +01:00
34 changed files with 2745 additions and 1402 deletions

392
README.md
View File

@@ -1,347 +1,129 @@
# DD3 LoRa Bridge (Multi-Sender) # DD3-LoRa-Bridge-MultiSender
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. Firmware for LilyGO T3 v1.6.1 (`ESP32 + SX1276 + SSD1306`) that runs as either:
- `Sender` (PIN `GPIO14` HIGH): reads multiple IEC 62056-21 meters, batches data, sends over LoRa.
- `Receiver` (PIN `GPIO14` LOW): receives/ACKs batches, publishes MQTT, serves web UI, logs to SD.
## Hardware ## Current Architecture
Board: **LilyGO T3 LoRa32 v1.6.1** (ESP32 + SX1276 + SSD1306 128x64 + LiPo)
Variants:
- SX1276 **433 MHz** module (default build)
- SX1276 **868 MHz** module (use 868 build environments)
### Pin Mapping - Single codebase, role selected at boot via `detect_role()` (`include/config.h`, `src/config.cpp`).
- LoRa (SX1276) - LoRa link uses explicit CRC16 frame protection in firmware (`src/lora_transport.cpp`), in addition to LoRa PHY CRC.
- SCK: GPIO5 - Sender batches up to `30` samples and retransmits on missing ACK (`BATCH_MAX_RETRIES=2`, policy `Keep`).
- MISO: GPIO19 - Receiver handles AP fallback when STA config is missing/invalid and exposes a config/status web UI.
- 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
- I2C RTC (DS3231)
- SDA: GPIO21
- SCL: GPIO22
- I2C address: 0x68
- 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 ## LoRa Frame Protocol (Current)
- 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 Frame format on-air:
### Sender (battery-powered)
- Reads 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
- Reads battery voltage and estimates SoC.
- Builds JSON payload, compresses, wraps in LoRa packet, transmits.
- Light sleeps between meter reads; batches are sent every 30s.
- Listens for LoRa time sync packets to set UTC clock.
- Uses DS3231 RTC after boot if no time sync has arrived yet.
- OLED shows status + meter data pages.
**Sender flow (pseudo-code)**: `[msg_kind:1][device_short_id:2][payload...][crc16:2]`
```cpp
void sender_loop() {
meter_read_every_second(); // OBIS -> MeterData samples
read_battery(data); // VBAT + SoC
if (time_to_send_batch()) { `msg_kind`:
json = meterBatchToJson(samples, batch_id); // bat_v per batch, t_first/t_last included - `0` = `BatchUp`
compressed = compressBuffer(json); - `1` = `AckDown`
lora_send(packet(MeterBatch, compressed));
}
display_set_last_meter(data); ### `BatchUp`
display_set_last_read(ok);
display_set_last_tx(ok);
display_tick();
lora_receive_time_sync(); // optional `BatchUp` is chunked in transport (`batch_id`, `chunk_index`, `chunk_count`, `total_len`) and then decoded via `payload_codec`.
light_sleep_until_next_event();
}
```
**Key sender functions**: Payload header contains:
```cpp - fixed magic/schema fields (`kMagic=0xDDB3`, `kSchema=2`)
bool meter_read(MeterData &data); // parse OBIS fields - `schema_id`
void read_battery(MeterData &data); // ADC -> volts + percent - sender/batch/time/error metadata
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) Supported payload schemas in this branch:
- WiFi STA connect using stored config; if not available/fails, starts AP. - `schema_id=1` (`EnergyMulti`): integer kWh for up to 3 meters (`energy1_kwh`, `energy2_kwh`, `energy3_kwh`)
- NTP sync (UTC) and local display in Europe/Berlin. - `schema_id=0` (legacy): older energy/power delta encoding path remains decode-compatible
- Receives LoRa packets, verifies CRC16, decompresses, parses JSON.
- Publishes meter JSON to MQTT.
- Sends ACKs for MeterBatch packets and de-duplicates by batch_id.
- Web UI:
- AP mode: status + WiFi/MQTT config.
- STA mode: status + per-sender pages.
- OLED cycles through receiver status and per-sender pages (receiver OLED never sleeps).
**Receiver loop (pseudo-code)**: `n == 0` is used as sync request (no meter samples).
```cpp
void receiver_loop() {
if (lora_receive(pkt)) {
if (pkt.type == MeterData) {
json = decompressBuffer(pkt.payload);
if (jsonToMeterData(json, data)) {
update_sender_status(data);
mqtt_publish_state(data);
}
} else if (pkt.type == MeterBatch) {
json = reassemble_and_decompress_batch(pkt);
for (sample in jsonToMeterBatch(json)) { // uses t_first/t_last for jittered timestamps
update_sender_status(sample);
mqtt_publish_state(sample);
}
}
}
if (time_to_send_timesync()) { ### `AckDown` (7 bytes)
time_send_timesync(self_short_id); // 60s for first 10 min, then hourly if RTC is present
}
mqtt_loop(); `[flags:1][batch_id_be:2][epoch_utc_be:4]`
web_server_loop();
display_set_receiver_status(...);
display_tick();
}
```
**Key receiver functions**: - `flags bit0`: `time_valid`
```cpp - Receiver sends ACK repeatedly (`ACK_REPEAT_COUNT=3`, `ACK_REPEAT_DELAY_MS=200`).
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms); - Sender accepts time only if `time_valid=1` and `epoch >= MIN_ACCEPTED_EPOCH_UTC` (`2026-02-01 00:00:00 UTC`).
bool jsonToMeterData(const String &json, MeterData &data);
bool jsonToMeterBatch(const String &json, MeterData *samples, size_t max, size_t &count);
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) ## Time Bootstrap Guardrail
Enabled by `-DENABLE_TEST_MODE` (see `platformio.ini` test environment).
- Sender: sends 4-digit test code every ~30s in JSON. On sender boot:
- Receiver: shows last test code per sender and publishes to `/test` topic. - `g_time_acquired=false`
- Normal behavior is excluded from test builds. - only sync requests every `SYNC_REQUEST_INTERVAL_MS` (15s)
- no normal sampling/transmit until valid ACK time received
**Test sender (pseudo-code)**: This prevents publishing/storing pre-threshold timestamps.
```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)**: ## Multi-Meter Sender Behavior
```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 Implemented in `src/meter_driver.cpp` + sender path in `src/main.cpp`:
Packet layout:
``` - Meter protocol: IEC 62056-21 ASCII, Mode D style framing (`/ ... !`)
[0] protocol_version (1) - UART settings: `9600 7E1`
[1] role (0=sender, 1=receiver) - Parsed OBIS: `1-0:1.8.0`
[2..3] device_id_short (uint16) - Conversion: floor to integer kWh (`floorf`)
[4] payload_type (0=meter, 1=test, 2=time_sync, 3=meter_batch, 4=ack)
[5..N-3] compressed payload
[N-2..N-1] CRC16 (bytes 0..N-3)
```
LoRa radio settings: Meter count is build-dependent (`include/config.h`):
- Frequency: **433 MHz** or **868 MHz** (set by build env via `LORA_FREQUENCY_HZ`) - Debug builds (`SERIAL_DEBUG_MODE=1`): `METER_COUNT=2`
- SF10, BW 125 kHz, CR 4/5, CRC on, Sync Word 0x34 - Prod builds (`SERIAL_DEBUG_MODE=0`): `METER_COUNT=3`
## Data Format Default RX pins:
JSON payload (sender + MQTT): - Meter1: `GPIO34` (`Serial2`)
- Meter2: `GPIO25` (`Serial1`)
- Meter3: `GPIO3` (`Serial`, prod only because debug serial is disabled)
```json ## Receiver Behavior
{
"id": "F19C",
"ts": 1737200000,
"e_kwh": 1234.57,
"p_w": 950.00,
"p1_w": 500.00,
"p2_w": 450.00,
"p3_w": 0.00,
"bat_v": 3.92,
"bat_pct": 78
}
```
MeterBatch JSON (compressed over LoRa) uses per-field arrays with integer units for easier ingestion: For valid `BatchUp` decode:
1. Reassemble chunks and decode payload.
2. Send `AckDown` immediately.
3. Drop duplicate batches per sender (`batch_id` tracking).
4. If `n==0`: treat as sync request only.
5. Else convert to `MeterData`, log to SD, update web UI, publish MQTT.
```json ## MQTT Topics and Payloads
{
"schema": 1,
"sender": "s01",
"batch_id": 1842,
"t0": 1738288000,
"t_first": 1738288000,
"t_last": 1738288030,
"dt_s": 1,
"n": 3,
"e_wh": [123456700, 123456701, 123456701],
"p_w": [930, 940, 950],
"p1_w": [480, 490, 500],
"p2_w": [450, 450, 450],
"p3_w": [0, 0, 0],
"bat_v": 3.92,
"meta": {
"rssi": -92,
"snr": 7.5,
"rx_ts": 1738288031
}
}
```
Notes: State topic:
- `sender` maps to `EXPECTED_SENDER_IDS` order (`s01` = first sender). - `smartmeter/<device_id>/state`
- `meta` is injected by the receiver after batch reassembly.
- `bat_v` is a single batch-level value (percent is calculated locally).
## Device IDs Fault topic (retained):
- Derived from WiFi STA MAC. - `smartmeter/<device_id>/faults`
- `short_id = (MAC[4] << 8) | MAC[5]`
- `device_id = dd3-%04X`
- JSON `id` uses only the last 4 hex digits (e.g., `F19C`) to save airtime.
Receiver expects known senders in `include/config.h` via: For `EnergyMulti` samples, state JSON includes:
```cpp - `id`, `ts`
constexpr uint8_t NUM_SENDERS = 1; - `energy1_kwh`, `energy2_kwh`, optional `energy3_kwh`
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C }; - `bat_v`, `bat_pct`
``` - optional link fields: `rssi`, `snr`
- fault/reject fields: `err_last`, `rx_reject`, `rx_reject_text` (+ non-zero counters)
## OLED Behavior Home Assistant discovery publishing is enabled (`ENABLE_HA_DISCOVERY=true`) but still advertises legacy keys (`e_kwh`, `p_w`, `p1_w`, `p2_w`, `p3_w`) in `src/mqtt_client.cpp`.
- Sender: OLED stays **ON for 10 seconds** on each wake, then powers down for sleep.
- Receiver: OLED is always on (no auto-off).
- Pages rotate every 4s.
## Power & Battery ## Web UI, Wi-Fi, Storage
- 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 - STA config is stored in Preferences (`wifi_manager`).
- AP SSID: `DD3-Bridge-<short_id>` - If STA/MQTT config is unavailable, receiver starts AP mode with SSID prefix `DD3-Bridge-`.
- AP password: `changeme123` - Web auth defaults are `admin/admin` (`WEB_AUTH_DEFAULT_USER/PASS`).
- Endpoints: - SD logging is enabled (`ENABLE_SD_LOGGING=true`).
- `/`: 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.org` and `time.nist.gov`.
## RTC (DS3231)
- Optional DS3231 on the I2C bus. Connect SDA to GPIO21 and SCL to GPIO22 (same bus as the OLED).
- Enable/disable with `ENABLE_DS3231` in `include/config.h`.
- Receiver time sync packets set the RTC.
- On boot, if no LoRa time sync has arrived yet, the sender uses the RTC time as the initial `ts_utc`.
- When no RTC is present or enabled, the receiver keeps sending time sync every 60 seconds.
## Build Environments ## Build Environments
- `lilygo-t3-v1-6-1`: production build
- `lilygo-t3-v1-6-1-test`: test build with `ENABLE_TEST_MODE`
- `lilygo-t3-v1-6-1-868`: production build for 868 MHz modules
- `lilygo-t3-v1-6-1-868-test`: test build for 868 MHz modules
## Config Knobs From `platformio.ini`:
Key timing settings in `include/config.h`:
- `METER_SAMPLE_INTERVAL_MS`
- `METER_SEND_INTERVAL_MS`
- `BATCH_ACK_TIMEOUT_MS`
- `BATCH_MAX_RETRIES`
- `BATCH_QUEUE_DEPTH`
- `BATCH_RETRY_POLICY` (keep or drop on retry exhaustion)
- `SERIAL_DEBUG_MODE` / `SERIAL_DEBUG_DUMP_JSON`
- `LORA_SEND_BYPASS` (debug only)
## Limits & Known Constraints - `lilygo-t3-v1-6-1`
- **Compression**: uses lightweight RLE (good for JSON but not optimal). - `lilygo-t3-v1-6-1-test`
- **OBIS parsing**: supports IEC 62056-21 ASCII (Mode D); may need tuning for some meters. - `lilygo-t3-v1-6-1-868`
- **Payload size**: single JSON frames < 256 bytes (ArduinoJson static doc); batch frames are chunked and reassembled. - `lilygo-t3-v1-6-1-868-test`
- **Battery ADC**: uses simple linear calibration constant in `power_manager.cpp`. - `lilygo-t3-v1-6-1-payload-test`
- **OLED**: no hardware reset line is used (matches working reference). - `lilygo-t3-v1-6-1-868-payload-test`
- **Batch ACKs**: sender waits for ACK after a batch and retries up to `BATCH_MAX_RETRIES` with `BATCH_ACK_TIMEOUT_MS` between attempts. - `lilygo-t3-v1-6-1-prod`
- `lilygo-t3-v1-6-1-868-prod`
## Files & Modules Example:
- `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`: IEC 62056-21 ASCII 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 ```bash
pio run -e lilygo-t3-v1-6-1 -t upload --upload-port COMx ~/.platformio/penv/bin/pio run -e lilygo-t3-v1-6-1
``` ```
Test mode: ## Test Mode
```bash
pio run -e lilygo-t3-v1-6-1-test -t upload --upload-port COMx
```
868 MHz builds:
```bash
pio run -e lilygo-t3-v1-6-1-868 -t upload --upload-port COMx
```
868 MHz test mode:
```bash
pio run -e lilygo-t3-v1-6-1-868-test -t upload --upload-port COMx
```
`ENABLE_TEST_MODE` replaces normal sender/receiver loops with dedicated test loops (`src/test_mode.cpp`).
It sends/receives plain JSON test frames and publishes to `smartmeter/<device_id>/test`.

View File

@@ -1,6 +0,0 @@
#pragma once
#include <Arduino.h>
bool compressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len);
bool decompressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len);

View File

@@ -7,21 +7,11 @@ enum class DeviceRole : uint8_t {
Receiver = 1 Receiver = 1
}; };
enum class PayloadType : uint8_t {
MeterData = 0,
TestCode = 1,
TimeSync = 2,
MeterBatch = 3,
Ack = 4
};
enum class BatchRetryPolicy : uint8_t { enum class BatchRetryPolicy : uint8_t {
Keep = 0, Keep = 0,
Drop = 1 Drop = 1
}; };
constexpr uint8_t PROTOCOL_VERSION = 1;
// Pin definitions // Pin definitions
constexpr uint8_t PIN_LORA_SCK = 5; constexpr uint8_t PIN_LORA_SCK = 5;
constexpr uint8_t PIN_LORA_MISO = 19; constexpr uint8_t PIN_LORA_MISO = 19;
@@ -39,17 +29,19 @@ constexpr uint8_t OLED_HEIGHT = 64;
constexpr uint8_t PIN_BAT_ADC = 35; constexpr uint8_t PIN_BAT_ADC = 35;
constexpr uint8_t PIN_ROLE = 13; constexpr uint8_t PIN_ROLE = 14;
constexpr uint8_t PIN_OLED_CTRL = 14; constexpr uint8_t PIN_OLED_CTRL = 13;
constexpr uint8_t PIN_METER_RX = 34; constexpr uint8_t PIN_METER1_RX = 34; // UART2 RX
constexpr uint8_t PIN_METER2_RX = 25; // UART1 RX
constexpr uint8_t PIN_METER3_RX = 3; // UART0 RX (prod only, when serial debug is off)
// LoRa settings // LoRa settings
#ifndef LORA_FREQUENCY_HZ #ifndef LORA_FREQUENCY_HZ
#define LORA_FREQUENCY_HZ 433E6 #define LORA_FREQUENCY_HZ 433E6
#endif #endif
constexpr long LORA_FREQUENCY = LORA_FREQUENCY_HZ; constexpr long LORA_FREQUENCY = LORA_FREQUENCY_HZ;
constexpr uint8_t LORA_SPREADING_FACTOR = 10; constexpr uint8_t LORA_SPREADING_FACTOR = 12;
constexpr long LORA_BANDWIDTH = 125E3; constexpr long LORA_BANDWIDTH = 125E3;
constexpr uint8_t LORA_CODING_RATE = 5; constexpr uint8_t LORA_CODING_RATE = 5;
constexpr uint8_t LORA_SYNC_WORD = 0x34; constexpr uint8_t LORA_SYNC_WORD = 0x34;
@@ -57,27 +49,50 @@ constexpr uint8_t LORA_PREAMBLE_LEN = 8;
// Timing // Timing
constexpr uint32_t SENDER_WAKE_INTERVAL_SEC = 30; constexpr uint32_t SENDER_WAKE_INTERVAL_SEC = 30;
constexpr uint32_t TIME_SYNC_INTERVAL_SEC = 60; constexpr uint32_t SYNC_REQUEST_INTERVAL_MS = 15000;
constexpr uint32_t TIME_SYNC_SLOW_INTERVAL_SEC = 3600;
constexpr uint32_t TIME_SYNC_FAST_WINDOW_MS = 10UL * 60UL * 1000UL;
constexpr bool ENABLE_DS3231 = true;
constexpr uint32_t OLED_PAGE_INTERVAL_MS = 4000; constexpr uint32_t OLED_PAGE_INTERVAL_MS = 4000;
constexpr uint32_t OLED_AUTO_OFF_MS = 10UL * 60UL * 1000UL; constexpr uint32_t OLED_AUTO_OFF_MS = 10UL * 60UL * 1000UL;
constexpr uint32_t SENDER_OLED_READ_MS = 10000; constexpr uint32_t SENDER_OLED_READ_MS = 10000;
constexpr uint32_t METER_SAMPLE_INTERVAL_MS = 1000; constexpr uint32_t METER_SAMPLE_INTERVAL_MS = 1000;
constexpr uint32_t METER_SEND_INTERVAL_MS = 30000; constexpr uint32_t METER_SEND_INTERVAL_MS = 30000;
constexpr uint32_t BATTERY_SAMPLE_INTERVAL_MS = 60000;
constexpr float BATTERY_CAL = 1.083f;
constexpr uint32_t BATCH_ACK_TIMEOUT_MS = 3000; constexpr uint32_t BATCH_ACK_TIMEOUT_MS = 3000;
constexpr uint8_t ACK_REPEAT_COUNT = 3;
constexpr uint32_t ACK_REPEAT_DELAY_MS = 200;
constexpr uint8_t BATCH_MAX_RETRIES = 2; constexpr uint8_t BATCH_MAX_RETRIES = 2;
constexpr uint8_t METER_BATCH_MAX_SAMPLES = 30; constexpr uint8_t METER_BATCH_MAX_SAMPLES = 30;
constexpr uint8_t BATCH_QUEUE_DEPTH = 10; constexpr uint8_t BATCH_QUEUE_DEPTH = 10;
constexpr BatchRetryPolicy BATCH_RETRY_POLICY = BatchRetryPolicy::Keep; constexpr BatchRetryPolicy BATCH_RETRY_POLICY = BatchRetryPolicy::Keep;
constexpr uint32_t WATCHDOG_TIMEOUT_SEC = 120; constexpr uint32_t WATCHDOG_TIMEOUT_SEC = 120;
constexpr bool ENABLE_HA_DISCOVERY = true; constexpr bool ENABLE_HA_DISCOVERY = true;
constexpr bool SERIAL_DEBUG_MODE = false; #ifndef SERIAL_DEBUG_MODE_FLAG
#define SERIAL_DEBUG_MODE_FLAG 0
#endif
constexpr bool SERIAL_DEBUG_MODE = SERIAL_DEBUG_MODE_FLAG != 0;
constexpr uint8_t METER_COUNT_DEBUG = 2;
constexpr uint8_t METER_COUNT_PROD = 3;
constexpr uint8_t METER_COUNT = SERIAL_DEBUG_MODE ? METER_COUNT_DEBUG : METER_COUNT_PROD;
constexpr bool SERIAL_DEBUG_DUMP_JSON = false; constexpr bool SERIAL_DEBUG_DUMP_JSON = false;
constexpr bool LORA_SEND_BYPASS = false; constexpr bool LORA_SEND_BYPASS = false;
constexpr bool ENABLE_SD_LOGGING = true;
constexpr uint8_t PIN_SD_CS = 13;
constexpr uint8_t PIN_SD_MOSI = 15;
constexpr uint8_t PIN_SD_MISO = 2;
constexpr uint8_t PIN_SD_SCK = 14;
constexpr uint16_t SD_HISTORY_MAX_DAYS = 30;
constexpr uint16_t SD_HISTORY_MIN_RES_MIN = 1;
constexpr uint16_t SD_HISTORY_MAX_BINS = 4000;
constexpr uint16_t SD_HISTORY_TIME_BUDGET_MS = 10;
constexpr const char *AP_SSID_PREFIX = "DD3-Bridge-";
constexpr const char *AP_PASSWORD = "changeme123";
constexpr bool WEB_AUTH_REQUIRE_STA = true;
constexpr bool WEB_AUTH_REQUIRE_AP = false;
constexpr const char *WEB_AUTH_DEFAULT_USER = "admin";
constexpr const char *WEB_AUTH_DEFAULT_PASS = "admin";
constexpr uint8_t NUM_SENDERS = 1; constexpr uint8_t NUM_SENDERS = 1;
constexpr uint32_t MIN_ACCEPTED_EPOCH_UTC = 1769904000UL; // 2026-02-01 00:00:00 UTC
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = {
0xF19C //433mhz sender 0xF19C //433mhz sender
//0x7EB4 //868mhz sender //0x7EB4 //868mhz sender

View File

@@ -9,6 +9,15 @@ enum class FaultType : uint8_t {
LoraTx = 3 LoraTx = 3
}; };
enum class RxRejectReason : uint8_t {
None = 0,
CrcFail = 1,
InvalidMsgKind = 2,
LengthMismatch = 3,
DeviceIdMismatch = 4,
BatchIdMismatch = 5
};
struct FaultCounters { struct FaultCounters {
uint32_t meter_read_fail; uint32_t meter_read_fail;
uint32_t decode_fail; uint32_t decode_fail;
@@ -19,6 +28,9 @@ struct MeterData {
uint32_t ts_utc; uint32_t ts_utc;
uint16_t short_id; uint16_t short_id;
char device_id[16]; char device_id[16];
bool energy_multi;
uint8_t energy_meter_count;
uint32_t energy_kwh_int[3];
float energy_total_kwh; float energy_total_kwh;
float phase_power_w[3]; float phase_power_w[3];
float total_power_w; float total_power_w;
@@ -32,12 +44,15 @@ struct MeterData {
uint32_t err_decode; uint32_t err_decode;
uint32_t err_lora_tx; uint32_t err_lora_tx;
FaultType last_error; FaultType last_error;
uint8_t rx_reject_reason;
}; };
struct SenderStatus { struct SenderStatus {
MeterData last_data; MeterData last_data;
uint32_t last_update_ts_utc; uint32_t last_update_ts_utc;
uint16_t last_acked_batch_id;
bool has_data; bool has_data;
}; };
void init_device_ids(uint16_t &short_id, char *device_id, size_t device_id_len); void init_device_ids(uint16_t &short_id, char *device_id, size_t device_id_len);
const char *rx_reject_reason_text(RxRejectReason reason);

7
include/html_util.h Normal file
View File

@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
String html_escape(const String &input);
String url_encode_component(const String &input);
bool sanitize_device_id(const String &input, String &out_device_id);

View File

@@ -4,7 +4,3 @@
#include "data_model.h" #include "data_model.h"
bool meterDataToJson(const MeterData &data, String &out_json); bool meterDataToJson(const MeterData &data, String &out_json);
bool jsonToMeterData(const String &json, MeterData &data);
bool meterBatchToJson(const MeterData *samples, size_t count, uint16_t batch_id, String &out_json,
const FaultCounters *faults = nullptr, FaultType last_error = FaultType::None);
bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_count, size_t &out_count);

View File

@@ -2,14 +2,26 @@
#include <Arduino.h> #include <Arduino.h>
#include "config.h" #include "config.h"
#include "data_model.h"
constexpr size_t LORA_MAX_PAYLOAD = 230; constexpr size_t LORA_MAX_PAYLOAD = 230;
constexpr size_t LORA_FRAME_HEADER_LEN = 3; // msg_kind + dev_id_short
constexpr size_t LORA_FRAME_CRC_LEN = 2;
constexpr size_t LORA_ACK_DOWN_PAYLOAD_LEN = 7; // flags(1) + batch_id(2) + epoch_utc(4)
static_assert(LORA_ACK_DOWN_PAYLOAD_LEN == 7, "ACK_DOWN payload must remain 7 bytes");
constexpr size_t lora_frame_size(size_t payload_len) {
return LORA_FRAME_HEADER_LEN + payload_len + LORA_FRAME_CRC_LEN;
}
enum class LoraMsgKind : uint8_t {
BatchUp = 0,
AckDown = 1
};
struct LoraPacket { struct LoraPacket {
uint8_t protocol_version; LoraMsgKind msg_kind;
DeviceRole role;
uint16_t device_id_short; uint16_t device_id_short;
PayloadType payload_type;
uint8_t payload[LORA_MAX_PAYLOAD]; uint8_t payload[LORA_MAX_PAYLOAD];
size_t payload_len; size_t payload_len;
int16_t rssi_dbm; int16_t rssi_dbm;
@@ -19,5 +31,10 @@ struct LoraPacket {
void lora_init(); void lora_init();
bool lora_send(const LoraPacket &pkt); bool lora_send(const LoraPacket &pkt);
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms); bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms);
RxRejectReason lora_get_last_rx_reject_reason();
bool lora_get_last_rx_signal(int16_t &rssi_dbm, float &snr_db);
void lora_idle();
void lora_sleep(); void lora_sleep();
void lora_receive_continuous();
bool lora_receive_window(LoraPacket &pkt, uint32_t timeout_ms);
uint32_t lora_airtime_ms(size_t packet_len); uint32_t lora_airtime_ms(size_t packet_len);

View File

@@ -1,7 +1,8 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
#include "data_model.h"
void meter_init(); void meter_init();
bool meter_read(MeterData &data); void meter_poll();
uint8_t meter_count();
bool meter_get_last_energy_kwh(uint8_t meter_idx, uint32_t &out_energy_kwh);

View File

@@ -5,6 +5,7 @@
void power_sender_init(); void power_sender_init();
void power_receiver_init(); void power_receiver_init();
void power_configure_unused_pins_sender();
void read_battery(MeterData &data); void read_battery(MeterData &data);
uint8_t battery_percent_from_voltage(float voltage_v); uint8_t battery_percent_from_voltage(float voltage_v);
void light_sleep_ms(uint32_t ms); void light_sleep_ms(uint32_t ms);

View File

@@ -1,8 +0,0 @@
#pragma once
#include <Arduino.h>
bool rtc_ds3231_init();
bool rtc_ds3231_is_present();
bool rtc_ds3231_read_epoch(uint32_t &epoch_utc);
bool rtc_ds3231_set_epoch(uint32_t epoch_utc);

View File

@@ -1,17 +1,11 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
#include "lora_transport.h"
void time_receiver_init(const char *ntp_server_1, const char *ntp_server_2); void time_receiver_init(const char *ntp_server_1, const char *ntp_server_2);
uint32_t time_get_utc(); uint32_t time_get_utc();
bool time_is_synced(); bool time_is_synced();
void time_set_utc(uint32_t epoch); void time_set_utc(uint32_t epoch);
bool time_send_timesync(uint16_t device_id_short);
bool time_handle_timesync_payload(const uint8_t *payload, size_t len);
void time_get_local_hhmm(char *out, size_t out_len); void time_get_local_hhmm(char *out, size_t out_len);
void time_rtc_init();
bool time_try_load_from_rtc();
bool time_rtc_present();
uint32_t time_get_last_sync_utc(); uint32_t time_get_last_sync_utc();
uint32_t time_get_last_sync_age_sec(); uint32_t time_get_last_sync_age_sec();

View File

@@ -12,6 +12,8 @@ struct WifiMqttConfig {
String mqtt_pass; String mqtt_pass;
String ntp_server_1; String ntp_server_1;
String ntp_server_2; String ntp_server_2;
String web_user;
String web_pass;
bool valid; bool valid;
}; };

View File

@@ -18,6 +18,8 @@ lib_deps =
adafruit/Adafruit SSD1306@^2.5.9 adafruit/Adafruit SSD1306@^2.5.9
adafruit/Adafruit GFX Library@^1.11.9 adafruit/Adafruit GFX Library@^1.11.9
knolleary/PubSubClient@^2.8 knolleary/PubSubClient@^2.8
build_flags =
-DSERIAL_DEBUG_MODE_FLAG=1
[env:lilygo-t3-v1-6-1-test] [env:lilygo-t3-v1-6-1-test]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip
@@ -30,6 +32,7 @@ lib_deps =
adafruit/Adafruit GFX Library@^1.11.9 adafruit/Adafruit GFX Library@^1.11.9
knolleary/PubSubClient@^2.8 knolleary/PubSubClient@^2.8
build_flags = build_flags =
-DSERIAL_DEBUG_MODE_FLAG=1
-DENABLE_TEST_MODE -DENABLE_TEST_MODE
[env:lilygo-t3-v1-6-1-868] [env:lilygo-t3-v1-6-1-868]
@@ -43,6 +46,7 @@ lib_deps =
adafruit/Adafruit GFX Library@^1.11.9 adafruit/Adafruit GFX Library@^1.11.9
knolleary/PubSubClient@^2.8 knolleary/PubSubClient@^2.8
build_flags = build_flags =
-DSERIAL_DEBUG_MODE_FLAG=1
-DLORA_FREQUENCY_HZ=868E6 -DLORA_FREQUENCY_HZ=868E6
[env:lilygo-t3-v1-6-1-868-test] [env:lilygo-t3-v1-6-1-868-test]
@@ -56,5 +60,62 @@ lib_deps =
adafruit/Adafruit GFX Library@^1.11.9 adafruit/Adafruit GFX Library@^1.11.9
knolleary/PubSubClient@^2.8 knolleary/PubSubClient@^2.8
build_flags = build_flags =
-DSERIAL_DEBUG_MODE_FLAG=1
-DENABLE_TEST_MODE -DENABLE_TEST_MODE
-DLORA_FREQUENCY_HZ=868E6 -DLORA_FREQUENCY_HZ=868E6
[env:lilygo-t3-v1-6-1-payload-test]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip
board = ttgo-lora32-v1
framework = arduino
lib_deps =
sandeepmistry/LoRa@^0.8.0
bblanchon/ArduinoJson@^6.21.5
adafruit/Adafruit SSD1306@^2.5.9
adafruit/Adafruit GFX Library@^1.11.9
knolleary/PubSubClient@^2.8
build_flags =
-DSERIAL_DEBUG_MODE_FLAG=1
-DPAYLOAD_CODEC_TEST
[env:lilygo-t3-v1-6-1-868-payload-test]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip
board = ttgo-lora32-v1
framework = arduino
lib_deps =
sandeepmistry/LoRa@^0.8.0
bblanchon/ArduinoJson@^6.21.5
adafruit/Adafruit SSD1306@^2.5.9
adafruit/Adafruit GFX Library@^1.11.9
knolleary/PubSubClient@^2.8
build_flags =
-DSERIAL_DEBUG_MODE_FLAG=1
-DPAYLOAD_CODEC_TEST
-DLORA_FREQUENCY_HZ=868E6
[env:lilygo-t3-v1-6-1-prod]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip
board = ttgo-lora32-v1
framework = arduino
lib_deps =
sandeepmistry/LoRa@^0.8.0
bblanchon/ArduinoJson@^6.21.5
adafruit/Adafruit SSD1306@^2.5.9
adafruit/Adafruit GFX Library@^1.11.9
knolleary/PubSubClient@^2.8
build_flags =
-DSERIAL_DEBUG_MODE_FLAG=0
[env:lilygo-t3-v1-6-1-868-prod]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip
board = ttgo-lora32-v1
framework = arduino
lib_deps =
sandeepmistry/LoRa@^0.8.0
bblanchon/ArduinoJson@^6.21.5
adafruit/Adafruit SSD1306@^2.5.9
adafruit/Adafruit GFX Library@^1.11.9
knolleary/PubSubClient@^2.8
build_flags =
-DSERIAL_DEBUG_MODE_FLAG=0
-DLORA_FREQUENCY_HZ=868E6

View File

@@ -1,71 +0,0 @@
#include "compressor.h"
static constexpr uint8_t RLE_MARKER = 0xFF;
bool compressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len) {
out_len = 0;
if (!in || !out) {
return false;
}
size_t i = 0;
while (i < in_len) {
uint8_t value = in[i];
size_t run = 1;
while (i + run < in_len && in[i + run] == value && run < 255) {
run++;
}
if (value == RLE_MARKER || run >= 4) {
if (out_len + 3 > out_max) {
return false;
}
out[out_len++] = RLE_MARKER;
out[out_len++] = static_cast<uint8_t>(run);
out[out_len++] = value;
} else {
if (out_len + run > out_max) {
return false;
}
for (size_t j = 0; j < run; ++j) {
out[out_len++] = value;
}
}
i += run;
}
return true;
}
bool decompressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len) {
out_len = 0;
if (!in || !out) {
return false;
}
size_t i = 0;
while (i < in_len) {
uint8_t value = in[i++];
if (value == RLE_MARKER) {
if (i + 1 >= in_len) {
return false;
}
uint8_t run = in[i++];
uint8_t data = in[i++];
if (out_len + run > out_max) {
return false;
}
for (uint8_t j = 0; j < run; ++j) {
out[out_len++] = data;
}
} else {
if (out_len + 1 > out_max) {
return false;
}
out[out_len++] = value;
}
}
return true;
}

View File

@@ -2,5 +2,5 @@
DeviceRole detect_role() { DeviceRole detect_role() {
pinMode(PIN_ROLE, INPUT_PULLDOWN); pinMode(PIN_ROLE, INPUT_PULLDOWN);
return digitalRead(PIN_ROLE) == HIGH ? DeviceRole::Receiver : DeviceRole::Sender; return digitalRead(PIN_ROLE) == HIGH ? DeviceRole::Sender : DeviceRole::Receiver;
} }

View File

@@ -8,3 +8,20 @@ void init_device_ids(uint16_t &short_id, char *device_id, size_t device_id_len)
short_id = (static_cast<uint16_t>(mac[4]) << 8) | mac[5]; short_id = (static_cast<uint16_t>(mac[4]) << 8) | mac[5];
snprintf(device_id, device_id_len, "dd3-%04X", short_id); snprintf(device_id, device_id_len, "dd3-%04X", short_id);
} }
const char *rx_reject_reason_text(RxRejectReason reason) {
switch (reason) {
case RxRejectReason::CrcFail:
return "crc_fail";
case RxRejectReason::InvalidMsgKind:
return "invalid_msg_kind";
case RxRejectReason::LengthMismatch:
return "length_mismatch";
case RxRejectReason::DeviceIdMismatch:
return "device_id_mismatch";
case RxRejectReason::BatchIdMismatch:
return "batch_id_mismatch";
default:
return "none";
}
}

View File

@@ -4,6 +4,8 @@
#include <Wire.h> #include <Wire.h>
#include <Adafruit_GFX.h> #include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h> #include <Adafruit_SSD1306.h>
#include <limits.h>
#include <math.h>
#include <time.h> #include <time.h>
static Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, -1); static Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, -1);
@@ -36,10 +38,9 @@ static bool g_mqtt_ok = false;
static bool g_oled_on = true; static bool g_oled_on = true;
static bool g_prev_ctrl_high = false; static bool g_prev_ctrl_high = false;
static uint32_t g_oled_off_start = 0;
static uint32_t g_last_page_ms = 0; static uint32_t g_last_page_ms = 0;
static uint8_t g_page = 0; static uint8_t g_page = 0;
static uint32_t g_boot_ms = 0; static uint32_t g_last_activity_ms = 0;
static bool g_display_ready = false; static bool g_display_ready = false;
static uint32_t g_last_init_attempt_ms = 0; static uint32_t g_last_init_attempt_ms = 0;
static bool g_last_oled_on = true; static bool g_last_oled_on = true;
@@ -69,7 +70,9 @@ void display_power_down() {
} }
void display_init() { void display_init() {
if (g_role == DeviceRole::Sender) {
pinMode(PIN_OLED_CTRL, INPUT_PULLDOWN); pinMode(PIN_OLED_CTRL, INPUT_PULLDOWN);
}
Wire.begin(PIN_OLED_SDA, PIN_OLED_SCL); Wire.begin(PIN_OLED_SDA, PIN_OLED_SCL);
Wire.setClock(100000); Wire.setClock(100000);
g_display_ready = display.begin(SSD1306_SWITCHCAPVCC, OLED_I2C_ADDR); g_display_ready = display.begin(SSD1306_SWITCHCAPVCC, OLED_I2C_ADDR);
@@ -81,7 +84,7 @@ void display_init() {
display.display(); display.display();
} }
g_last_init_attempt_ms = millis(); g_last_init_attempt_ms = millis();
g_boot_ms = millis(); g_last_activity_ms = millis();
} }
void display_set_role(DeviceRole role) { void display_set_role(DeviceRole role) {
@@ -160,6 +163,20 @@ static uint32_t age_seconds(uint32_t ts_utc, uint32_t ts_ms) {
return (millis() - ts_ms) / 1000; return (millis() - ts_ms) / 1000;
} }
static int32_t round_power_w(float value) {
if (isnan(value)) {
return 0;
}
long rounded = lroundf(value);
if (rounded > INT32_MAX) {
return INT32_MAX;
}
if (rounded < INT32_MIN) {
return INT32_MIN;
}
return static_cast<int32_t>(rounded);
}
static bool render_last_error_line(uint8_t y) { static bool render_last_error_line(uint8_t y) {
if (g_last_error == FaultType::None) { if (g_last_error == FaultType::None) {
return false; return false;
@@ -235,15 +252,15 @@ static void render_sender_status() {
static void render_sender_measurement() { static void render_sender_measurement() {
display.clearDisplay(); display.clearDisplay();
display.setCursor(0, 0); display.setCursor(0, 0);
display.printf("E %.1f kWh", g_last_meter.energy_total_kwh); display.printf("E %.2f kWh", g_last_meter.energy_total_kwh);
display.setCursor(0, 12); display.setCursor(0, 12);
display.printf("P %.0fW", g_last_meter.total_power_w); display.printf("P %dW", static_cast<int>(round_power_w(g_last_meter.total_power_w)));
display.setCursor(0, 24); display.setCursor(0, 24);
display.printf("L1 %.0fW", g_last_meter.phase_power_w[0]); display.printf("L1 %dW", static_cast<int>(round_power_w(g_last_meter.phase_power_w[0])));
display.setCursor(0, 36); display.setCursor(0, 36);
display.printf("L2 %.0fW", g_last_meter.phase_power_w[1]); display.printf("L2 %dW", static_cast<int>(round_power_w(g_last_meter.phase_power_w[1])));
display.setCursor(0, 48); display.setCursor(0, 48);
display.printf("L3 %.0fW", g_last_meter.phase_power_w[2]); display.printf("L3 %dW", static_cast<int>(round_power_w(g_last_meter.phase_power_w[2])));
display.display(); display.display();
} }
@@ -268,16 +285,10 @@ static void render_receiver_status() {
display.printf("Time: %s", time_buf); display.printf("Time: %s", time_buf);
uint32_t latest = 0; uint32_t latest = 0;
bool link_valid = false;
int16_t link_rssi = 0;
float link_snr = 0.0f;
if (g_statuses) { if (g_statuses) {
for (uint8_t i = 0; i < g_status_count; ++i) { for (uint8_t i = 0; i < g_status_count; ++i) {
if (g_statuses[i].has_data && g_statuses[i].last_update_ts_utc > latest) { if (g_statuses[i].has_data && g_statuses[i].last_update_ts_utc > latest) {
latest = g_statuses[i].last_update_ts_utc; latest = g_statuses[i].last_update_ts_utc;
link_valid = g_statuses[i].last_data.link_valid;
link_rssi = g_statuses[i].last_data.link_rssi_dbm;
link_snr = g_statuses[i].last_data.link_snr_db;
} }
} }
} }
@@ -291,9 +302,6 @@ static void render_receiver_status() {
localtime_r(&t, &timeinfo); localtime_r(&t, &timeinfo);
display.printf("Upd %02d:%02d", timeinfo.tm_hour, timeinfo.tm_min); display.printf("Upd %02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
} }
if (link_valid) {
display.printf(" R:%d S:%.1f", link_rssi, link_snr);
}
render_last_error_line(56); render_last_error_line(56);
display.display(); display.display();
@@ -342,15 +350,37 @@ static void render_receiver_sender(uint8_t index) {
#endif #endif
display.setCursor(0, 12); display.setCursor(0, 12);
display.printf("E %.1f kWh", status.last_data.energy_total_kwh); if (status.last_data.energy_multi) {
display.setCursor(0, 24); display.printf("E1 %lu E2 %lu", static_cast<unsigned long>(status.last_data.energy_kwh_int[0]),
display.printf("P %.0fW", status.last_data.total_power_w); static_cast<unsigned long>(status.last_data.energy_kwh_int[1]));
display.setCursor(0, 36); } else {
display.printf("L1 %.0fW", status.last_data.phase_power_w[0]); display.printf("E %.2f kWh", status.last_data.energy_total_kwh);
display.setCursor(0, 48); }
display.printf("L2 %.0fW", status.last_data.phase_power_w[1]); display.setCursor(0, 22);
display.setCursor(0, 56); if (status.last_data.energy_multi && status.last_data.energy_meter_count >= 3) {
display.printf("L3 %.0fW", status.last_data.phase_power_w[2]); display.printf("E3 %lu", static_cast<unsigned long>(status.last_data.energy_kwh_int[2]));
} else {
display.printf("L1 %dW", static_cast<int>(round_power_w(status.last_data.phase_power_w[0])));
}
display.setCursor(0, 32);
display.printf("L2 %dW", static_cast<int>(round_power_w(status.last_data.phase_power_w[1])));
display.setCursor(0, 42);
display.printf("L3 %dW", static_cast<int>(round_power_w(status.last_data.phase_power_w[2])));
display.setCursor(0, 52);
display.print("P");
char p_buf[16];
snprintf(p_buf, sizeof(p_buf), "%dW", static_cast<int>(round_power_w(status.last_data.total_power_w)));
int16_t x1 = 0;
int16_t y1 = 0;
uint16_t w = 0;
uint16_t h = 0;
display.getTextBounds(p_buf, 0, 0, &x1, &y1, &w, &h);
int16_t x = static_cast<int16_t>(display.width() - w);
if (x < 0) {
x = 0;
}
display.setCursor(x, 52);
display.print(p_buf);
display.display(); display.display();
} }
@@ -369,29 +399,21 @@ void display_tick() {
} }
return; return;
} }
bool ctrl_high = digitalRead(PIN_OLED_CTRL) == HIGH; bool ctrl_high = false;
if (g_role == DeviceRole::Sender) {
ctrl_high = digitalRead(PIN_OLED_CTRL) == HIGH;
}
bool in_boot_window = (millis() - g_boot_ms) < OLED_AUTO_OFF_MS; uint32_t now_ms = millis();
bool ctrl_falling_edge = g_prev_ctrl_high && !ctrl_high;
if (g_role == DeviceRole::Receiver) { if (g_role == DeviceRole::Receiver) {
g_oled_on = true; g_oled_on = true;
g_oled_off_start = 0; g_last_activity_ms = now_ms;
} else if (in_boot_window) {
g_oled_on = true;
} else { } else {
if (ctrl_high) { if (ctrl_high || ctrl_falling_edge) {
g_oled_on = true; g_last_activity_ms = now_ms;
g_oled_off_start = 0;
} else if (g_prev_ctrl_high && !ctrl_high) {
g_oled_off_start = millis();
} else if (!g_prev_ctrl_high && !ctrl_high && g_oled_off_start == 0) {
g_oled_off_start = millis();
} }
g_oled_on = (now_ms - g_last_activity_ms) < OLED_AUTO_OFF_MS;
if (!ctrl_high && g_oled_off_start > 0 && millis() - g_oled_off_start > OLED_AUTO_OFF_MS) {
g_oled_on = false;
}
// fall through to power gating below
} }
if (g_oled_on) { if (g_oled_on) {

98
src/html_util.cpp Normal file
View File

@@ -0,0 +1,98 @@
#include "html_util.h"
String html_escape(const String &input) {
String out;
out.reserve(input.length() + 8);
for (size_t i = 0; i < input.length(); ++i) {
char c = input[i];
switch (c) {
case '&':
out += "&amp;";
break;
case '<':
out += "&lt;";
break;
case '>':
out += "&gt;";
break;
case '"':
out += "&quot;";
break;
case '\'':
out += "&#39;";
break;
default:
out += c;
break;
}
}
return out;
}
String url_encode_component(const String &input) {
String out;
out.reserve(input.length() * 3);
const char *hex = "0123456789ABCDEF";
for (size_t i = 0; i < input.length(); ++i) {
unsigned char c = static_cast<unsigned char>(input[i]);
bool safe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~';
if (safe) {
out += static_cast<char>(c);
} else {
out += '%';
out += hex[(c >> 4) & 0x0F];
out += hex[c & 0x0F];
}
}
return out;
}
static bool is_hex_char(char c) {
return (c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F');
}
static String to_upper_hex4(const String &input) {
String out = input;
out.toUpperCase();
return out;
}
bool sanitize_device_id(const String &input, String &out_device_id) {
String trimmed = input;
trimmed.trim();
if (trimmed.length() == 0) {
return false;
}
if (trimmed.indexOf('/') >= 0 || trimmed.indexOf('\\') >= 0 || trimmed.indexOf("..") >= 0) {
return false;
}
if (trimmed.indexOf('%') >= 0) {
return false;
}
if (trimmed.length() == 4) {
for (size_t i = 0; i < 4; ++i) {
if (!is_hex_char(trimmed[i])) {
return false;
}
}
out_device_id = String("dd3-") + to_upper_hex4(trimmed);
return true;
}
if (trimmed.length() == 8 && trimmed.startsWith("dd3-")) {
String hex = trimmed.substring(4);
for (size_t i = 0; i < 4; ++i) {
if (!is_hex_char(hex[i])) {
return false;
}
}
out_device_id = String("dd3-") + to_upper_hex4(hex);
return true;
}
return false;
}

View File

@@ -2,8 +2,6 @@
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <limits.h> #include <limits.h>
#include <math.h> #include <math.h>
#include "config.h"
#include "power_manager.h"
static float round2(float value) { static float round2(float value) {
if (isnan(value)) { if (isnan(value)) {
@@ -12,20 +10,6 @@ static float round2(float value) {
return roundf(value * 100.0f) / 100.0f; return roundf(value * 100.0f) / 100.0f;
} }
static uint32_t kwh_to_wh(float value) {
if (isnan(value)) {
return 0;
}
double wh = static_cast<double>(value) * 1000.0;
if (wh < 0.0) {
wh = 0.0;
}
if (wh > static_cast<double>(UINT32_MAX)) {
wh = static_cast<double>(UINT32_MAX);
}
return static_cast<uint32_t>(llround(wh));
}
static int32_t round_to_i32(float value) { static int32_t round_to_i32(float value) {
if (isnan(value)) { if (isnan(value)) {
return 0; return 0;
@@ -51,31 +35,6 @@ static const char *short_id_from_device_id(const char *device_id) {
return device_id; return device_id;
} }
static void sender_label_from_short_id(uint16_t short_id, char *out, size_t out_len) {
if (!out || out_len == 0) {
return;
}
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (EXPECTED_SENDER_IDS[i] == short_id) {
snprintf(out, out_len, "s%02u", static_cast<unsigned>(i + 1));
return;
}
}
snprintf(out, out_len, "s00");
}
static uint16_t short_id_from_sender_label(const char *sender_label) {
if (!sender_label || strlen(sender_label) < 2 || sender_label[0] != 's') {
return 0;
}
char *end = nullptr;
long idx = strtol(sender_label + 1, &end, 10);
if (end == sender_label + 1 || idx <= 0 || idx > NUM_SENDERS) {
return 0;
}
return EXPECTED_SENDER_IDS[idx - 1];
}
static void format_float_2(char *buf, size_t buf_len, float value) { static void format_float_2(char *buf, size_t buf_len, float value) {
if (!buf || buf_len == 0) { if (!buf || buf_len == 0) {
return; return;
@@ -87,21 +46,36 @@ static void format_float_2(char *buf, size_t buf_len, float value) {
snprintf(buf, buf_len, "%.2f", round2(value)); snprintf(buf, buf_len, "%.2f", round2(value));
} }
static void set_int_or_null(JsonDocument &doc, const char *key, float value) {
if (!key || key[0] == '\0') {
return;
}
if (isnan(value)) {
doc[key] = nullptr;
return;
}
doc[key] = round_to_i32(value);
}
bool meterDataToJson(const MeterData &data, String &out_json) { bool meterDataToJson(const MeterData &data, String &out_json) {
StaticJsonDocument<256> doc; StaticJsonDocument<320> doc;
doc["id"] = short_id_from_device_id(data.device_id); doc["id"] = short_id_from_device_id(data.device_id);
doc["ts"] = data.ts_utc; doc["ts"] = data.ts_utc;
char buf[16]; char buf[16];
if (data.energy_multi) {
doc["energy1_kwh"] = data.energy_kwh_int[0];
doc["energy2_kwh"] = data.energy_kwh_int[1];
if (data.energy_meter_count >= 3) {
doc["energy3_kwh"] = data.energy_kwh_int[2];
}
} else {
format_float_2(buf, sizeof(buf), data.energy_total_kwh); format_float_2(buf, sizeof(buf), data.energy_total_kwh);
doc["e_kwh"] = serialized(buf); doc["e_kwh"] = serialized(buf);
format_float_2(buf, sizeof(buf), data.total_power_w); set_int_or_null(doc, "p_w", data.total_power_w);
doc["p_w"] = serialized(buf); set_int_or_null(doc, "p1_w", data.phase_power_w[0]);
format_float_2(buf, sizeof(buf), data.phase_power_w[0]); set_int_or_null(doc, "p2_w", data.phase_power_w[1]);
doc["p1_w"] = serialized(buf); set_int_or_null(doc, "p3_w", data.phase_power_w[2]);
format_float_2(buf, sizeof(buf), data.phase_power_w[1]); }
doc["p2_w"] = serialized(buf);
format_float_2(buf, sizeof(buf), data.phase_power_w[2]);
doc["p3_w"] = serialized(buf);
format_float_2(buf, sizeof(buf), data.battery_voltage_v); format_float_2(buf, sizeof(buf), data.battery_voltage_v);
doc["bat_v"] = serialized(buf); doc["bat_v"] = serialized(buf);
doc["bat_pct"] = data.battery_percent; doc["bat_pct"] = data.battery_percent;
@@ -118,202 +92,11 @@ bool meterDataToJson(const MeterData &data, String &out_json) {
if (data.err_lora_tx > 0) { if (data.err_lora_tx > 0) {
doc["err_tx"] = data.err_lora_tx; doc["err_tx"] = data.err_lora_tx;
} }
if (data.last_error != FaultType::None) {
doc["err_last"] = static_cast<uint8_t>(data.last_error); doc["err_last"] = static_cast<uint8_t>(data.last_error);
} doc["rx_reject"] = data.rx_reject_reason;
doc["rx_reject_text"] = rx_reject_reason_text(static_cast<RxRejectReason>(data.rx_reject_reason));
out_json = ""; out_json = "";
size_t len = serializeJson(doc, out_json); size_t len = serializeJson(doc, out_json);
return len > 0 && len < 256; return len > 0 && len < 320;
}
bool jsonToMeterData(const String &json, MeterData &data) {
StaticJsonDocument<256> doc;
DeserializationError err = deserializeJson(doc, json);
if (err) {
return false;
}
const char *id = doc["id"] | "";
if (strlen(id) == 4) {
snprintf(data.device_id, sizeof(data.device_id), "dd3-%s", id);
} else {
strncpy(data.device_id, id, sizeof(data.device_id));
}
data.device_id[sizeof(data.device_id) - 1] = '\0';
data.ts_utc = doc["ts"] | 0;
data.energy_total_kwh = doc["e_kwh"] | NAN;
data.total_power_w = doc["p_w"] | NAN;
data.phase_power_w[0] = doc["p1_w"] | NAN;
data.phase_power_w[1] = doc["p2_w"] | NAN;
data.phase_power_w[2] = doc["p3_w"] | NAN;
data.battery_voltage_v = doc["bat_v"] | NAN;
if (doc["bat_pct"].isNull() && !isnan(data.battery_voltage_v)) {
data.battery_percent = battery_percent_from_voltage(data.battery_voltage_v);
} else {
data.battery_percent = doc["bat_pct"] | 0;
}
data.valid = true;
data.link_valid = false;
data.link_rssi_dbm = 0;
data.link_snr_db = NAN;
data.err_meter_read = doc["err_m"] | 0;
data.err_decode = doc["err_d"] | 0;
data.err_lora_tx = doc["err_tx"] | 0;
data.last_error = static_cast<FaultType>(doc["err_last"] | 0);
if (strlen(data.device_id) >= 8) {
const char *suffix = data.device_id + strlen(data.device_id) - 4;
data.short_id = static_cast<uint16_t>(strtoul(suffix, nullptr, 16));
}
return true;
}
bool meterBatchToJson(const MeterData *samples, size_t count, uint16_t batch_id, String &out_json, const FaultCounters *faults, FaultType last_error) {
if (!samples || count == 0) {
return false;
}
DynamicJsonDocument doc(8192);
doc["schema"] = 1;
char sender_label[8] = {};
sender_label_from_short_id(samples[count - 1].short_id, sender_label, sizeof(sender_label));
doc["sender"] = sender_label;
doc["batch_id"] = batch_id;
doc["t0"] = samples[0].ts_utc;
doc["t_first"] = samples[0].ts_utc;
doc["t_last"] = samples[count - 1].ts_utc;
uint32_t dt_s = METER_SAMPLE_INTERVAL_MS / 1000;
doc["dt_s"] = dt_s > 0 ? dt_s : 1;
doc["n"] = static_cast<uint32_t>(count);
if (faults) {
if (faults->meter_read_fail > 0) {
doc["err_m"] = faults->meter_read_fail;
}
if (faults->lora_tx_fail > 0) {
doc["err_tx"] = faults->lora_tx_fail;
}
}
if (last_error != FaultType::None) {
doc["err_last"] = static_cast<uint8_t>(last_error);
}
if (!isnan(samples[count - 1].battery_voltage_v)) {
char bat_buf[16];
format_float_2(bat_buf, sizeof(bat_buf), samples[count - 1].battery_voltage_v);
doc["bat_v"] = serialized(bat_buf);
}
JsonArray energy = doc.createNestedArray("e_wh");
JsonArray p_w = doc.createNestedArray("p_w");
JsonArray p1_w = doc.createNestedArray("p1_w");
JsonArray p2_w = doc.createNestedArray("p2_w");
JsonArray p3_w = doc.createNestedArray("p3_w");
for (size_t i = 0; i < count; ++i) {
energy.add(kwh_to_wh(samples[i].energy_total_kwh));
p_w.add(round_to_i32(samples[i].total_power_w));
p1_w.add(round_to_i32(samples[i].phase_power_w[0]));
p2_w.add(round_to_i32(samples[i].phase_power_w[1]));
p3_w.add(round_to_i32(samples[i].phase_power_w[2]));
}
out_json = "";
size_t len = serializeJson(doc, out_json);
return len > 0;
}
bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_count, size_t &out_count) {
out_count = 0;
if (!out_samples || max_count == 0) {
return false;
}
DynamicJsonDocument doc(8192);
DeserializationError err = deserializeJson(doc, json);
if (err) {
return false;
}
const char *id = doc["id"] | "";
const char *sender = doc["sender"] | "";
uint32_t err_m = doc["err_m"] | 0;
uint32_t err_tx = doc["err_tx"] | 0;
FaultType last_error = static_cast<FaultType>(doc["err_last"] | 0);
float bat_v = doc["bat_v"] | NAN;
if (!doc["schema"].isNull()) {
if ((doc["schema"] | 0) != 1) {
return false;
}
size_t count = doc["n"] | 0;
if (count == 0) {
return false;
}
if (count > max_count) {
count = max_count;
}
uint32_t t0 = doc["t0"] | 0;
uint32_t t_first = doc["t_first"] | t0;
uint32_t t_last = doc["t_last"] | t_first;
uint32_t dt_s = doc["dt_s"] | 1;
JsonArray energy = doc["e_wh"].as<JsonArray>();
JsonArray p_w = doc["p_w"].as<JsonArray>();
JsonArray p1_w = doc["p1_w"].as<JsonArray>();
JsonArray p2_w = doc["p2_w"].as<JsonArray>();
JsonArray p3_w = doc["p3_w"].as<JsonArray>();
for (size_t idx = 0; idx < count; ++idx) {
MeterData &data = out_samples[idx];
data = {};
uint16_t short_id = short_id_from_sender_label(sender);
if (short_id != 0) {
snprintf(data.device_id, sizeof(data.device_id), "dd3-%04X", short_id);
data.short_id = short_id;
} else if (id[0] != '\0') {
strncpy(data.device_id, id, sizeof(data.device_id));
data.device_id[sizeof(data.device_id) - 1] = '\0';
} else {
snprintf(data.device_id, sizeof(data.device_id), "dd3-0000");
}
if (count > 1 && t_last >= t_first) {
uint32_t span = t_last - t_first;
uint32_t step = span / static_cast<uint32_t>(count - 1);
data.ts_utc = t_first + static_cast<uint32_t>(idx) * step;
} else {
data.ts_utc = t0 + static_cast<uint32_t>(idx) * dt_s;
}
data.energy_total_kwh = static_cast<float>((energy[idx] | 0)) / 1000.0f;
data.total_power_w = static_cast<float>(p_w[idx] | 0);
data.phase_power_w[0] = static_cast<float>(p1_w[idx] | 0);
data.phase_power_w[1] = static_cast<float>(p2_w[idx] | 0);
data.phase_power_w[2] = static_cast<float>(p3_w[idx] | 0);
data.battery_voltage_v = bat_v;
if (!isnan(bat_v)) {
data.battery_percent = battery_percent_from_voltage(bat_v);
} else {
data.battery_percent = 0;
}
data.valid = true;
data.link_valid = false;
data.link_rssi_dbm = 0;
data.link_snr_db = NAN;
data.err_meter_read = err_m;
data.err_decode = 0;
data.err_lora_tx = err_tx;
data.last_error = last_error;
if (data.short_id == 0 && strlen(data.device_id) >= 8) {
const char *suffix = data.device_id + strlen(data.device_id) - 4;
data.short_id = static_cast<uint16_t>(strtoul(suffix, nullptr, 16));
}
}
out_count = count;
return count > 0;
}
return false;
} }

View File

@@ -3,6 +3,38 @@
#include <SPI.h> #include <SPI.h>
#include <math.h> #include <math.h>
static RxRejectReason g_last_rx_reject_reason = RxRejectReason::None;
static uint32_t g_last_rx_reject_log_ms = 0;
static bool g_last_rx_signal_valid = false;
static int16_t g_last_rx_rssi_dbm = 0;
static float g_last_rx_snr_db = 0.0f;
static void note_reject(RxRejectReason reason) {
g_last_rx_reject_reason = reason;
if (SERIAL_DEBUG_MODE) {
uint32_t now_ms = millis();
if (now_ms - g_last_rx_reject_log_ms >= 1000) {
g_last_rx_reject_log_ms = now_ms;
Serial.printf("lora_rx: reject reason=%s\n", rx_reject_reason_text(reason));
}
}
}
RxRejectReason lora_get_last_rx_reject_reason() {
RxRejectReason reason = g_last_rx_reject_reason;
g_last_rx_reject_reason = RxRejectReason::None;
return reason;
}
bool lora_get_last_rx_signal(int16_t &rssi_dbm, float &snr_db) {
if (!g_last_rx_signal_valid) {
return false;
}
rssi_dbm = g_last_rx_rssi_dbm;
snr_db = g_last_rx_snr_db;
return true;
}
static uint16_t crc16_ccitt(const uint8_t *data, size_t len) { static uint16_t crc16_ccitt(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF; uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; ++i) { for (size_t i = 0; i < len; ++i) {
@@ -33,13 +65,23 @@ bool lora_send(const LoraPacket &pkt) {
if (LORA_SEND_BYPASS) { if (LORA_SEND_BYPASS) {
return true; return true;
} }
uint8_t buffer[5 + LORA_MAX_PAYLOAD + 2]; uint32_t t0 = 0;
uint32_t t1 = 0;
uint32_t t2 = 0;
uint32_t t3 = 0;
uint32_t t4 = 0;
if (SERIAL_DEBUG_MODE) {
t0 = millis();
}
LoRa.idle();
if (SERIAL_DEBUG_MODE) {
t1 = millis();
}
uint8_t buffer[1 + 2 + LORA_MAX_PAYLOAD + 2];
size_t idx = 0; size_t idx = 0;
buffer[idx++] = pkt.protocol_version; buffer[idx++] = static_cast<uint8_t>(pkt.msg_kind);
buffer[idx++] = static_cast<uint8_t>(pkt.role);
buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short >> 8); buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short >> 8);
buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short & 0xFF); buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short & 0xFF);
buffer[idx++] = static_cast<uint8_t>(pkt.payload_type);
if (pkt.payload_len > LORA_MAX_PAYLOAD) { if (pkt.payload_len > LORA_MAX_PAYLOAD) {
return false; return false;
@@ -53,8 +95,24 @@ bool lora_send(const LoraPacket &pkt) {
buffer[idx++] = static_cast<uint8_t>(crc & 0xFF); buffer[idx++] = static_cast<uint8_t>(crc & 0xFF);
LoRa.beginPacket(); LoRa.beginPacket();
if (SERIAL_DEBUG_MODE) {
t2 = millis();
}
LoRa.write(buffer, idx); LoRa.write(buffer, idx);
int result = LoRa.endPacket(); if (SERIAL_DEBUG_MODE) {
t3 = millis();
}
int result = LoRa.endPacket(false);
if (SERIAL_DEBUG_MODE) {
t4 = millis();
Serial.printf("lora_tx: idle=%lums begin=%lums write=%lums end=%lums total=%lums len=%u\n",
static_cast<unsigned long>(t1 - t0),
static_cast<unsigned long>(t2 - t1),
static_cast<unsigned long>(t3 - t2),
static_cast<unsigned long>(t4 - t3),
static_cast<unsigned long>(t4 - t0),
static_cast<unsigned>(idx));
}
return result == 1; return result == 1;
} }
@@ -63,40 +121,61 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
while (true) { while (true) {
int packet_size = LoRa.parsePacket(); int packet_size = LoRa.parsePacket();
if (packet_size > 0) { if (packet_size > 0) {
if (packet_size < 7) { g_last_rx_rssi_dbm = static_cast<int16_t>(LoRa.packetRssi());
g_last_rx_snr_db = LoRa.packetSnr();
g_last_rx_signal_valid = true;
if (packet_size < 5) {
while (LoRa.available()) { while (LoRa.available()) {
LoRa.read(); LoRa.read();
} }
note_reject(RxRejectReason::LengthMismatch);
return false; return false;
} }
uint8_t buffer[5 + LORA_MAX_PAYLOAD + 2]; uint8_t buffer[1 + 2 + LORA_MAX_PAYLOAD + 2];
size_t len = 0; size_t len = 0;
while (LoRa.available() && len < sizeof(buffer)) { while (LoRa.available() && len < sizeof(buffer)) {
buffer[len++] = LoRa.read(); buffer[len++] = LoRa.read();
} }
if (LoRa.available()) {
while (LoRa.available()) {
LoRa.read();
}
if (SERIAL_DEBUG_MODE) {
Serial.println("rx_reject: oversize packet drained");
}
note_reject(RxRejectReason::LengthMismatch);
return false;
}
if (len < 7) { if (len < 5) {
note_reject(RxRejectReason::LengthMismatch);
return false; return false;
} }
uint16_t crc_calc = crc16_ccitt(buffer, len - 2); uint16_t crc_calc = crc16_ccitt(buffer, len - 2);
uint16_t crc_rx = static_cast<uint16_t>(buffer[len - 2] << 8) | buffer[len - 1]; uint16_t crc_rx = static_cast<uint16_t>(buffer[len - 2] << 8) | buffer[len - 1];
if (crc_calc != crc_rx) { if (crc_calc != crc_rx) {
note_reject(RxRejectReason::CrcFail);
return false;
}
uint8_t msg_kind = buffer[0];
if (msg_kind > static_cast<uint8_t>(LoraMsgKind::AckDown)) {
note_reject(RxRejectReason::InvalidMsgKind);
return false; return false;
} }
pkt.protocol_version = buffer[0]; pkt.msg_kind = static_cast<LoraMsgKind>(msg_kind);
pkt.role = static_cast<DeviceRole>(buffer[1]); pkt.device_id_short = static_cast<uint16_t>(buffer[1] << 8) | buffer[2];
pkt.device_id_short = static_cast<uint16_t>(buffer[2] << 8) | buffer[3]; pkt.payload_len = len - 5;
pkt.payload_type = static_cast<PayloadType>(buffer[4]);
pkt.payload_len = len - 7;
if (pkt.payload_len > LORA_MAX_PAYLOAD) { if (pkt.payload_len > LORA_MAX_PAYLOAD) {
note_reject(RxRejectReason::LengthMismatch);
return false; return false;
} }
memcpy(pkt.payload, &buffer[5], pkt.payload_len); memcpy(pkt.payload, &buffer[3], pkt.payload_len);
pkt.rssi_dbm = static_cast<int16_t>(LoRa.packetRssi()); pkt.rssi_dbm = g_last_rx_rssi_dbm;
pkt.snr_db = LoRa.packetSnr(); pkt.snr_db = g_last_rx_snr_db;
return true; return true;
} }
@@ -107,10 +186,31 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
} }
} }
void lora_idle() {
LoRa.idle();
}
void lora_sleep() { void lora_sleep() {
LoRa.sleep(); LoRa.sleep();
} }
void lora_receive_continuous() {
LoRa.receive();
}
bool lora_receive_window(LoraPacket &pkt, uint32_t timeout_ms) {
if (timeout_ms == 0) {
return false;
}
g_last_rx_signal_valid = false;
g_last_rx_rssi_dbm = 0;
g_last_rx_snr_db = 0.0f;
LoRa.receive();
bool got = lora_receive(pkt, timeout_ms);
LoRa.sleep();
return got;
}
uint32_t lora_airtime_ms(size_t packet_len) { uint32_t lora_airtime_ms(size_t packet_len) {
if (packet_len == 0) { if (packet_len == 0) {
return 0; return 0;

View File

@@ -1,8 +1,7 @@
#include <Arduino.h> #include <Arduino.h>
#include "config.h" #include "config.h"
#include "data_model.h" #include "data_model.h"
#include "json_codec.h" #include "payload_codec.h"
#include "compressor.h"
#include "lora_transport.h" #include "lora_transport.h"
#include "meter_driver.h" #include "meter_driver.h"
#include "power_manager.h" #include "power_manager.h"
@@ -12,8 +11,9 @@
#include "web_server.h" #include "web_server.h"
#include "display_ui.h" #include "display_ui.h"
#include "test_mode.h" #include "test_mode.h"
#include <ArduinoJson.h> #include "sd_logger.h"
#include <stdarg.h> #include <stdarg.h>
#include <math.h>
#ifdef ARDUINO_ARCH_ESP32 #ifdef ARDUINO_ARCH_ESP32
#include <esp_task_wdt.h> #include <esp_task_wdt.h>
#include <esp_system.h> #include <esp_system.h>
@@ -26,8 +26,6 @@ static char g_device_id[16] = "";
static SenderStatus g_sender_statuses[NUM_SENDERS]; static SenderStatus g_sender_statuses[NUM_SENDERS];
static bool g_ap_mode = false; static bool g_ap_mode = false;
static WifiMqttConfig g_cfg; static WifiMqttConfig g_cfg;
static uint32_t g_last_timesync_ms = 0;
static constexpr uint32_t TIME_SYNC_OFFSET_MS = 15000;
static uint32_t g_boot_ms = 0; static uint32_t g_boot_ms = 0;
static FaultCounters g_sender_faults = {}; static FaultCounters g_sender_faults = {};
static FaultCounters g_receiver_faults = {}; static FaultCounters g_receiver_faults = {};
@@ -51,14 +49,16 @@ static bool g_receiver_discovery_sent = false;
static constexpr size_t BATCH_HEADER_SIZE = 6; static constexpr size_t BATCH_HEADER_SIZE = 6;
static constexpr size_t BATCH_CHUNK_PAYLOAD = LORA_MAX_PAYLOAD - BATCH_HEADER_SIZE; static constexpr size_t BATCH_CHUNK_PAYLOAD = LORA_MAX_PAYLOAD - BATCH_HEADER_SIZE;
static constexpr size_t BATCH_MAX_COMPRESSED = 4096; static constexpr size_t BATCH_MAX_COMPRESSED = 4096;
static constexpr size_t BATCH_MAX_DECOMPRESSED = 8192;
static constexpr uint32_t BATCH_RX_MARGIN_MS = 800; static constexpr uint32_t BATCH_RX_MARGIN_MS = 800;
struct BatchBuffer { struct BatchBuffer {
uint16_t batch_id; uint16_t batch_id;
bool batch_id_valid; bool batch_id_valid;
uint8_t count; uint8_t count;
MeterData samples[METER_BATCH_MAX_SAMPLES]; struct EnergySample {
uint32_t ts_utc;
uint32_t energy_kwh[3];
} samples[METER_BATCH_MAX_SAMPLES];
}; };
static BatchBuffer g_batch_queue[BATCH_QUEUE_DEPTH]; static BatchBuffer g_batch_queue[BATCH_QUEUE_DEPTH];
@@ -66,7 +66,7 @@ static uint8_t g_batch_head = 0;
static uint8_t g_batch_tail = 0; static uint8_t g_batch_tail = 0;
static uint8_t g_batch_count = 0; static uint8_t g_batch_count = 0;
static MeterData g_build_samples[METER_BATCH_MAX_SAMPLES]; static BatchBuffer::EnergySample g_build_samples[METER_BATCH_MAX_SAMPLES];
static uint8_t g_build_count = 0; static uint8_t g_build_count = 0;
static uint32_t g_last_sample_ms = 0; static uint32_t g_last_sample_ms = 0;
@@ -82,11 +82,21 @@ static uint16_t g_last_acked_batch_id = 0;
static uint8_t g_batch_retry_count = 0; static uint8_t g_batch_retry_count = 0;
static bool g_batch_ack_pending = false; static bool g_batch_ack_pending = false;
static uint32_t g_batch_ack_timeout_ms = BATCH_ACK_TIMEOUT_MS; static uint32_t g_batch_ack_timeout_ms = BATCH_ACK_TIMEOUT_MS;
static MeterData g_inflight_samples[METER_BATCH_MAX_SAMPLES]; static BatchBuffer::EnergySample g_inflight_samples[METER_BATCH_MAX_SAMPLES];
static uint8_t g_inflight_count = 0; static uint8_t g_inflight_count = 0;
static uint16_t g_inflight_batch_id = 0; static uint16_t g_inflight_batch_id = 0;
static bool g_inflight_active = false; static bool g_inflight_active = false;
static bool g_inflight_sync_request = false;
static uint32_t g_last_debug_log_ms = 0; static uint32_t g_last_debug_log_ms = 0;
static uint32_t g_sender_rx_window_ms = 0;
static uint32_t g_sender_sleep_ms = 0;
static uint32_t g_sender_power_log_ms = 0;
static RxRejectReason g_sender_rx_reject_reason = RxRejectReason::None;
static uint32_t g_sender_rx_reject_log_ms = 0;
static uint32_t g_last_energy_kwh[3] = {};
static bool g_last_energy_valid[3] = {};
static bool g_time_acquired = false;
static uint32_t g_last_sync_request_ms = 0;
static void watchdog_kick(); static void watchdog_kick();
@@ -102,25 +112,6 @@ static void serial_debug_printf(const char *fmt, ...) {
Serial.println(buf); Serial.println(buf);
} }
static void serial_debug_print_json(const String &json) {
if (!SERIAL_DEBUG_MODE || !SERIAL_DEBUG_DUMP_JSON) {
return;
}
const char *data = json.c_str();
size_t len = json.length();
const size_t chunk = 128;
for (size_t i = 0; i < len; i += chunk) {
size_t n = len - i;
if (n > chunk) {
n = chunk;
}
Serial.write(reinterpret_cast<const uint8_t *>(data + i), n);
watchdog_kick();
delay(0);
}
Serial.write('\n');
}
static uint16_t g_last_batch_id_rx[NUM_SENDERS] = {}; static uint16_t g_last_batch_id_rx[NUM_SENDERS] = {};
struct BatchRxState { struct BatchRxState {
@@ -142,6 +133,7 @@ static void init_sender_statuses() {
g_sender_statuses[i] = {}; g_sender_statuses[i] = {};
g_sender_statuses[i].has_data = false; g_sender_statuses[i].has_data = false;
g_sender_statuses[i].last_update_ts_utc = 0; g_sender_statuses[i].last_update_ts_utc = 0;
g_sender_statuses[i].last_acked_batch_id = 0;
g_sender_statuses[i].last_data.short_id = EXPECTED_SENDER_IDS[i]; g_sender_statuses[i].last_data.short_id = EXPECTED_SENDER_IDS[i];
snprintf(g_sender_statuses[i].last_data.device_id, sizeof(g_sender_statuses[i].last_data.device_id), "dd3-%04X", EXPECTED_SENDER_IDS[i]); snprintf(g_sender_statuses[i].last_data.device_id, sizeof(g_sender_statuses[i].last_data.device_id), "dd3-%04X", EXPECTED_SENDER_IDS[i]);
g_sender_faults_remote[i] = {}; g_sender_faults_remote[i] = {};
@@ -162,6 +154,10 @@ static void update_battery_cache() {
g_last_battery_ms = millis(); g_last_battery_ms = millis();
} }
static bool battery_sample_due(uint32_t now_ms) {
return g_last_battery_ms == 0 || now_ms - g_last_battery_ms >= BATTERY_SAMPLE_INTERVAL_MS;
}
static bool batch_queue_drop_oldest() { static bool batch_queue_drop_oldest() {
if (g_batch_count == 0) { if (g_batch_count == 0) {
return false; return false;
@@ -174,12 +170,25 @@ static bool batch_queue_drop_oldest() {
g_inflight_active = false; g_inflight_active = false;
g_inflight_count = 0; g_inflight_count = 0;
g_inflight_batch_id = 0; g_inflight_batch_id = 0;
g_inflight_sync_request = false;
} }
g_batch_tail = (g_batch_tail + 1) % BATCH_QUEUE_DEPTH; g_batch_tail = (g_batch_tail + 1) % BATCH_QUEUE_DEPTH;
g_batch_count--; g_batch_count--;
return dropped_inflight; return dropped_inflight;
} }
static void sender_note_rx_reject(RxRejectReason reason, const char *context) {
if (reason == RxRejectReason::None) {
return;
}
g_sender_rx_reject_reason = reason;
uint32_t now_ms = millis();
if (SERIAL_DEBUG_MODE && now_ms - g_sender_rx_reject_log_ms >= 1000) {
g_sender_rx_reject_log_ms = now_ms;
serial_debug_printf("rx_reject: %s reason=%s", context, rx_reject_reason_text(reason));
}
}
static BatchBuffer *batch_queue_peek() { static BatchBuffer *batch_queue_peek() {
if (g_batch_count == 0) { if (g_batch_count == 0) {
return nullptr; return nullptr;
@@ -187,7 +196,7 @@ static BatchBuffer *batch_queue_peek() {
return &g_batch_queue[g_batch_tail]; return &g_batch_queue[g_batch_tail];
} }
static void batch_queue_enqueue(const MeterData *samples, uint8_t count) { static void batch_queue_enqueue(const BatchBuffer::EnergySample *samples, uint8_t count) {
if (!samples || count == 0) { if (!samples || count == 0) {
return; return;
} }
@@ -283,13 +292,66 @@ static uint16_t read_u16_le(const uint8_t *src) {
return static_cast<uint16_t>(src[0]) | (static_cast<uint16_t>(src[1]) << 8); return static_cast<uint16_t>(src[0]) | (static_cast<uint16_t>(src[1]) << 8);
} }
static void write_u16_be(uint8_t *dst, uint16_t value) {
dst[0] = static_cast<uint8_t>((value >> 8) & 0xFF);
dst[1] = static_cast<uint8_t>(value & 0xFF);
}
static uint16_t read_u16_be(const uint8_t *src) {
return static_cast<uint16_t>(src[0] << 8) | static_cast<uint16_t>(src[1]);
}
static void write_u32_be(uint8_t *dst, uint32_t value) {
dst[0] = static_cast<uint8_t>((value >> 24) & 0xFF);
dst[1] = static_cast<uint8_t>((value >> 16) & 0xFF);
dst[2] = static_cast<uint8_t>((value >> 8) & 0xFF);
dst[3] = static_cast<uint8_t>(value & 0xFF);
}
static uint32_t read_u32_be(const uint8_t *src) {
return (static_cast<uint32_t>(src[0]) << 24) |
(static_cast<uint32_t>(src[1]) << 16) |
(static_cast<uint32_t>(src[2]) << 8) |
static_cast<uint32_t>(src[3]);
}
static uint16_t sender_id_from_short_id(uint16_t short_id) {
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (EXPECTED_SENDER_IDS[i] == short_id) {
return static_cast<uint16_t>(i + 1);
}
}
return 0;
}
static uint16_t short_id_from_sender_id(uint16_t sender_id) {
if (sender_id == 0 || sender_id > NUM_SENDERS) {
return 0;
}
return EXPECTED_SENDER_IDS[sender_id - 1];
}
static uint16_t battery_mv_from_voltage(float value) {
if (isnan(value) || value <= 0.0f) {
return 0;
}
long mv = lroundf(value * 1000.0f);
if (mv < 0) {
mv = 0;
}
if (mv > UINT16_MAX) {
mv = UINT16_MAX;
}
return static_cast<uint16_t>(mv);
}
static uint32_t compute_batch_rx_timeout_ms(uint16_t total_len, uint8_t chunk_count) { static uint32_t compute_batch_rx_timeout_ms(uint16_t total_len, uint8_t chunk_count) {
if (total_len == 0 || chunk_count == 0) { if (total_len == 0 || chunk_count == 0) {
return 10000; return 10000;
} }
size_t max_chunk_payload = total_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : total_len; size_t max_chunk_payload = total_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : total_len;
size_t payload_len = BATCH_HEADER_SIZE + max_chunk_payload; size_t payload_len = BATCH_HEADER_SIZE + max_chunk_payload;
size_t packet_len = 5 + payload_len + 2; size_t packet_len = 3 + payload_len + 2;
uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len); uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len);
uint32_t timeout_ms = static_cast<uint32_t>(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS; uint32_t timeout_ms = static_cast<uint32_t>(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS;
return timeout_ms < 10000 ? 10000 : timeout_ms; return timeout_ms < 10000 ? 10000 : timeout_ms;
@@ -300,28 +362,12 @@ static uint32_t compute_batch_ack_timeout_ms(size_t payload_len) {
return 10000; return 10000;
} }
uint8_t chunk_count = static_cast<uint8_t>((payload_len + BATCH_CHUNK_PAYLOAD - 1) / BATCH_CHUNK_PAYLOAD); uint8_t chunk_count = static_cast<uint8_t>((payload_len + BATCH_CHUNK_PAYLOAD - 1) / BATCH_CHUNK_PAYLOAD);
size_t packet_len = 5 + BATCH_HEADER_SIZE + (payload_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : payload_len) + 2; size_t packet_len = 3 + BATCH_HEADER_SIZE + (payload_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : payload_len) + 2;
uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len); uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len);
uint32_t timeout_ms = static_cast<uint32_t>(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS; uint32_t timeout_ms = static_cast<uint32_t>(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS;
return timeout_ms < 10000 ? 10000 : timeout_ms; return timeout_ms < 10000 ? 10000 : timeout_ms;
} }
static bool inject_batch_meta(String &json, int16_t rssi_dbm, float snr_db, uint32_t rx_ts_utc) {
DynamicJsonDocument doc(8192);
DeserializationError err = deserializeJson(doc, json);
if (err) {
return false;
}
JsonObject meta = doc.createNestedObject("meta");
meta["rssi"] = rssi_dbm;
meta["snr"] = snr_db;
meta["rx_ts"] = rx_ts_utc;
json = "";
return serializeJson(doc, json) > 0;
}
static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_display, uint16_t batch_id) { static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_display, uint16_t batch_id) {
if (!data || len == 0 || len > BATCH_MAX_COMPRESSED) { if (!data || len == 0 || len > BATCH_MAX_COMPRESSED) {
return false; return false;
@@ -339,10 +385,8 @@ static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_
chunk_len = BATCH_CHUNK_PAYLOAD; chunk_len = BATCH_CHUNK_PAYLOAD;
} }
LoraPacket pkt = {}; LoraPacket pkt = {};
pkt.protocol_version = PROTOCOL_VERSION; pkt.msg_kind = LoraMsgKind::BatchUp;
pkt.role = DeviceRole::Sender;
pkt.device_id_short = g_short_id; pkt.device_id_short = g_short_id;
pkt.payload_type = PayloadType::MeterBatch;
pkt.payload_len = chunk_len + BATCH_HEADER_SIZE; pkt.payload_len = chunk_len + BATCH_HEADER_SIZE;
uint8_t *payload = pkt.payload; uint8_t *payload = pkt.payload;
@@ -373,17 +417,32 @@ static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_
return all_ok; return all_ok;
} }
static void send_batch_ack(uint16_t batch_id, uint16_t sender_id) { static void send_batch_ack(uint16_t batch_id, uint8_t sample_count) {
uint32_t epoch = time_get_utc();
uint8_t time_valid = (time_is_synced() && epoch >= MIN_ACCEPTED_EPOCH_UTC) ? 1 : 0;
if (!time_valid) {
epoch = 0;
}
LoraPacket ack = {}; LoraPacket ack = {};
ack.protocol_version = PROTOCOL_VERSION; ack.msg_kind = LoraMsgKind::AckDown;
ack.role = DeviceRole::Receiver;
ack.device_id_short = g_short_id; ack.device_id_short = g_short_id;
ack.payload_type = PayloadType::Ack; ack.payload_len = LORA_ACK_DOWN_PAYLOAD_LEN;
ack.payload_len = 6; ack.payload[0] = time_valid;
write_u16_le(&ack.payload[0], batch_id); write_u16_be(&ack.payload[1], batch_id);
write_u16_le(&ack.payload[2], sender_id); write_u32_be(&ack.payload[3], epoch);
write_u16_le(&ack.payload[4], g_short_id); uint8_t repeats = ACK_REPEAT_COUNT == 0 ? 1 : ACK_REPEAT_COUNT;
for (uint8_t i = 0; i < repeats; ++i) {
lora_send(ack); lora_send(ack);
if (i + 1 < repeats && ACK_REPEAT_DELAY_MS > 0) {
delay(ACK_REPEAT_DELAY_MS);
}
}
serial_debug_printf("ack: tx batch_id=%u time_valid=%u epoch=%lu samples=%u",
batch_id,
static_cast<unsigned>(time_valid),
static_cast<unsigned long>(epoch),
static_cast<unsigned>(sample_count));
lora_receive_continuous();
} }
static bool prepare_inflight_from_queue() { static bool prepare_inflight_from_queue() {
@@ -408,47 +467,67 @@ static bool prepare_inflight_from_queue() {
} }
static bool send_inflight_batch(uint32_t ts_for_display) { static bool send_inflight_batch(uint32_t ts_for_display) {
if (!g_inflight_active || g_inflight_count == 0) { if (!g_inflight_active) {
return false; return false;
} }
uint32_t json_start = millis(); BatchInput input = {};
String json; input.schema_id = 1;
if (!meterBatchToJson(g_inflight_samples, g_inflight_count, g_inflight_batch_id, json, &g_sender_faults, g_sender_last_error)) { input.sender_id = sender_id_from_short_id(g_short_id);
return false; input.batch_id = g_inflight_batch_id;
} input.t_last = g_inflight_sync_request ? time_get_utc() :
uint32_t json_ms = millis() - json_start; g_inflight_samples[g_inflight_count - 1].ts_utc;
if (SERIAL_DEBUG_MODE) { uint32_t dt_s = METER_SAMPLE_INTERVAL_MS / 1000;
serial_debug_printf("tx: batch_id=%u count=%u json_len=%u", g_inflight_batch_id, g_inflight_count, static_cast<unsigned>(json.length())); input.dt_s = dt_s > 0 ? static_cast<uint8_t>(dt_s) : 1;
if (json_ms > 200) { input.n = g_inflight_sync_request ? 0 : g_inflight_count;
serial_debug_printf("tx: json encode took %lums", static_cast<unsigned long>(json_ms)); input.meter_count = METER_COUNT;
} input.battery_mV = battery_mv_from_voltage(g_last_battery_voltage_v);
serial_debug_print_json(json); input.err_m = g_sender_faults.meter_read_fail > 255 ? 255 : static_cast<uint8_t>(g_sender_faults.meter_read_fail);
input.err_d = g_sender_faults.decode_fail > 255 ? 255 : static_cast<uint8_t>(g_sender_faults.decode_fail);
input.err_tx = g_sender_faults.lora_tx_fail > 255 ? 255 : static_cast<uint8_t>(g_sender_faults.lora_tx_fail);
input.err_last = static_cast<uint8_t>(g_sender_last_error);
input.err_rx_reject = static_cast<uint8_t>(g_sender_rx_reject_reason);
for (uint8_t i = 0; i < input.n; ++i) {
input.energy1_kwh[i] = g_inflight_samples[i].energy_kwh[0];
input.energy2_kwh[i] = g_inflight_samples[i].energy_kwh[1];
input.energy3_kwh[i] = g_inflight_samples[i].energy_kwh[2];
} }
static uint8_t compressed[BATCH_MAX_COMPRESSED]; static uint8_t encoded[BATCH_MAX_COMPRESSED];
size_t compressed_len = 0; size_t encoded_len = 0;
uint32_t compress_start = millis(); uint32_t encode_start = millis();
if (!compressBuffer(reinterpret_cast<const uint8_t *>(json.c_str()), json.length(), compressed, sizeof(compressed), compressed_len)) { if (!encode_batch(input, encoded, sizeof(encoded), &encoded_len)) {
return false; return false;
} }
uint32_t compress_ms = millis() - compress_start; uint32_t encode_ms = millis() - encode_start;
if (SERIAL_DEBUG_MODE && compress_ms > 200) { if (SERIAL_DEBUG_MODE) {
serial_debug_printf("tx: compress took %lums", static_cast<unsigned long>(compress_ms)); serial_debug_printf("tx: batch_id=%u count=%u bin_len=%u", g_inflight_batch_id, input.n,
static_cast<unsigned>(encoded_len));
if (encode_ms > 200) {
serial_debug_printf("tx: encode took %lums", static_cast<unsigned long>(encode_ms));
} }
g_batch_ack_timeout_ms = compute_batch_ack_timeout_ms(compressed_len); }
g_batch_ack_timeout_ms = compute_batch_ack_timeout_ms(encoded_len);
uint32_t send_start = millis(); uint32_t send_start = millis();
bool ok = send_batch_payload(compressed, compressed_len, ts_for_display, g_inflight_batch_id); bool ok = send_batch_payload(encoded, encoded_len, ts_for_display, g_inflight_batch_id);
uint32_t send_ms = millis() - send_start; uint32_t send_ms = millis() - send_start;
if (SERIAL_DEBUG_MODE && send_ms > 1000) { if (SERIAL_DEBUG_MODE && send_ms > 1000) {
serial_debug_printf("tx: send batch took %lums", static_cast<unsigned long>(send_ms)); serial_debug_printf("tx: send batch took %lums", static_cast<unsigned long>(send_ms));
} }
if (ok) { if (ok) {
g_last_batch_send_ms = millis(); g_last_batch_send_ms = millis();
serial_debug_printf("tx: sent batch_id=%u len=%u", g_inflight_batch_id, static_cast<unsigned>(compressed_len)); if (g_inflight_sync_request) {
serial_debug_printf("sync: request tx batch_id=%u", g_inflight_batch_id);
} else {
serial_debug_printf("tx: sent batch_id=%u len=%u", g_inflight_batch_id, static_cast<unsigned>(encoded_len));
}
} else {
if (g_inflight_sync_request) {
serial_debug_printf("sync: request tx failed batch_id=%u", g_inflight_batch_id);
} else { } else {
serial_debug_printf("tx: send failed batch_id=%u", g_inflight_batch_id); serial_debug_printf("tx: send failed batch_id=%u", g_inflight_batch_id);
} }
}
return ok; return ok;
} }
@@ -456,6 +535,7 @@ static bool send_meter_batch(uint32_t ts_for_display) {
if (!prepare_inflight_from_queue()) { if (!prepare_inflight_from_queue()) {
return false; return false;
} }
g_inflight_sync_request = false;
bool ok = send_inflight_batch(ts_for_display); bool ok = send_inflight_batch(ts_for_display);
if (ok) { if (ok) {
g_last_sent_batch_id = g_inflight_batch_id; g_last_sent_batch_id = g_inflight_batch_id;
@@ -464,12 +544,36 @@ static bool send_meter_batch(uint32_t ts_for_display) {
g_inflight_active = false; g_inflight_active = false;
g_inflight_count = 0; g_inflight_count = 0;
g_inflight_batch_id = 0; g_inflight_batch_id = 0;
g_inflight_sync_request = false;
}
return ok;
}
static bool send_sync_request() {
if (g_batch_ack_pending) {
return false;
}
if (battery_sample_due(millis())) {
update_battery_cache();
}
g_inflight_active = true;
g_inflight_sync_request = true;
g_inflight_count = 0;
g_inflight_batch_id = g_batch_id;
bool ok = send_inflight_batch(time_get_utc());
if (ok) {
g_last_sent_batch_id = g_inflight_batch_id;
g_batch_ack_pending = true;
} else {
g_inflight_active = false;
g_inflight_sync_request = false;
g_inflight_batch_id = 0;
} }
return ok; return ok;
} }
static bool resend_inflight_batch(uint32_t ts_for_display) { static bool resend_inflight_batch(uint32_t ts_for_display) {
if (!g_batch_ack_pending || !g_inflight_active || g_inflight_count == 0) { if (!g_batch_ack_pending || !g_inflight_active || (!g_inflight_sync_request && g_inflight_count == 0)) {
return false; return false;
} }
return send_inflight_batch(ts_for_display); return send_inflight_batch(ts_for_display);
@@ -484,6 +588,7 @@ static void finish_inflight_batch() {
g_inflight_active = false; g_inflight_active = false;
g_inflight_count = 0; g_inflight_count = 0;
g_inflight_batch_id = 0; g_inflight_batch_id = 0;
g_inflight_sync_request = false;
g_batch_id++; g_batch_id++;
} }
@@ -498,7 +603,7 @@ static void reset_batch_rx() {
g_batch_rx.timeout_ms = 0; g_batch_rx.timeout_ms = 0;
} }
static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool &decode_error, uint16_t &out_batch_id) { static bool process_batch_packet(const LoraPacket &pkt, BatchInput &out_batch, bool &decode_error, uint16_t &out_batch_id) {
decode_error = false; decode_error = false;
if (pkt.payload_len < BATCH_HEADER_SIZE) { if (pkt.payload_len < BATCH_HEADER_SIZE) {
return false; return false;
@@ -545,20 +650,11 @@ static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool &
g_batch_rx.last_rx_ms = now_ms; g_batch_rx.last_rx_ms = now_ms;
if (g_batch_rx.next_index == g_batch_rx.expected_chunks && g_batch_rx.received_len == g_batch_rx.total_len) { if (g_batch_rx.next_index == g_batch_rx.expected_chunks && g_batch_rx.received_len == g_batch_rx.total_len) {
static uint8_t decompressed[BATCH_MAX_DECOMPRESSED]; if (!decode_batch(g_batch_rx.buffer, g_batch_rx.received_len, &out_batch)) {
size_t decompressed_len = 0;
if (!decompressBuffer(g_batch_rx.buffer, g_batch_rx.received_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) {
decode_error = true; decode_error = true;
reset_batch_rx(); reset_batch_rx();
return false; return false;
} }
if (decompressed_len >= sizeof(decompressed)) {
decode_error = true;
reset_batch_rx();
return false;
}
decompressed[decompressed_len] = '\0';
out_json = String(reinterpret_cast<const char *>(decompressed));
out_batch_id = batch_id; out_batch_id = batch_id;
reset_batch_rx(); reset_batch_rx();
return true; return true;
@@ -568,13 +664,19 @@ static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool &
} }
void setup() { void setup() {
if (SERIAL_DEBUG_MODE) {
Serial.begin(115200); Serial.begin(115200);
delay(200); delay(200);
}
#ifdef PAYLOAD_CODEC_TEST
payload_codec_self_test();
#endif
watchdog_init(); watchdog_init();
g_boot_ms = millis(); g_boot_ms = millis();
g_role = detect_role(); g_role = detect_role();
init_device_ids(g_short_id, g_device_id, sizeof(g_device_id)); init_device_ids(g_short_id, g_device_id, sizeof(g_device_id));
display_set_role(g_role);
if (SERIAL_DEBUG_MODE) { if (SERIAL_DEBUG_MODE) {
#ifdef ARDUINO_ARCH_ESP32 #ifdef ARDUINO_ARCH_ESP32
serial_debug_printf("boot: reset_reason=%d", static_cast<int>(esp_reset_reason())); serial_debug_printf("boot: reset_reason=%d", static_cast<int>(esp_reset_reason()));
@@ -585,19 +687,22 @@ void setup() {
lora_init(); lora_init();
display_init(); display_init();
time_rtc_init();
time_try_load_from_rtc();
display_set_role(g_role);
display_set_self_ids(g_short_id, g_device_id); display_set_self_ids(g_short_id, g_device_id);
if (g_role == DeviceRole::Sender) { if (g_role == DeviceRole::Sender) {
power_sender_init(); power_sender_init();
power_configure_unused_pins_sender();
meter_init(); meter_init();
g_last_sample_ms = millis() - METER_SAMPLE_INTERVAL_MS; g_last_sample_ms = millis() - METER_SAMPLE_INTERVAL_MS;
g_last_send_ms = millis(); g_last_send_ms = millis();
g_last_sync_request_ms = millis() - SYNC_REQUEST_INTERVAL_MS;
g_time_acquired = false;
update_battery_cache(); update_battery_cache();
} else { } else {
power_receiver_init(); power_receiver_init();
lora_receive_continuous();
pinMode(PIN_ROLE, INPUT); // release pulldown before SD uses GPIO14 as SCK
sd_logger_init();
wifi_manager_init(); wifi_manager_init();
init_sender_statuses(); init_sender_statuses();
display_set_sender_statuses(g_sender_statuses, NUM_SENDERS); display_set_sender_statuses(g_sender_statuses, NUM_SENDERS);
@@ -613,8 +718,8 @@ void setup() {
} else { } else {
g_ap_mode = true; g_ap_mode = true;
char ap_ssid[32]; char ap_ssid[32];
snprintf(ap_ssid, sizeof(ap_ssid), "DD3-Bridge-%04X", g_short_id); snprintf(ap_ssid, sizeof(ap_ssid), "%s%04X", AP_SSID_PREFIX, g_short_id);
wifi_start_ap(ap_ssid, "changeme123"); wifi_start_ap(ap_ssid, AP_PASSWORD);
if (g_cfg.ntp_server_1.isEmpty()) { if (g_cfg.ntp_server_1.isEmpty()) {
g_cfg.ntp_server_1 = "pool.ntp.org"; g_cfg.ntp_server_1 = "pool.ntp.org";
} }
@@ -645,80 +750,166 @@ static void sender_loop() {
g_batch_retry_count); g_batch_retry_count);
} }
if (g_time_acquired) {
meter_poll();
for (uint8_t i = 0; i < meter_count() && i < 3; ++i) {
uint32_t e_kwh = 0;
if (meter_get_last_energy_kwh(i, e_kwh)) {
g_last_energy_kwh[i] = e_kwh;
g_last_energy_valid[i] = true;
}
}
if (now_ms - g_last_sample_ms >= METER_SAMPLE_INTERVAL_MS) { if (now_ms - g_last_sample_ms >= METER_SAMPLE_INTERVAL_MS) {
g_last_sample_ms = now_ms; g_last_sample_ms = now_ms;
MeterData data = {}; bool any_meter_valid = false;
data.short_id = g_short_id; for (uint8_t i = 0; i < meter_count() && i < 3; ++i) {
strncpy(data.device_id, g_device_id, sizeof(data.device_id)); if (g_last_energy_valid[i]) {
any_meter_valid = true;
bool meter_ok = meter_read(data); break;
if (!meter_ok) { }
}
if (!any_meter_valid) {
note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::MeterRead); note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::MeterRead);
display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms);
} } else {
if (g_build_count == 0) { BatchBuffer::EnergySample sample = {};
update_battery_cache(); sample.ts_utc = time_get_utc();
} sample.energy_kwh[0] = g_last_energy_valid[0] ? g_last_energy_kwh[0] : 0;
data.battery_voltage_v = g_last_battery_voltage_v; sample.energy_kwh[1] = g_last_energy_valid[1] ? g_last_energy_kwh[1] : 0;
data.battery_percent = g_last_battery_percent; sample.energy_kwh[2] = (meter_count() >= 3 && g_last_energy_valid[2]) ? g_last_energy_kwh[2] : 0;
g_last_sample_ts_utc = sample.ts_utc;
uint32_t now_utc = time_get_utc(); g_build_samples[g_build_count++] = sample;
data.ts_utc = now_utc > 0 ? now_utc : millis() / 1000;
data.valid = meter_ok;
g_last_sample_ts_utc = data.ts_utc;
g_build_samples[g_build_count++] = data;
if (g_build_count >= METER_BATCH_MAX_SAMPLES) { if (g_build_count >= METER_BATCH_MAX_SAMPLES) {
batch_queue_enqueue(g_build_samples, g_build_count); batch_queue_enqueue(g_build_samples, g_build_count);
g_build_count = 0; g_build_count = 0;
} }
display_set_last_meter(data);
display_set_last_read(meter_ok, data.ts_utc); MeterData view = {};
view.short_id = g_short_id;
strncpy(view.device_id, g_device_id, sizeof(view.device_id));
view.ts_utc = sample.ts_utc;
view.energy_multi = true;
view.energy_meter_count = meter_count();
view.energy_kwh_int[0] = sample.energy_kwh[0];
view.energy_kwh_int[1] = sample.energy_kwh[1];
view.energy_kwh_int[2] = sample.energy_kwh[2];
view.energy_total_kwh = static_cast<float>(sample.energy_kwh[0]);
view.valid = true;
view.battery_voltage_v = g_last_battery_voltage_v;
view.battery_percent = g_last_battery_percent;
display_set_last_meter(view);
display_set_last_read(true, view.ts_utc);
}
if (g_build_count == 0 && battery_sample_due(now_ms)) {
update_battery_cache();
}
} }
if (!g_batch_ack_pending && now_ms - g_last_send_ms >= METER_SEND_INTERVAL_MS) { if (!g_batch_ack_pending && now_ms - g_last_send_ms >= METER_SEND_INTERVAL_MS) {
g_last_send_ms = now_ms; g_last_send_ms = now_ms;
send_meter_batch(last_sample_ts()); send_meter_batch(last_sample_ts());
} }
} else {
if (!g_batch_ack_pending && now_ms - g_last_sync_request_ms >= SYNC_REQUEST_INTERVAL_MS) {
g_last_sync_request_ms = now_ms;
send_sync_request();
}
}
if (g_batch_ack_pending) { if (g_batch_ack_pending) {
uint32_t end_ms = millis() + 400;
while (millis() < end_ms) {
LoraPacket ack_pkt = {}; LoraPacket ack_pkt = {};
if (!lora_receive(ack_pkt, 0) || ack_pkt.protocol_version != PROTOCOL_VERSION) { constexpr size_t ack_len = lora_frame_size(LORA_ACK_DOWN_PAYLOAD_LEN);
delay(5); uint32_t ack_air_ms = lora_airtime_ms(ack_len);
continue; uint32_t ack_window_ms = ack_air_ms + 300;
if (ack_window_ms < 1200) {
ack_window_ms = 1200;
}
if (ack_window_ms > 4000) {
ack_window_ms = 4000;
}
if (SERIAL_DEBUG_MODE) {
serial_debug_printf("ack: rx window=%lu airtime=%lu", static_cast<unsigned long>(ack_window_ms),
static_cast<unsigned long>(ack_air_ms));
}
uint32_t rx_start = millis();
bool got_ack = lora_receive_window(ack_pkt, ack_window_ms);
if (!got_ack) {
got_ack = lora_receive_window(ack_pkt, ack_window_ms / 2);
}
uint32_t rx_elapsed = millis() - rx_start;
if (SERIAL_DEBUG_MODE) {
g_sender_rx_window_ms += rx_elapsed;
}
if (!got_ack) {
RxRejectReason reason = lora_get_last_rx_reject_reason();
sender_note_rx_reject(reason, "ack");
if (SERIAL_DEBUG_MODE) {
int16_t rssi_dbm = 0;
float snr_db = 0.0f;
bool has_signal = lora_get_last_rx_signal(rssi_dbm, snr_db);
const char *reason_text = reason == RxRejectReason::None ? "timeout" : rx_reject_reason_text(reason);
if (has_signal) {
serial_debug_printf("ack: rx miss reason=%s rssi=%d snr=%.1f",
reason_text,
static_cast<int>(rssi_dbm),
static_cast<double>(snr_db));
} else {
serial_debug_printf("ack: rx miss reason=%s", reason_text);
}
}
} else if (ack_pkt.msg_kind != LoraMsgKind::AckDown) {
sender_note_rx_reject(RxRejectReason::InvalidMsgKind, "ack");
if (SERIAL_DEBUG_MODE) {
uint16_t ack_id = ack_pkt.payload_len >= 3 ? read_u16_be(&ack_pkt.payload[1]) : 0;
serial_debug_printf("ack: reject msg_kind=%u payload_len=%u ack_id=%u",
static_cast<unsigned>(ack_pkt.msg_kind),
static_cast<unsigned>(ack_pkt.payload_len),
ack_id);
}
} else if (ack_pkt.payload_len < LORA_ACK_DOWN_PAYLOAD_LEN) {
sender_note_rx_reject(RxRejectReason::LengthMismatch, "ack");
if (SERIAL_DEBUG_MODE) {
uint16_t ack_id = ack_pkt.payload_len >= 3 ? read_u16_be(&ack_pkt.payload[1]) : 0;
serial_debug_printf("ack: reject msg_kind=%u payload_len=%u ack_id=%u",
static_cast<unsigned>(ack_pkt.msg_kind),
static_cast<unsigned>(ack_pkt.payload_len),
ack_id);
}
} else {
uint8_t time_valid = ack_pkt.payload[0] & 0x01;
uint16_t ack_id = read_u16_be(&ack_pkt.payload[1]);
uint32_t ack_epoch = read_u32_be(&ack_pkt.payload[3]);
bool set_time = false;
if (g_batch_ack_pending && ack_id == g_last_sent_batch_id) {
if (time_valid == 1 && ack_epoch >= MIN_ACCEPTED_EPOCH_UTC) {
time_set_utc(ack_epoch);
g_time_acquired = true;
set_time = true;
} }
if (ack_pkt.payload_type == PayloadType::Ack && ack_pkt.payload_len >= 6 && ack_pkt.role == DeviceRole::Receiver) {
uint16_t ack_id = read_u16_le(ack_pkt.payload);
uint16_t ack_sender = read_u16_le(&ack_pkt.payload[2]);
uint16_t ack_receiver = read_u16_le(&ack_pkt.payload[4]);
if (ack_sender == g_short_id && ack_receiver == ack_pkt.device_id_short &&
g_batch_ack_pending && ack_id == g_last_sent_batch_id) {
g_last_acked_batch_id = ack_id; g_last_acked_batch_id = ack_id;
serial_debug_printf("ack: ok batch_id=%u", ack_id); serial_debug_printf("ack: rx ok batch_id=%u time_valid=%u epoch=%lu set=%u",
ack_id,
static_cast<unsigned>(time_valid),
static_cast<unsigned long>(ack_epoch),
set_time ? 1 : 0);
finish_inflight_batch(); finish_inflight_batch();
break; } else {
if (ack_id != g_last_sent_batch_id) {
sender_note_rx_reject(RxRejectReason::BatchIdMismatch, "ack");
if (SERIAL_DEBUG_MODE) {
serial_debug_printf("ack: reject msg_kind=%u payload_len=%u ack_id=%u",
static_cast<unsigned>(ack_pkt.msg_kind),
static_cast<unsigned>(ack_pkt.payload_len),
ack_id);
} }
} }
} }
} }
LoraPacket rx = {};
if (lora_receive(rx, 0) && rx.protocol_version == PROTOCOL_VERSION) {
if (rx.payload_type == PayloadType::TimeSync) {
time_handle_timesync_payload(rx.payload, rx.payload_len);
} else if (rx.payload_type == PayloadType::Ack && rx.payload_len >= 6 && rx.role == DeviceRole::Receiver) {
uint16_t ack_id = read_u16_le(rx.payload);
uint16_t ack_sender = read_u16_le(&rx.payload[2]);
uint16_t ack_receiver = read_u16_le(&rx.payload[4]);
if (ack_sender == g_short_id && ack_receiver == rx.device_id_short &&
g_batch_ack_pending && ack_id == g_last_sent_batch_id) {
g_last_acked_batch_id = ack_id;
serial_debug_printf("ack: ok batch_id=%u", ack_id);
finish_inflight_batch();
}
} }
if (!g_batch_ack_pending) {
lora_sleep();
} }
if (g_batch_ack_pending && (now_ms - g_last_batch_send_ms >= g_batch_ack_timeout_ms)) { if (g_batch_ack_pending && (now_ms - g_last_batch_send_ms >= g_batch_ack_timeout_ms)) {
@@ -737,6 +928,7 @@ static void sender_loop() {
g_inflight_active = false; g_inflight_active = false;
g_inflight_count = 0; g_inflight_count = 0;
g_inflight_batch_id = 0; g_inflight_batch_id = 0;
g_inflight_sync_request = false;
} }
note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx); note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx);
display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms); display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms);
@@ -745,78 +937,38 @@ static void sender_loop() {
display_tick(); display_tick();
uint32_t next_sample_due = g_last_sample_ms + METER_SAMPLE_INTERVAL_MS; uint32_t next_due = g_time_acquired ? (g_last_sample_ms + METER_SAMPLE_INTERVAL_MS) :
(g_last_sync_request_ms + SYNC_REQUEST_INTERVAL_MS);
if (g_time_acquired) {
uint32_t next_send_due = g_last_send_ms + METER_SEND_INTERVAL_MS; uint32_t next_send_due = g_last_send_ms + METER_SEND_INTERVAL_MS;
uint32_t next_due = next_sample_due < next_send_due ? next_sample_due : next_send_due; if (next_send_due < next_due) {
next_due = next_send_due;
}
}
if (!g_batch_ack_pending && next_due > now_ms) { if (!g_batch_ack_pending && next_due > now_ms) {
watchdog_kick(); watchdog_kick();
if (SERIAL_DEBUG_MODE) {
g_sender_sleep_ms += (next_due - now_ms);
if (now_ms - g_sender_power_log_ms >= 10000) {
g_sender_power_log_ms = now_ms;
serial_debug_printf("power: rx_ms=%lu sleep_ms=%lu", static_cast<unsigned long>(g_sender_rx_window_ms),
static_cast<unsigned long>(g_sender_sleep_ms));
}
}
lora_sleep();
light_sleep_ms(next_due - now_ms); light_sleep_ms(next_due - now_ms);
} }
} }
static void receiver_loop() { static void receiver_loop() {
watchdog_kick(); watchdog_kick();
if (g_last_timesync_ms == 0) {
g_last_timesync_ms = millis() - (TIME_SYNC_INTERVAL_SEC * 1000UL - TIME_SYNC_OFFSET_MS);
}
LoraPacket pkt = {}; LoraPacket pkt = {};
if (lora_receive(pkt, 0) && pkt.protocol_version == PROTOCOL_VERSION) { if (lora_receive(pkt, 0)) {
if (pkt.payload_type == PayloadType::MeterData) { if (pkt.msg_kind == LoraMsgKind::BatchUp) {
uint8_t decompressed[256]; BatchInput batch = {};
size_t decompressed_len = 0;
if (!decompressBuffer(pkt.payload, pkt.payload_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
} else {
if (decompressed_len >= sizeof(decompressed)) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
return;
}
decompressed[decompressed_len] = '\0';
MeterData data = {};
if (jsonToMeterData(String(reinterpret_cast<const char *>(decompressed)), data)) {
data.link_valid = true;
data.link_rssi_dbm = pkt.rssi_dbm;
data.link_snr_db = pkt.snr_db;
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) {
data.short_id = pkt.device_id_short;
g_sender_statuses[i].last_data = data;
g_sender_statuses[i].last_update_ts_utc = data.ts_utc;
g_sender_statuses[i].has_data = true;
g_sender_faults_remote[i].meter_read_fail = data.err_meter_read;
g_sender_faults_remote[i].lora_tx_fail = data.err_lora_tx;
g_sender_last_error_remote[i] = data.last_error;
g_sender_last_error_remote_utc[i] = time_get_utc();
g_sender_last_error_remote_ms[i] = millis();
mqtt_publish_state(data);
if (ENABLE_HA_DISCOVERY && !g_sender_discovery_sent[i]) {
g_sender_discovery_sent[i] = mqtt_publish_discovery(data.device_id);
}
publish_faults_if_needed(data.device_id, g_sender_faults_remote[i], g_sender_faults_remote_published[i],
g_sender_last_error_remote[i], g_sender_last_error_remote_published[i],
g_sender_last_error_remote_utc[i], g_sender_last_error_remote_ms[i]);
break;
}
}
} else {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
}
}
} else if (pkt.payload_type == PayloadType::MeterBatch) {
String json;
bool decode_error = false; bool decode_error = false;
uint16_t batch_id = 0; uint16_t batch_id = 0;
if (process_batch_packet(pkt, json, decode_error, batch_id)) { if (process_batch_packet(pkt, batch, decode_error, batch_id)) {
uint32_t rx_ts_utc = time_get_utc();
if (rx_ts_utc == 0) {
rx_ts_utc = millis() / 1000;
}
inject_batch_meta(json, pkt.rssi_dbm, pkt.snr_db, rx_ts_utc);
MeterData samples[METER_BATCH_MAX_SAMPLES];
size_t count = 0;
int8_t sender_idx = -1; int8_t sender_idx = -1;
for (uint8_t i = 0; i < NUM_SENDERS; ++i) { for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) { if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) {
@@ -824,20 +976,92 @@ static void receiver_loop() {
break; break;
} }
} }
bool duplicate = sender_idx >= 0 && g_last_batch_id_rx[sender_idx] == batch_id; bool duplicate = sender_idx >= 0 && g_last_batch_id_rx[sender_idx] == batch_id;
send_batch_ack(batch_id, batch.n);
if (duplicate) { if (duplicate) {
send_batch_ack(batch_id, pkt.device_id_short); goto receiver_loop_done;
} else if (jsonToMeterBatch(json, samples, METER_BATCH_MAX_SAMPLES, count)) { }
if (sender_idx >= 0) { if (sender_idx >= 0) {
g_last_batch_id_rx[sender_idx] = batch_id;
}
if (batch.n == 0) {
goto receiver_loop_done;
}
if (batch.n > METER_BATCH_MAX_SAMPLES) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
goto receiver_loop_done;
}
size_t count = batch.n;
uint16_t short_id = pkt.device_id_short;
if (short_id == 0) {
short_id = short_id_from_sender_id(batch.sender_id);
}
uint64_t span = static_cast<uint64_t>(batch.dt_s) * static_cast<uint64_t>(count - 1);
if (batch.t_last < span || batch.t_last < MIN_ACCEPTED_EPOCH_UTC) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
goto receiver_loop_done;
}
uint32_t t_first = batch.t_last - static_cast<uint32_t>(span);
if (t_first < MIN_ACCEPTED_EPOCH_UTC) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
goto receiver_loop_done;
}
MeterData samples[METER_BATCH_MAX_SAMPLES];
float bat_v = batch.battery_mV > 0 ? static_cast<float>(batch.battery_mV) / 1000.0f : NAN;
for (size_t s = 0; s < count; ++s) {
MeterData &data = samples[s];
data = {};
data.short_id = short_id;
if (short_id != 0) {
snprintf(data.device_id, sizeof(data.device_id), "dd3-%04X", short_id);
} else {
snprintf(data.device_id, sizeof(data.device_id), "dd3-0000");
}
data.ts_utc = t_first + static_cast<uint32_t>(s) * batch.dt_s;
if (batch.schema_id == 1) {
data.energy_multi = true;
data.energy_meter_count = batch.meter_count;
data.energy_kwh_int[0] = batch.energy1_kwh[s];
data.energy_kwh_int[1] = batch.energy2_kwh[s];
data.energy_kwh_int[2] = batch.energy3_kwh[s];
data.energy_total_kwh = static_cast<float>(batch.energy1_kwh[s]);
data.phase_power_w[0] = 0.0f;
data.phase_power_w[1] = 0.0f;
data.phase_power_w[2] = 0.0f;
data.total_power_w = 0.0f;
} else {
data.energy_total_kwh = static_cast<float>(batch.energy_wh[s]) / 1000.0f;
data.phase_power_w[0] = static_cast<float>(batch.p1_w[s]);
data.phase_power_w[1] = static_cast<float>(batch.p2_w[s]);
data.phase_power_w[2] = static_cast<float>(batch.p3_w[s]);
data.total_power_w = data.phase_power_w[0] + data.phase_power_w[1] + data.phase_power_w[2];
}
data.battery_voltage_v = bat_v;
data.battery_percent = !isnan(bat_v) ? battery_percent_from_voltage(bat_v) : 0;
data.valid = true;
data.link_valid = true;
data.link_rssi_dbm = pkt.rssi_dbm;
data.link_snr_db = pkt.snr_db;
data.err_meter_read = batch.err_m;
data.err_decode = batch.err_d;
data.err_lora_tx = batch.err_tx;
data.last_error = static_cast<FaultType>(batch.err_last);
data.rx_reject_reason = batch.err_rx_reject;
sd_logger_log_sample(data, (s + 1 == count) && data.last_error != FaultType::None);
}
if (sender_idx >= 0) {
g_sender_statuses[sender_idx].last_acked_batch_id = batch_id;
web_server_set_last_batch(static_cast<uint8_t>(sender_idx), samples, count); web_server_set_last_batch(static_cast<uint8_t>(sender_idx), samples, count);
for (size_t s = 0; s < count; ++s) { for (size_t s = 0; s < count; ++s) {
samples[s].link_valid = true;
samples[s].link_rssi_dbm = pkt.rssi_dbm;
samples[s].link_snr_db = pkt.snr_db;
samples[s].short_id = pkt.device_id_short;
mqtt_publish_state(samples[s]); mqtt_publish_state(samples[s]);
} }
if (count > 0) {
g_sender_statuses[sender_idx].last_data = samples[count - 1]; g_sender_statuses[sender_idx].last_data = samples[count - 1];
g_sender_statuses[sender_idx].last_update_ts_utc = samples[count - 1].ts_utc; g_sender_statuses[sender_idx].last_update_ts_utc = samples[count - 1].ts_utc;
g_sender_statuses[sender_idx].has_data = true; g_sender_statuses[sender_idx].has_data = true;
@@ -853,13 +1077,6 @@ static void receiver_loop() {
g_sender_last_error_remote[sender_idx], g_sender_last_error_remote_published[sender_idx], g_sender_last_error_remote[sender_idx], g_sender_last_error_remote_published[sender_idx],
g_sender_last_error_remote_utc[sender_idx], g_sender_last_error_remote_ms[sender_idx]); g_sender_last_error_remote_utc[sender_idx], g_sender_last_error_remote_ms[sender_idx]);
} }
g_last_batch_id_rx[sender_idx] = batch_id;
send_batch_ack(batch_id, pkt.device_id_short);
}
} else {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
}
} else if (decode_error) { } else if (decode_error) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode); note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms); display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
@@ -867,18 +1084,7 @@ static void receiver_loop() {
} }
} }
uint32_t interval_sec = TIME_SYNC_INTERVAL_SEC; receiver_loop_done:
if (time_rtc_present() && millis() - g_boot_ms >= TIME_SYNC_FAST_WINDOW_MS) {
interval_sec = TIME_SYNC_SLOW_INTERVAL_SEC;
}
if (!g_ap_mode && millis() - g_last_timesync_ms > interval_sec * 1000UL) {
g_last_timesync_ms = millis();
if (!time_send_timesync(g_short_id)) {
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::LoraTx);
display_set_last_error(g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms);
}
}
mqtt_loop(); mqtt_loop();
web_server_loop(); web_server_loop();
if (ENABLE_HA_DISCOVERY && !g_receiver_discovery_sent) { if (ENABLE_HA_DISCOVERY && !g_receiver_discovery_sent) {

View File

@@ -4,11 +4,31 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
static constexpr uint32_t METER_READ_TIMEOUT_MS = 2000; static constexpr uint32_t METER_FRAME_TIMEOUT_MS = 1500;
static constexpr size_t METER_FRAME_MAX = 512;
void meter_init() { enum class MeterRxState : uint8_t {
Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1); WaitStart = 0,
} InFrame = 1
};
struct MeterPort {
HardwareSerial *serial;
MeterRxState state;
char frame_buf[METER_FRAME_MAX + 1];
size_t frame_len;
uint32_t last_rx_ms;
uint32_t bytes_rx;
uint32_t frames_ok;
uint32_t frames_parse_fail;
uint32_t rx_overflow;
uint32_t rx_timeout;
uint32_t last_energy_kwh;
bool has_energy;
};
static MeterPort g_ports[METER_COUNT] = {};
static uint32_t g_last_log_ms = 0;
static bool parse_obis_ascii_value(const char *line, const char *obis, float &out_value) { static bool parse_obis_ascii_value(const char *line, const char *obis, float &out_value) {
const char *p = strstr(line, obis); const char *p = strstr(line, obis);
@@ -45,121 +65,142 @@ static bool parse_obis_ascii_value(const char *line, const char *obis, float &ou
return true; return true;
} }
static bool parse_obis_ascii_unit_scale(const char *line, const char *obis, float &value) { static bool parse_energy_kwh_floor(const char *frame, size_t len, uint32_t &out_kwh) {
const char *p = strstr(line, obis);
if (!p) {
return false;
}
const char *asterisk = strchr(p, '*');
if (!asterisk) {
return false;
}
const char *end = strchr(asterisk, ')');
if (!end) {
return false;
}
char unit_buf[8];
size_t ulen = 0;
for (const char *c = asterisk + 1; c < end && ulen + 1 < sizeof(unit_buf); ++c) {
if (*c == ' ') {
continue;
}
unit_buf[ulen++] = *c;
}
unit_buf[ulen] = '\0';
if (ulen == 0) {
return false;
}
if (strcmp(unit_buf, "Wh") == 0) {
value *= 0.001f;
return true;
}
return false;
}
static bool meter_read_ascii(MeterData &data) {
const uint32_t start_ms = millis();
bool in_telegram = false;
bool got_any = false;
bool energy_ok = false;
bool total_p_ok = false;
bool p1_ok = false;
bool p2_ok = false;
bool p3_ok = false;
char line[128]; char line[128];
size_t line_len = 0; size_t line_len = 0;
for (size_t i = 0; i < len; ++i) {
while (millis() - start_ms < METER_READ_TIMEOUT_MS) { char c = frame[i];
while (Serial2.available()) {
char c = static_cast<char>(Serial2.read());
if (!in_telegram) {
if (c == '/') {
in_telegram = true;
line_len = 0;
line[line_len++] = c;
}
continue;
}
if (c == '\r') { if (c == '\r') {
continue; continue;
} }
if (c == '\n') { if (c == '\n' || c == '!') {
line[line_len] = '\0'; line[line_len] = '\0';
if (line[0] == '!') {
return got_any;
}
float value = NAN; float value = NAN;
if (parse_obis_ascii_value(line, "1-0:1.8.0", value)) { if (parse_obis_ascii_value(line, "1-0:1.8.0", value) && !isnan(value) && value >= 0.0f) {
parse_obis_ascii_unit_scale(line, "1-0:1.8.0", value); out_kwh = static_cast<uint32_t>(floorf(value));
data.energy_total_kwh = value; return true;
energy_ok = true;
got_any = true;
} }
if (parse_obis_ascii_value(line, "1-0:16.7.0", value)) {
data.total_power_w = value;
total_p_ok = true;
got_any = true;
}
if (parse_obis_ascii_value(line, "1-0:36.7.0", value)) {
data.phase_power_w[0] = value;
p1_ok = true;
got_any = true;
}
if (parse_obis_ascii_value(line, "1-0:56.7.0", value)) {
data.phase_power_w[1] = value;
p2_ok = true;
got_any = true;
}
if (parse_obis_ascii_value(line, "1-0:76.7.0", value)) {
data.phase_power_w[2] = value;
p3_ok = true;
got_any = true;
}
line_len = 0; line_len = 0;
if (c == '!') {
break;
}
continue; continue;
} }
if (line_len + 1 < sizeof(line)) { if (line_len + 1 < sizeof(line)) {
line[line_len++] = c; line[line_len++] = c;
} }
} }
delay(5); return false;
}
static void meter_debug_log() {
if (!SERIAL_DEBUG_MODE) {
return;
}
uint32_t now_ms = millis();
if (now_ms - g_last_log_ms < 60000) {
return;
}
g_last_log_ms = now_ms;
for (uint8_t i = 0; i < METER_COUNT; ++i) {
const MeterPort &p = g_ports[i];
Serial.printf("meter%u: ok=%lu parse_fail=%lu overflow=%lu timeout=%lu bytes=%lu e=%lu valid=%u\n",
static_cast<unsigned>(i + 1),
static_cast<unsigned long>(p.frames_ok),
static_cast<unsigned long>(p.frames_parse_fail),
static_cast<unsigned long>(p.rx_overflow),
static_cast<unsigned long>(p.rx_timeout),
static_cast<unsigned long>(p.bytes_rx),
static_cast<unsigned long>(p.last_energy_kwh),
p.has_energy ? 1 : 0);
}
}
void meter_init() {
g_ports[0].serial = &Serial2;
g_ports[0].serial->begin(9600, SERIAL_7E1, PIN_METER1_RX, -1);
g_ports[0].state = MeterRxState::WaitStart;
if (METER_COUNT >= 2) {
g_ports[1].serial = &Serial1;
g_ports[1].serial->begin(9600, SERIAL_7E1, PIN_METER2_RX, -1);
g_ports[1].state = MeterRxState::WaitStart;
} }
data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok; if (METER_COUNT >= 3) {
return data.valid; g_ports[2].serial = &Serial;
g_ports[2].serial->begin(9600, SERIAL_7E1, PIN_METER3_RX, -1);
g_ports[2].state = MeterRxState::WaitStart;
}
} }
bool meter_read(MeterData &data) { static void meter_poll_port(MeterPort &port) {
data.energy_total_kwh = NAN; if (!port.serial) {
data.total_power_w = NAN; return;
data.phase_power_w[0] = NAN; }
data.phase_power_w[1] = NAN; uint32_t now_ms = millis();
data.phase_power_w[2] = NAN; if (port.state == MeterRxState::InFrame && (now_ms - port.last_rx_ms > METER_FRAME_TIMEOUT_MS)) {
data.valid = false; port.rx_timeout++;
port.state = MeterRxState::WaitStart;
port.frame_len = 0;
}
return meter_read_ascii(data); while (port.serial->available()) {
char c = static_cast<char>(port.serial->read());
port.bytes_rx++;
port.last_rx_ms = now_ms;
if (port.state == MeterRxState::WaitStart) {
if (c == '/') {
port.state = MeterRxState::InFrame;
port.frame_len = 0;
port.frame_buf[port.frame_len++] = c;
}
continue;
}
if (port.frame_len + 1 >= sizeof(port.frame_buf)) {
port.rx_overflow++;
port.state = MeterRxState::WaitStart;
port.frame_len = 0;
continue;
}
port.frame_buf[port.frame_len++] = c;
if (c == '!') {
port.frame_buf[port.frame_len] = '\0';
uint32_t energy_kwh = 0;
if (parse_energy_kwh_floor(port.frame_buf, port.frame_len, energy_kwh)) {
port.last_energy_kwh = energy_kwh;
port.has_energy = true;
port.frames_ok++;
} else {
port.frames_parse_fail++;
}
port.state = MeterRxState::WaitStart;
port.frame_len = 0;
}
}
}
void meter_poll() {
for (uint8_t i = 0; i < METER_COUNT; ++i) {
meter_poll_port(g_ports[i]);
}
meter_debug_log();
}
uint8_t meter_count() {
return METER_COUNT;
}
bool meter_get_last_energy_kwh(uint8_t meter_idx, uint32_t &out_energy_kwh) {
if (meter_idx >= METER_COUNT) {
return false;
}
if (!g_ports[meter_idx].has_energy) {
return false;
}
out_energy_kwh = g_ports[meter_idx].last_energy_kwh;
return true;
} }

View File

@@ -10,6 +10,19 @@ static PubSubClient mqtt_client(wifi_client);
static WifiMqttConfig g_cfg; static WifiMqttConfig g_cfg;
static String g_client_id; static String g_client_id;
static const char *fault_text(FaultType fault) {
switch (fault) {
case FaultType::MeterRead:
return "meter";
case FaultType::Decode:
return "decode";
case FaultType::LoraTx:
return "loratx";
default:
return "none";
}
}
void mqtt_init(const WifiMqttConfig &config, const char *device_id) { void mqtt_init(const WifiMqttConfig &config, const char *device_id) {
g_cfg = config; g_cfg = config;
mqtt_client.setServer(config.mqtt_host.c_str(), config.mqtt_port); mqtt_client.setServer(config.mqtt_host.c_str(), config.mqtt_port);
@@ -66,10 +79,9 @@ bool mqtt_publish_faults(const char *device_id, const FaultCounters &counters, F
doc["err_m"] = counters.meter_read_fail; doc["err_m"] = counters.meter_read_fail;
doc["err_d"] = counters.decode_fail; doc["err_d"] = counters.decode_fail;
doc["err_tx"] = counters.lora_tx_fail; doc["err_tx"] = counters.lora_tx_fail;
if (last_error != FaultType::None) {
doc["err_last"] = static_cast<uint8_t>(last_error); doc["err_last"] = static_cast<uint8_t>(last_error);
doc["err_last_age"] = last_error_age_sec; doc["err_last_text"] = fault_text(last_error);
} doc["err_last_age"] = last_error != FaultType::None ? last_error_age_sec : 0;
String payload; String payload;
size_t len = serializeJson(doc, payload); size_t len = serializeJson(doc, payload);
@@ -138,6 +150,8 @@ bool mqtt_publish_discovery(const char *device_id) {
ok = ok && publish_discovery_sensor(device_id, "err_m", "Meter Read Errors", "count", "", faults_topic.c_str(), "{{ value_json.err_m }}"); ok = ok && publish_discovery_sensor(device_id, "err_m", "Meter Read Errors", "count", "", faults_topic.c_str(), "{{ value_json.err_m }}");
ok = ok && publish_discovery_sensor(device_id, "err_d", "Decode Errors", "count", "", faults_topic.c_str(), "{{ value_json.err_d }}"); ok = ok && publish_discovery_sensor(device_id, "err_d", "Decode Errors", "count", "", faults_topic.c_str(), "{{ value_json.err_d }}");
ok = ok && publish_discovery_sensor(device_id, "err_tx", "LoRa TX Errors", "count", "", faults_topic.c_str(), "{{ value_json.err_tx }}"); ok = ok && publish_discovery_sensor(device_id, "err_tx", "LoRa TX Errors", "count", "", faults_topic.c_str(), "{{ value_json.err_tx }}");
ok = ok && publish_discovery_sensor(device_id, "err_last", "Last Error Code", "", "", faults_topic.c_str(), "{{ value_json.err_last }}");
ok = ok && publish_discovery_sensor(device_id, "err_last_text", "Last Error", "", "", faults_topic.c_str(), "{{ value_json.err_last_text }}");
ok = ok && publish_discovery_sensor(device_id, "err_last_age", "Last Error Age", "s", "", faults_topic.c_str(), "{{ value_json.err_last_age }}"); ok = ok && publish_discovery_sensor(device_id, "err_last_age", "Last Error Age", "s", "", faults_topic.c_str(), "{{ value_json.err_last_age }}");
return ok; return ok;
} }

406
src/payload_codec.cpp Normal file
View File

@@ -0,0 +1,406 @@
#include "payload_codec.h"
#include <limits.h>
static constexpr uint16_t kMagic = 0xDDB3;
static constexpr uint8_t kSchema = 2;
static constexpr uint8_t kFlags = 0x01;
static constexpr size_t kMaxSamples = 30;
static constexpr uint8_t kPayloadSchemaLegacy = 0;
static constexpr uint8_t kPayloadSchemaEnergyMulti = 1;
static void write_u16_le(uint8_t *dst, uint16_t value) {
dst[0] = static_cast<uint8_t>(value & 0xFF);
dst[1] = static_cast<uint8_t>((value >> 8) & 0xFF);
}
static void write_u32_le(uint8_t *dst, uint32_t value) {
dst[0] = static_cast<uint8_t>(value & 0xFF);
dst[1] = static_cast<uint8_t>((value >> 8) & 0xFF);
dst[2] = static_cast<uint8_t>((value >> 16) & 0xFF);
dst[3] = static_cast<uint8_t>((value >> 24) & 0xFF);
}
static uint16_t read_u16_le(const uint8_t *src) {
return static_cast<uint16_t>(src[0]) | (static_cast<uint16_t>(src[1]) << 8);
}
static uint32_t read_u32_le(const uint8_t *src) {
return static_cast<uint32_t>(src[0]) |
(static_cast<uint32_t>(src[1]) << 8) |
(static_cast<uint32_t>(src[2]) << 16) |
(static_cast<uint32_t>(src[3]) << 24);
}
size_t uleb128_encode(uint32_t v, uint8_t *out, size_t cap) {
size_t i = 0;
do {
if (i >= cap) {
return 0;
}
uint8_t byte = static_cast<uint8_t>(v & 0x7F);
v >>= 7;
if (v != 0) {
byte |= 0x80;
}
out[i++] = byte;
} while (v != 0);
return i;
}
bool uleb128_decode(const uint8_t *in, size_t len, size_t *pos, uint32_t *v) {
if (!in || !pos || !v) {
return false;
}
uint32_t result = 0;
uint8_t shift = 0;
size_t p = *pos;
for (uint8_t i = 0; i < 5; ++i) {
if (p >= len) {
return false;
}
uint8_t byte = in[p++];
if (i == 4 && (byte & 0xF0) != 0) {
return false;
}
result |= static_cast<uint32_t>(byte & 0x7F) << shift;
if ((byte & 0x80) == 0) {
*pos = p;
*v = result;
return true;
}
shift = static_cast<uint8_t>(shift + 7);
}
return false;
}
uint32_t zigzag32(int32_t x) {
return (static_cast<uint32_t>(x) << 1) ^ static_cast<uint32_t>(x >> 31);
}
int32_t unzigzag32(uint32_t u) {
return static_cast<int32_t>((u >> 1) ^ (static_cast<uint32_t>(-static_cast<int32_t>(u & 1))));
}
size_t svarint_encode(int32_t x, uint8_t *out, size_t cap) {
uint32_t zz = zigzag32(x);
return uleb128_encode(zz, out, cap);
}
bool svarint_decode(const uint8_t *in, size_t len, size_t *pos, int32_t *x) {
uint32_t u = 0;
if (!uleb128_decode(in, len, pos, &u)) {
return false;
}
*x = unzigzag32(u);
return true;
}
static bool ensure_capacity(size_t needed, size_t cap, size_t pos) {
return pos + needed <= cap;
}
bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *out_len) {
if (!out || !out_len) {
return false;
}
if (in.n > kMaxSamples) {
return false;
}
if (in.dt_s == 0) {
return false;
}
size_t pos = 0;
if (!ensure_capacity(23, out_cap, pos)) {
return false;
}
write_u16_le(&out[pos], kMagic);
pos += 2;
out[pos++] = kSchema;
out[pos++] = kFlags;
out[pos++] = in.schema_id;
write_u16_le(&out[pos], in.sender_id);
pos += 2;
write_u16_le(&out[pos], in.batch_id);
pos += 2;
write_u32_le(&out[pos], in.t_last);
pos += 4;
out[pos++] = in.dt_s;
out[pos++] = in.n;
write_u16_le(&out[pos], in.battery_mV);
pos += 2;
out[pos++] = in.err_m;
out[pos++] = in.err_d;
out[pos++] = in.err_tx;
out[pos++] = in.err_last;
out[pos++] = in.err_rx_reject;
out[pos++] = in.meter_count;
if (in.n == 0) {
*out_len = pos;
return true;
}
if (in.schema_id == kPayloadSchemaEnergyMulti) {
if (in.meter_count == 0 || in.meter_count > 3) {
return false;
}
if (!ensure_capacity(static_cast<size_t>(in.n) * 12, out_cap, pos)) {
return false;
}
for (uint8_t i = 0; i < in.n; ++i) {
write_u32_le(&out[pos], in.energy1_kwh[i]);
pos += 4;
write_u32_le(&out[pos], in.energy2_kwh[i]);
pos += 4;
write_u32_le(&out[pos], in.energy3_kwh[i]);
pos += 4;
}
*out_len = pos;
return true;
}
if (!ensure_capacity(4, out_cap, pos)) {
return false;
}
write_u32_le(&out[pos], in.energy_wh[0]);
pos += 4;
for (uint8_t i = 1; i < in.n; ++i) {
if (in.energy_wh[i] < in.energy_wh[i - 1]) {
return false;
}
uint32_t delta = in.energy_wh[i] - in.energy_wh[i - 1];
size_t wrote = uleb128_encode(delta, &out[pos], out_cap - pos);
if (wrote == 0) {
return false;
}
pos += wrote;
}
auto encode_phase = [&](const int16_t *phase) -> bool {
if (!ensure_capacity(2, out_cap, pos)) {
return false;
}
write_u16_le(&out[pos], static_cast<uint16_t>(phase[0]));
pos += 2;
for (uint8_t i = 1; i < in.n; ++i) {
int32_t delta = static_cast<int32_t>(phase[i]) - static_cast<int32_t>(phase[i - 1]);
size_t wrote = svarint_encode(delta, &out[pos], out_cap - pos);
if (wrote == 0) {
return false;
}
pos += wrote;
}
return true;
};
if (!encode_phase(in.p1_w)) {
return false;
}
if (!encode_phase(in.p2_w)) {
return false;
}
if (!encode_phase(in.p3_w)) {
return false;
}
*out_len = pos;
return true;
}
bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) {
if (!buf || !out) {
return false;
}
size_t pos = 0;
if (len < 23) {
return false;
}
uint16_t magic = read_u16_le(&buf[pos]);
pos += 2;
uint8_t schema = buf[pos++];
uint8_t flags = buf[pos++];
if (magic != kMagic || schema != kSchema || (flags & 0x01) == 0) {
return false;
}
out->schema_id = buf[pos++];
out->sender_id = read_u16_le(&buf[pos]);
pos += 2;
out->batch_id = read_u16_le(&buf[pos]);
pos += 2;
out->t_last = read_u32_le(&buf[pos]);
pos += 4;
out->dt_s = buf[pos++];
out->n = buf[pos++];
out->battery_mV = read_u16_le(&buf[pos]);
pos += 2;
out->err_m = buf[pos++];
out->err_d = buf[pos++];
out->err_tx = buf[pos++];
out->err_last = buf[pos++];
out->err_rx_reject = buf[pos++];
out->meter_count = buf[pos++];
if (out->n > kMaxSamples || out->dt_s == 0) {
return false;
}
if (out->n == 0) {
for (uint8_t i = 0; i < kMaxSamples; ++i) {
out->energy_wh[i] = 0;
out->p1_w[i] = 0;
out->p2_w[i] = 0;
out->p3_w[i] = 0;
}
return pos == len;
}
if (out->schema_id == kPayloadSchemaEnergyMulti) {
if (out->meter_count == 0 || out->meter_count > 3) {
return false;
}
if (pos + static_cast<size_t>(out->n) * 12 > len) {
return false;
}
for (uint8_t i = 0; i < out->n; ++i) {
out->energy1_kwh[i] = read_u32_le(&buf[pos]);
pos += 4;
out->energy2_kwh[i] = read_u32_le(&buf[pos]);
pos += 4;
out->energy3_kwh[i] = read_u32_le(&buf[pos]);
pos += 4;
}
for (uint8_t i = out->n; i < kMaxSamples; ++i) {
out->energy1_kwh[i] = 0;
out->energy2_kwh[i] = 0;
out->energy3_kwh[i] = 0;
}
return pos == len;
}
if (pos + 4 > len) {
return false;
}
out->energy_wh[0] = read_u32_le(&buf[pos]);
pos += 4;
for (uint8_t i = 1; i < out->n; ++i) {
uint32_t delta = 0;
if (!uleb128_decode(buf, len, &pos, &delta)) {
return false;
}
uint64_t sum = static_cast<uint64_t>(out->energy_wh[i - 1]) + static_cast<uint64_t>(delta);
if (sum > UINT32_MAX) {
return false;
}
out->energy_wh[i] = static_cast<uint32_t>(sum);
}
auto decode_phase = [&](int16_t *phase) -> bool {
if (pos + 2 > len) {
return false;
}
phase[0] = static_cast<int16_t>(read_u16_le(&buf[pos]));
pos += 2;
int32_t prev = static_cast<int32_t>(phase[0]);
for (uint8_t i = 1; i < out->n; ++i) {
int32_t delta = 0;
if (!svarint_decode(buf, len, &pos, &delta)) {
return false;
}
int32_t value = prev + delta;
if (value < INT16_MIN || value > INT16_MAX) {
return false;
}
phase[i] = static_cast<int16_t>(value);
prev = value;
}
return true;
};
if (!decode_phase(out->p1_w)) {
return false;
}
if (!decode_phase(out->p2_w)) {
return false;
}
if (!decode_phase(out->p3_w)) {
return false;
}
for (uint8_t i = out->n; i < kMaxSamples; ++i) {
out->energy_wh[i] = 0;
out->p1_w[i] = 0;
out->p2_w[i] = 0;
out->p3_w[i] = 0;
}
return pos == len;
}
#ifdef PAYLOAD_CODEC_TEST
bool payload_codec_self_test() {
BatchInput in = {};
in.schema_id = kPayloadSchemaLegacy;
in.sender_id = 1;
in.batch_id = 42;
in.t_last = 1700000000;
in.dt_s = 1;
in.n = 5;
in.battery_mV = 3750;
in.err_m = 2;
in.err_d = 1;
in.err_tx = 3;
in.err_last = 2;
in.err_rx_reject = 1;
in.meter_count = 0;
in.energy_wh[0] = 100000;
in.energy_wh[1] = 100001;
in.energy_wh[2] = 100050;
in.energy_wh[3] = 100050;
in.energy_wh[4] = 100200;
in.p1_w[0] = -120;
in.p1_w[1] = -90;
in.p1_w[2] = 1910;
in.p1_w[3] = -90;
in.p1_w[4] = 500;
in.p2_w[0] = 50;
in.p2_w[1] = -1950;
in.p2_w[2] = 60;
in.p2_w[3] = 2060;
in.p2_w[4] = -10;
in.p3_w[0] = 0;
in.p3_w[1] = 10;
in.p3_w[2] = -1990;
in.p3_w[3] = 10;
in.p3_w[4] = 20;
uint8_t buf[256];
size_t len = 0;
if (!encode_batch(in, buf, sizeof(buf), &len)) {
Serial.println("payload_codec_self_test: encode failed");
return false;
}
BatchInput out = {};
if (!decode_batch(buf, len, &out)) {
Serial.println("payload_codec_self_test: decode failed");
return false;
}
if (out.sender_id != in.sender_id || out.batch_id != in.batch_id || out.t_last != in.t_last ||
out.dt_s != in.dt_s || out.n != in.n || out.battery_mV != in.battery_mV ||
out.err_m != in.err_m || out.err_d != in.err_d || out.err_tx != in.err_tx || out.err_last != in.err_last ||
out.err_rx_reject != in.err_rx_reject) {
Serial.println("payload_codec_self_test: header mismatch");
return false;
}
for (uint8_t i = 0; i < in.n; ++i) {
if (out.energy_wh[i] != in.energy_wh[i] || out.p1_w[i] != in.p1_w[i] || out.p2_w[i] != in.p2_w[i] ||
out.p3_w[i] != in.p3_w[i]) {
Serial.println("payload_codec_self_test: sample mismatch");
return false;
}
}
Serial.printf("payload_codec_self_test: ok len=%u\n", static_cast<unsigned>(len));
return true;
}
#endif

42
src/payload_codec.h Normal file
View File

@@ -0,0 +1,42 @@
#pragma once
#include <Arduino.h>
struct BatchInput {
uint8_t schema_id;
uint16_t sender_id;
uint16_t batch_id;
uint32_t t_last;
uint8_t dt_s;
uint8_t n;
uint8_t meter_count;
uint16_t battery_mV;
uint8_t err_m;
uint8_t err_d;
uint8_t err_tx;
uint8_t err_last;
uint8_t err_rx_reject;
uint32_t energy1_kwh[30];
uint32_t energy2_kwh[30];
uint32_t energy3_kwh[30];
uint32_t energy_wh[30];
int16_t p1_w[30];
int16_t p2_w[30];
int16_t p3_w[30];
};
bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *out_len);
bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out);
size_t uleb128_encode(uint32_t v, uint8_t *out, size_t cap);
bool uleb128_decode(const uint8_t *in, size_t len, size_t *pos, uint32_t *v);
uint32_t zigzag32(int32_t x);
int32_t unzigzag32(uint32_t u);
size_t svarint_encode(int32_t x, uint8_t *out, size_t cap);
bool svarint_decode(const uint8_t *in, size_t len, size_t *pos, int32_t *x);
#ifdef PAYLOAD_CODEC_TEST
bool payload_codec_self_test();
#endif

View File

@@ -6,11 +6,13 @@
#include <esp_sleep.h> #include <esp_sleep.h>
static constexpr float BATTERY_DIVIDER = 2.0f; static constexpr float BATTERY_DIVIDER = 2.0f;
static constexpr float BATTERY_CAL = 1.0f;
static constexpr float ADC_REF_V = 3.3f; static constexpr float ADC_REF_V = 3.3f;
void power_sender_init() { void power_sender_init() {
setCpuFrequencyMhz(80);
WiFi.mode(WIFI_OFF);
esp_wifi_stop(); esp_wifi_stop();
esp_wifi_deinit();
btStop(); btStop();
analogReadResolution(12); analogReadResolution(12);
pinMode(PIN_BAT_ADC, INPUT); pinMode(PIN_BAT_ADC, INPUT);
@@ -22,22 +24,79 @@ void power_receiver_init() {
pinMode(PIN_BAT_ADC, INPUT); pinMode(PIN_BAT_ADC, INPUT);
} }
void read_battery(MeterData &data) { void power_configure_unused_pins_sender() {
const int samples = 8; // Board-specific: only touch pins that are known unused and safe on TTGO LoRa32 v1.6.1
uint32_t sum = 0; const uint8_t pins[] = {32, 33};
for (int i = 0; i < samples; ++i) { for (uint8_t pin : pins) {
sum += analogRead(PIN_BAT_ADC); pinMode(pin, INPUT_PULLDOWN);
delay(5);
} }
float avg = static_cast<float>(sum) / samples; }
void read_battery(MeterData &data) {
uint32_t sum = 0;
uint16_t samples[5] = {};
for (uint8_t i = 0; i < 5; ++i) {
samples[i] = analogRead(PIN_BAT_ADC);
sum += samples[i];
}
float avg = static_cast<float>(sum) / 5.0f;
float v = (avg / 4095.0f) * ADC_REF_V * BATTERY_DIVIDER * BATTERY_CAL; float v = (avg / 4095.0f) * ADC_REF_V * BATTERY_DIVIDER * BATTERY_CAL;
if (SERIAL_DEBUG_MODE) {
Serial.printf("bat_adc: %u %u %u %u %u avg=%.1f v=%.3f\n",
samples[0], samples[1], samples[2], samples[3], samples[4],
static_cast<double>(avg), static_cast<double>(v));
}
data.battery_voltage_v = v; data.battery_voltage_v = v;
data.battery_percent = battery_percent_from_voltage(v); data.battery_percent = battery_percent_from_voltage(v);
} }
uint8_t battery_percent_from_voltage(float voltage_v) { uint8_t battery_percent_from_voltage(float voltage_v) {
float pct = (voltage_v - 3.0f) / (4.2f - 3.0f) * 100.0f; if (isnan(voltage_v)) {
return 0;
}
struct LutPoint {
float v;
uint8_t pct;
};
static const LutPoint kCurve[] = {
{4.20f, 100},
{4.15f, 95},
{4.11f, 90},
{4.08f, 85},
{4.02f, 80},
{3.98f, 75},
{3.95f, 70},
{3.91f, 60},
{3.87f, 50},
{3.85f, 45},
{3.84f, 40},
{3.82f, 35},
{3.80f, 30},
{3.77f, 25},
{3.75f, 20},
{3.73f, 15},
{3.70f, 10},
{3.65f, 5},
{3.60f, 2},
{2.90f, 0},
};
if (voltage_v >= kCurve[0].v) {
return kCurve[0].pct;
}
if (voltage_v <= kCurve[sizeof(kCurve) / sizeof(kCurve[0]) - 1].v) {
return 0;
}
for (size_t i = 0; i + 1 < sizeof(kCurve) / sizeof(kCurve[0]); ++i) {
const LutPoint &hi = kCurve[i];
const LutPoint &lo = kCurve[i + 1];
if (voltage_v <= hi.v && voltage_v >= lo.v) {
float span = hi.v - lo.v;
if (span <= 0.0f) {
return lo.pct;
}
float t = (voltage_v - lo.v) / span;
float pct = lo.pct + t * (hi.pct - lo.pct);
if (pct < 0.0f) { if (pct < 0.0f) {
pct = 0.0f; pct = 0.0f;
} }
@@ -45,6 +104,9 @@ uint8_t battery_percent_from_voltage(float voltage_v) {
pct = 100.0f; pct = 100.0f;
} }
return static_cast<uint8_t>(pct + 0.5f); return static_cast<uint8_t>(pct + 0.5f);
}
}
return 0;
} }
void light_sleep_ms(uint32_t ms) { void light_sleep_ms(uint32_t ms) {

View File

@@ -1,122 +0,0 @@
#include "rtc_ds3231.h"
#include "config.h"
#include <Wire.h>
#include <time.h>
static constexpr uint8_t DS3231_ADDR = 0x68;
static uint8_t bcd_to_dec(uint8_t val) {
return static_cast<uint8_t>((val >> 4) * 10 + (val & 0x0F));
}
static uint8_t dec_to_bcd(uint8_t val) {
return static_cast<uint8_t>(((val / 10) << 4) | (val % 10));
}
static time_t timegm_fallback(struct tm *tm_utc) {
if (!tm_utc) {
return static_cast<time_t>(-1);
}
char *old_tz = getenv("TZ");
setenv("TZ", "UTC0", 1);
tzset();
time_t t = mktime(tm_utc);
if (old_tz) {
setenv("TZ", old_tz, 1);
} else {
unsetenv("TZ");
}
tzset();
return t;
}
static bool read_registers(uint8_t start_reg, uint8_t *out, size_t len) {
if (!out || len == 0) {
return false;
}
Wire.beginTransmission(DS3231_ADDR);
Wire.write(start_reg);
if (Wire.endTransmission(false) != 0) {
return false;
}
size_t read = Wire.requestFrom(DS3231_ADDR, static_cast<uint8_t>(len));
if (read != len) {
return false;
}
for (size_t i = 0; i < len; ++i) {
out[i] = Wire.read();
}
return true;
}
static bool write_registers(uint8_t start_reg, const uint8_t *data, size_t len) {
if (!data || len == 0) {
return false;
}
Wire.beginTransmission(DS3231_ADDR);
Wire.write(start_reg);
for (size_t i = 0; i < len; ++i) {
Wire.write(data[i]);
}
return Wire.endTransmission() == 0;
}
bool rtc_ds3231_init() {
Wire.begin(PIN_OLED_SDA, PIN_OLED_SCL);
Wire.setClock(100000);
return rtc_ds3231_is_present();
}
bool rtc_ds3231_is_present() {
Wire.beginTransmission(DS3231_ADDR);
return Wire.endTransmission() == 0;
}
bool rtc_ds3231_read_epoch(uint32_t &epoch_utc) {
uint8_t regs[7] = {};
if (!read_registers(0x00, regs, sizeof(regs))) {
return false;
}
uint8_t sec = bcd_to_dec(regs[0] & 0x7F);
uint8_t min = bcd_to_dec(regs[1] & 0x7F);
uint8_t hour = bcd_to_dec(regs[2] & 0x3F);
uint8_t day = bcd_to_dec(regs[4] & 0x3F);
uint8_t month = bcd_to_dec(regs[5] & 0x1F);
uint16_t year = 2000 + bcd_to_dec(regs[6]);
struct tm tm_utc = {};
tm_utc.tm_sec = sec;
tm_utc.tm_min = min;
tm_utc.tm_hour = hour;
tm_utc.tm_mday = day;
tm_utc.tm_mon = month - 1;
tm_utc.tm_year = year - 1900;
tm_utc.tm_isdst = 0;
time_t t = timegm_fallback(&tm_utc);
if (t <= 0) {
return false;
}
epoch_utc = static_cast<uint32_t>(t);
return true;
}
bool rtc_ds3231_set_epoch(uint32_t epoch_utc) {
time_t t = static_cast<time_t>(epoch_utc);
struct tm tm_utc = {};
if (!gmtime_r(&t, &tm_utc)) {
return false;
}
uint8_t regs[7] = {};
regs[0] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_sec));
regs[1] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_min));
regs[2] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_hour));
regs[3] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_wday + 1));
regs[4] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_mday));
regs[5] = dec_to_bcd(static_cast<uint8_t>(tm_utc.tm_mon + 1));
regs[6] = dec_to_bcd(static_cast<uint8_t>((tm_utc.tm_year + 1900) - 2000));
return write_registers(0x00, regs, sizeof(regs));
}

128
src/sd_logger.cpp Normal file
View File

@@ -0,0 +1,128 @@
#include "sd_logger.h"
#include "config.h"
#include <SD.h>
#include <SPI.h>
#include <time.h>
static bool g_sd_ready = false;
static SPIClass *g_sd_spi = nullptr;
static const char *fault_text(FaultType fault) {
switch (fault) {
case FaultType::MeterRead:
return "meter";
case FaultType::Decode:
return "decode";
case FaultType::LoraTx:
return "loratx";
default:
return "";
}
}
static bool ensure_dir(const String &path) {
if (SD.exists(path)) {
return true;
}
return SD.mkdir(path);
}
static String format_date_utc(uint32_t ts_utc) {
time_t t = static_cast<time_t>(ts_utc);
struct tm tm_utc;
gmtime_r(&t, &tm_utc);
char buf[16];
snprintf(buf, sizeof(buf), "%04d-%02d-%02d",
tm_utc.tm_year + 1900,
tm_utc.tm_mon + 1,
tm_utc.tm_mday);
return String(buf);
}
void sd_logger_init() {
if (!ENABLE_SD_LOGGING) {
g_sd_ready = false;
return;
}
if (!g_sd_spi) {
g_sd_spi = new SPIClass(HSPI);
}
g_sd_spi->begin(PIN_SD_SCK, PIN_SD_MISO, PIN_SD_MOSI, PIN_SD_CS);
g_sd_ready = SD.begin(PIN_SD_CS, *g_sd_spi);
if (SERIAL_DEBUG_MODE) {
if (g_sd_ready) {
uint8_t type = SD.cardType();
uint64_t size = SD.cardSize();
Serial.printf("sd: ok type=%u size=%llu\n", static_cast<unsigned>(type), static_cast<unsigned long long>(size));
} else {
Serial.println("sd: init failed");
}
}
}
bool sd_logger_is_ready() {
return g_sd_ready;
}
void sd_logger_log_sample(const MeterData &data, bool include_error_text) {
if (!g_sd_ready || data.ts_utc == 0) {
return;
}
String root_dir = "/dd3";
if (!ensure_dir(root_dir)) {
return;
}
String sender_dir = root_dir + "/" + String(data.device_id);
if (!ensure_dir(sender_dir)) {
return;
}
String filename = sender_dir + "/" + format_date_utc(data.ts_utc) + ".csv";
bool new_file = !SD.exists(filename);
File f = SD.open(filename, FILE_APPEND);
if (!f) {
return;
}
if (new_file) {
f.println("ts_utc,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last");
}
f.print(data.ts_utc);
f.print(',');
f.print(data.total_power_w, 1);
f.print(',');
f.print(data.phase_power_w[0], 1);
f.print(',');
f.print(data.phase_power_w[1], 1);
f.print(',');
f.print(data.phase_power_w[2], 1);
f.print(',');
f.print(data.energy_total_kwh, 3);
f.print(',');
f.print(data.battery_voltage_v, 2);
f.print(',');
f.print(data.battery_percent);
f.print(',');
f.print(data.link_rssi_dbm);
f.print(',');
if (isnan(data.link_snr_db)) {
f.print("");
} else {
f.print(data.link_snr_db, 1);
}
f.print(',');
f.print(data.err_meter_read);
f.print(',');
f.print(data.err_decode);
f.print(',');
f.print(data.err_lora_tx);
f.print(',');
if (include_error_text && data.last_error != FaultType::None) {
f.print(fault_text(data.last_error));
}
f.println();
f.close();
}

7
src/sd_logger.h Normal file
View File

@@ -0,0 +1,7 @@
#pragma once
#include "data_model.h"
void sd_logger_init();
bool sd_logger_is_ready();
void sd_logger_log_sample(const MeterData &data, bool include_error_text);

View File

@@ -2,9 +2,7 @@
#ifdef ENABLE_TEST_MODE #ifdef ENABLE_TEST_MODE
#include "config.h" #include "config.h"
#include "compressor.h"
#include "lora_transport.h" #include "lora_transport.h"
#include "json_codec.h"
#include "time_manager.h" #include "time_manager.h"
#include "display_ui.h" #include "display_ui.h"
#include "mqtt_client.h" #include "mqtt_client.h"
@@ -14,16 +12,94 @@
static uint32_t g_last_test_ms = 0; static uint32_t g_last_test_ms = 0;
static uint16_t g_test_code_counter = 1000; static uint16_t g_test_code_counter = 1000;
static uint32_t g_last_timesync_ms = 0; static uint16_t g_test_batch_id = 1;
static uint16_t g_test_last_acked_batch_id = 0;
static constexpr uint32_t TEST_SEND_INTERVAL_MS = 30000; static constexpr uint32_t TEST_SEND_INTERVAL_MS = 30000;
static constexpr uint32_t TEST_TIMESYNC_OFFSET_MS = 15000;
void test_sender_loop(uint16_t short_id, const char *device_id) { static void write_u16_be(uint8_t *dst, uint16_t value) {
LoraPacket rx = {}; dst[0] = static_cast<uint8_t>((value >> 8) & 0xFF);
if (lora_receive(rx, 0) && rx.payload_type == PayloadType::TimeSync) { dst[1] = static_cast<uint8_t>(value & 0xFF);
time_handle_timesync_payload(rx.payload, rx.payload_len); }
static uint16_t read_u16_be(const uint8_t *src) {
return static_cast<uint16_t>(src[0] << 8) | static_cast<uint16_t>(src[1]);
}
static void write_u32_be(uint8_t *dst, uint32_t value) {
dst[0] = static_cast<uint8_t>((value >> 24) & 0xFF);
dst[1] = static_cast<uint8_t>((value >> 16) & 0xFF);
dst[2] = static_cast<uint8_t>((value >> 8) & 0xFF);
dst[3] = static_cast<uint8_t>(value & 0xFF);
}
static uint32_t read_u32_be(const uint8_t *src) {
return (static_cast<uint32_t>(src[0]) << 24) |
(static_cast<uint32_t>(src[1]) << 16) |
(static_cast<uint32_t>(src[2]) << 8) |
static_cast<uint32_t>(src[3]);
}
static uint32_t ack_window_ms() {
uint32_t air_ms = lora_airtime_ms(lora_frame_size(LORA_ACK_DOWN_PAYLOAD_LEN));
uint32_t window_ms = air_ms + 300;
if (window_ms < 1200) {
window_ms = 1200;
}
if (window_ms > 4000) {
window_ms = 4000;
}
return window_ms;
}
static bool receive_ack_for_batch(uint16_t batch_id, uint8_t &time_valid, uint32_t &ack_epoch, int16_t &rssi_dbm, float &snr_db) {
LoraPacket ack_pkt = {};
uint32_t window_ms = ack_window_ms();
bool got_ack = lora_receive_window(ack_pkt, window_ms);
if (!got_ack) {
got_ack = lora_receive_window(ack_pkt, window_ms / 2);
}
if (!got_ack || ack_pkt.msg_kind != LoraMsgKind::AckDown || ack_pkt.payload_len < LORA_ACK_DOWN_PAYLOAD_LEN) {
return false;
} }
uint16_t ack_id = read_u16_be(&ack_pkt.payload[1]);
if (ack_id != batch_id) {
return false;
}
time_valid = ack_pkt.payload[0] & 0x01;
ack_epoch = read_u32_be(&ack_pkt.payload[3]);
rssi_dbm = ack_pkt.rssi_dbm;
snr_db = ack_pkt.snr_db;
return true;
}
static void send_test_ack(uint16_t self_short_id, uint16_t batch_id, uint8_t &time_valid, uint32_t &ack_epoch) {
ack_epoch = time_get_utc();
time_valid = (time_is_synced() && ack_epoch >= MIN_ACCEPTED_EPOCH_UTC) ? 1 : 0;
if (!time_valid) {
ack_epoch = 0;
}
LoraPacket ack = {};
ack.msg_kind = LoraMsgKind::AckDown;
ack.device_id_short = self_short_id;
ack.payload_len = LORA_ACK_DOWN_PAYLOAD_LEN;
ack.payload[0] = time_valid;
write_u16_be(&ack.payload[1], batch_id);
write_u32_be(&ack.payload[3], ack_epoch);
uint8_t repeats = ACK_REPEAT_COUNT == 0 ? 1 : ACK_REPEAT_COUNT;
for (uint8_t i = 0; i < repeats; ++i) {
lora_send(ack);
if (i + 1 < repeats && ACK_REPEAT_DELAY_MS > 0) {
delay(ACK_REPEAT_DELAY_MS);
}
}
lora_receive_continuous();
}
void test_sender_loop(uint16_t short_id, const char *device_id) {
if (millis() - g_last_test_ms < TEST_SEND_INTERVAL_MS) { if (millis() - g_last_test_ms < TEST_SEND_INTERVAL_MS) {
return; return;
} }
@@ -45,11 +121,13 @@ void test_sender_loop(uint16_t short_id, const char *device_id) {
uint32_t now_utc = time_get_utc(); uint32_t now_utc = time_get_utc();
uint32_t ts = now_utc > 0 ? now_utc : millis() / 1000; uint32_t ts = now_utc > 0 ? now_utc : millis() / 1000;
StaticJsonDocument<128> doc; StaticJsonDocument<192> doc;
doc["id"] = device_id; doc["id"] = device_id;
doc["role"] = "sender"; doc["role"] = "sender";
doc["test_code"] = code; doc["test_code"] = code;
doc["ts"] = ts; doc["ts"] = ts;
doc["batch_id"] = g_test_batch_id;
doc["last_acked"] = g_test_last_acked_batch_id;
char bat_buf[8]; char bat_buf[8];
snprintf(bat_buf, sizeof(bat_buf), "%.2f", data.battery_voltage_v); snprintf(bat_buf, sizeof(bat_buf), "%.2f", data.battery_voltage_v);
doc["bat_v"] = serialized(bat_buf); doc["bat_v"] = serialized(bat_buf);
@@ -60,58 +138,71 @@ void test_sender_loop(uint16_t short_id, const char *device_id) {
data.ts_utc = ts; data.ts_utc = ts;
display_set_last_meter(data); display_set_last_meter(data);
uint8_t compressed[LORA_MAX_PAYLOAD]; if (json.length() > LORA_MAX_PAYLOAD) {
size_t compressed_len = 0;
if (!compressBuffer(reinterpret_cast<const uint8_t *>(json.c_str()), json.length(), compressed, sizeof(compressed), compressed_len)) {
return; return;
} }
LoraPacket pkt = {}; LoraPacket pkt = {};
pkt.protocol_version = PROTOCOL_VERSION; pkt.msg_kind = LoraMsgKind::BatchUp;
pkt.role = DeviceRole::Sender;
pkt.device_id_short = short_id; pkt.device_id_short = short_id;
pkt.payload_type = PayloadType::TestCode; pkt.payload_len = json.length();
pkt.payload_len = compressed_len; memcpy(pkt.payload, json.c_str(), pkt.payload_len);
memcpy(pkt.payload, compressed, compressed_len); if (!lora_send(pkt)) {
lora_send(pkt); return;
}
uint8_t time_valid = 0;
uint32_t ack_epoch = 0;
int16_t ack_rssi = 0;
float ack_snr = 0.0f;
if (receive_ack_for_batch(g_test_batch_id, time_valid, ack_epoch, ack_rssi, ack_snr)) {
if (time_valid == 1 && ack_epoch >= MIN_ACCEPTED_EPOCH_UTC) {
time_set_utc(ack_epoch);
}
g_test_last_acked_batch_id = g_test_batch_id;
g_test_batch_id++;
if (SERIAL_DEBUG_MODE) {
Serial.printf("test ack: batch=%u time_valid=%u epoch=%lu rssi=%d snr=%.1f\n",
static_cast<unsigned>(g_test_last_acked_batch_id),
static_cast<unsigned>(time_valid),
static_cast<unsigned long>(ack_epoch),
static_cast<int>(ack_rssi),
static_cast<double>(ack_snr));
}
}
} }
void test_receiver_loop(SenderStatus *statuses, uint8_t count, uint16_t self_short_id) { void test_receiver_loop(SenderStatus *statuses, uint8_t count, uint16_t self_short_id) {
if (g_last_timesync_ms == 0) {
g_last_timesync_ms = millis() - (TIME_SYNC_INTERVAL_SEC * 1000UL - TEST_TIMESYNC_OFFSET_MS);
}
if (millis() - g_last_timesync_ms > TIME_SYNC_INTERVAL_SEC * 1000UL) {
g_last_timesync_ms = millis();
time_send_timesync(self_short_id);
}
LoraPacket pkt = {}; LoraPacket pkt = {};
if (!lora_receive(pkt, 0)) { if (!lora_receive(pkt, 0)) {
return; return;
} }
if (pkt.payload_type != PayloadType::TestCode) { if (pkt.msg_kind != LoraMsgKind::BatchUp) {
return; return;
} }
uint8_t decompressed[160]; uint8_t decompressed[192];
size_t decompressed_len = 0; if (pkt.payload_len >= sizeof(decompressed)) {
if (!decompressBuffer(pkt.payload, pkt.payload_len, decompressed, sizeof(decompressed) - 1, decompressed_len)) {
return; return;
} }
if (decompressed_len >= sizeof(decompressed)) { memcpy(decompressed, pkt.payload, pkt.payload_len);
return; decompressed[pkt.payload_len] = '\0';
}
decompressed[decompressed_len] = '\0';
StaticJsonDocument<128> doc; StaticJsonDocument<192> doc;
if (deserializeJson(doc, reinterpret_cast<const char *>(decompressed)) != DeserializationError::Ok) { if (deserializeJson(doc, reinterpret_cast<const char *>(decompressed)) != DeserializationError::Ok) {
return; return;
} }
const char *id = doc["id"] | ""; const char *id = doc["id"] | "";
const char *code = doc["test_code"] | ""; const char *code = doc["test_code"] | "";
uint16_t batch_id = static_cast<uint16_t>(doc["batch_id"] | 0);
uint32_t ts = doc["ts"] | 0;
float bat_v = doc["bat_v"] | NAN; float bat_v = doc["bat_v"] | NAN;
uint8_t time_valid = 0;
uint32_t ack_epoch = 0;
send_test_ack(self_short_id, batch_id, time_valid, ack_epoch);
for (uint8_t i = 0; i < count; ++i) { for (uint8_t i = 0; i < count; ++i) {
if (strncmp(statuses[i].last_data.device_id, id, sizeof(statuses[i].last_data.device_id)) == 0) { if (strncmp(statuses[i].last_data.device_id, id, sizeof(statuses[i].last_data.device_id)) == 0) {
display_set_test_code_for_sender(i, code); display_set_test_code_for_sender(i, code);
@@ -119,12 +210,34 @@ void test_receiver_loop(SenderStatus *statuses, uint8_t count, uint16_t self_sho
statuses[i].last_data.battery_voltage_v = bat_v; statuses[i].last_data.battery_voltage_v = bat_v;
statuses[i].last_data.battery_percent = battery_percent_from_voltage(bat_v); statuses[i].last_data.battery_percent = battery_percent_from_voltage(bat_v);
} }
statuses[i].last_data.link_valid = true;
statuses[i].last_data.link_rssi_dbm = pkt.rssi_dbm;
statuses[i].last_data.link_snr_db = pkt.snr_db;
statuses[i].last_data.ts_utc = ts;
statuses[i].last_acked_batch_id = batch_id;
statuses[i].has_data = true; statuses[i].has_data = true;
statuses[i].last_update_ts_utc = time_get_utc(); statuses[i].last_update_ts_utc = time_get_utc();
break; break;
} }
} }
mqtt_publish_test(id, String(reinterpret_cast<const char *>(decompressed))); StaticJsonDocument<256> mqtt_doc;
mqtt_doc["id"] = id;
mqtt_doc["role"] = "receiver";
mqtt_doc["test_code"] = code;
mqtt_doc["ts"] = ts;
mqtt_doc["batch_id"] = batch_id;
mqtt_doc["acked_batch_id"] = batch_id;
if (!isnan(bat_v)) {
mqtt_doc["bat_v"] = bat_v;
}
mqtt_doc["rssi"] = pkt.rssi_dbm;
mqtt_doc["snr"] = pkt.snr_db;
mqtt_doc["time_valid"] = time_valid;
mqtt_doc["ack_epoch"] = ack_epoch;
String mqtt_payload;
serializeJson(mqtt_doc, mqtt_payload);
mqtt_publish_test(id, mqtt_payload);
} }
#endif #endif

View File

@@ -1,12 +1,8 @@
#include "time_manager.h" #include "time_manager.h"
#include "compressor.h"
#include "config.h"
#include "rtc_ds3231.h"
#include <time.h> #include <time.h>
static bool g_time_synced = false; static bool g_time_synced = false;
static bool g_tz_set = false; static bool g_tz_set = false;
static bool g_rtc_present = false;
static uint32_t g_last_sync_utc = 0; static uint32_t g_last_sync_utc = 0;
static void note_last_sync(uint32_t epoch) { static void note_last_sync(uint32_t epoch) {
@@ -55,59 +51,6 @@ void time_set_utc(uint32_t epoch) {
settimeofday(&tv, nullptr); settimeofday(&tv, nullptr);
g_time_synced = true; g_time_synced = true;
note_last_sync(epoch); note_last_sync(epoch);
if (g_rtc_present) {
rtc_ds3231_set_epoch(epoch);
}
}
bool time_send_timesync(uint16_t device_id_short) {
uint32_t epoch = time_get_utc();
if (epoch == 0) {
return false;
}
char payload_str[32];
snprintf(payload_str, sizeof(payload_str), "T:%lu", static_cast<unsigned long>(epoch));
uint8_t compressed[LORA_MAX_PAYLOAD];
size_t compressed_len = 0;
if (!compressBuffer(reinterpret_cast<const uint8_t *>(payload_str), strlen(payload_str), compressed, sizeof(compressed), compressed_len)) {
return false;
}
LoraPacket pkt = {};
pkt.protocol_version = PROTOCOL_VERSION;
pkt.role = DeviceRole::Receiver;
pkt.device_id_short = device_id_short;
pkt.payload_type = PayloadType::TimeSync;
pkt.payload_len = compressed_len;
memcpy(pkt.payload, compressed, compressed_len);
return lora_send(pkt);
}
bool time_handle_timesync_payload(const uint8_t *payload, size_t len) {
uint8_t decompressed[64];
size_t decompressed_len = 0;
if (!decompressBuffer(payload, len, decompressed, sizeof(decompressed), decompressed_len)) {
return false;
}
if (decompressed_len >= sizeof(decompressed)) {
return false;
}
decompressed[decompressed_len] = '\0';
if (decompressed_len < 3 || decompressed[0] != 'T' || decompressed[1] != ':') {
return false;
}
uint32_t epoch = static_cast<uint32_t>(strtoul(reinterpret_cast<const char *>(decompressed + 2), nullptr, 10));
if (epoch == 0) {
return false;
}
time_set_utc(epoch);
return true;
} }
void time_get_local_hhmm(char *out, size_t out_len) { void time_get_local_hhmm(char *out, size_t out_len) {
@@ -121,33 +64,6 @@ void time_get_local_hhmm(char *out, size_t out_len) {
snprintf(out, out_len, "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min); snprintf(out, out_len, "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
} }
void time_rtc_init() {
if (!ENABLE_DS3231) {
g_rtc_present = false;
return;
}
g_rtc_present = rtc_ds3231_init();
}
bool time_try_load_from_rtc() {
if (!g_rtc_present) {
return false;
}
if (time_is_synced()) {
return true;
}
uint32_t epoch = 0;
if (!rtc_ds3231_read_epoch(epoch) || epoch == 0) {
return false;
}
time_set_utc(epoch);
return true;
}
bool time_rtc_present() {
return g_rtc_present;
}
uint32_t time_get_last_sync_utc() { uint32_t time_get_last_sync_utc() {
return g_last_sync_utc; return g_last_sync_utc;
} }

View File

@@ -2,21 +2,110 @@
#include <WebServer.h> #include <WebServer.h>
#include "wifi_manager.h" #include "wifi_manager.h"
#include "config.h" #include "config.h"
#include "sd_logger.h"
#include "time_manager.h"
#include "html_util.h"
#include <SD.h>
#include <WiFi.h>
#include <time.h>
#include <new>
#include <limits.h>
#include <math.h>
#include <stdlib.h>
static WebServer server(80); static WebServer server(80);
static const SenderStatus *g_statuses = nullptr; static const SenderStatus *g_statuses = nullptr;
static uint8_t g_status_count = 0; static uint8_t g_status_count = 0;
static WifiMqttConfig g_config; static WifiMqttConfig g_config;
static bool g_is_ap = false; static bool g_is_ap = false;
static String g_web_user;
static String g_web_pass;
static const FaultCounters *g_sender_faults = nullptr; static const FaultCounters *g_sender_faults = nullptr;
static const FaultType *g_sender_last_errors = nullptr; static const FaultType *g_sender_last_errors = nullptr;
static MeterData g_last_batch[NUM_SENDERS][METER_BATCH_MAX_SAMPLES]; static MeterData g_last_batch[NUM_SENDERS][METER_BATCH_MAX_SAMPLES];
static uint8_t g_last_batch_count[NUM_SENDERS] = {}; static uint8_t g_last_batch_count[NUM_SENDERS] = {};
struct HistoryBin {
uint32_t ts;
float value;
uint32_t count;
};
enum class HistoryMode : uint8_t {
Avg = 0,
Max = 1
};
struct HistoryJob {
bool active;
bool done;
bool error;
String error_msg;
String device_id;
HistoryMode mode;
uint32_t start_ts;
uint32_t end_ts;
uint32_t res_sec;
uint32_t bins_count;
uint32_t bins_filled;
uint16_t day_index;
File file;
HistoryBin *bins;
};
static HistoryJob g_history = {};
static constexpr size_t SD_LIST_MAX_FILES = 200;
static constexpr size_t SD_DOWNLOAD_MAX_PATH = 160;
static int32_t round_power_w(float value) {
if (isnan(value)) {
return 0;
}
long rounded = lroundf(value);
if (rounded > INT32_MAX) {
return INT32_MAX;
}
if (rounded < INT32_MIN) {
return INT32_MIN;
}
return static_cast<int32_t>(rounded);
}
static bool auth_required() {
return g_is_ap ? WEB_AUTH_REQUIRE_AP : WEB_AUTH_REQUIRE_STA;
}
static const char *fault_text(FaultType fault) {
switch (fault) {
case FaultType::MeterRead:
return "meter";
case FaultType::Decode:
return "decode";
case FaultType::LoraTx:
return "loratx";
default:
return "none";
}
}
static bool ensure_auth() {
if (!auth_required()) {
return true;
}
const char *user = g_web_user.c_str();
const char *pass = g_web_pass.c_str();
if (server.authenticate(user, pass)) {
return true;
}
server.requestAuthentication(BASIC_AUTH, "DD3", "Authentication required");
return false;
}
static String html_header(const String &title) { static String html_header(const String &title) {
String safe_title = html_escape(title);
String h = "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"; String h = "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>";
h += "<title>" + title + "</title></head><body>"; h += "<title>" + safe_title + "</title></head><body>";
h += "<h2>" + title + "</h2>"; h += "<h2>" + safe_title + "</h2>";
return h; return h;
} }
@@ -37,9 +126,223 @@ static String format_faults(uint8_t idx) {
s += String(g_sender_faults[idx].lora_tx_fail); s += String(g_sender_faults[idx].lora_tx_fail);
s += " last:"; s += " last:";
s += String(static_cast<uint8_t>(g_sender_last_errors[idx])); s += String(static_cast<uint8_t>(g_sender_last_errors[idx]));
s += " (" + String(fault_text(g_sender_last_errors[idx])) + ")";
return s; return s;
} }
static bool sanitize_sd_download_path(String &path, String &error) {
path.trim();
if (path.length() == 0) {
error = "empty";
return false;
}
if (path.startsWith("dd3/")) {
path = "/" + path;
}
if (path.length() > SD_DOWNLOAD_MAX_PATH) {
error = "too_long";
return false;
}
if (!path.startsWith("/dd3/")) {
error = "prefix";
return false;
}
if (path.indexOf("..") >= 0) {
error = "dotdot";
return false;
}
if (path.indexOf('\\') >= 0) {
error = "backslash";
return false;
}
if (path.indexOf("//") >= 0) {
error = "repeated_slash";
return false;
}
return true;
}
static bool checkbox_checked(const char *name) {
if (!server.hasArg(name)) {
return false;
}
String val = server.arg(name);
return val == "on" || val == "true" || val == "1";
}
static bool sanitize_history_device_id(const String &input, String &out_device_id) {
if (sanitize_device_id(input, out_device_id)) {
return true;
}
if (g_statuses) {
for (uint8_t i = 0; i < g_status_count; ++i) {
String known = g_statuses[i].last_data.device_id;
if (input.equalsIgnoreCase(known) && sanitize_device_id(known, out_device_id)) {
return true;
}
}
}
return false;
}
static String sanitize_download_filename(const String &input, bool &clean) {
String out;
out.reserve(input.length());
clean = true;
for (size_t i = 0; i < input.length(); ++i) {
unsigned char c = static_cast<unsigned char>(input[i]);
if (c < 32 || c == 127 || c == '"' || c == '\\' || c == '/') {
out += '_';
clean = false;
continue;
}
out += static_cast<char>(c);
}
out.trim();
if (out.length() == 0) {
out = "download.bin";
clean = false;
}
return out;
}
static void history_reset() {
if (g_history.file) {
g_history.file.close();
}
if (g_history.bins) {
delete[] g_history.bins;
}
g_history = {};
}
static String history_date_from_epoch(uint32_t ts_utc) {
time_t t = static_cast<time_t>(ts_utc);
struct tm tm_utc;
gmtime_r(&t, &tm_utc);
char buf[16];
snprintf(buf, sizeof(buf), "%04d-%02d-%02d", tm_utc.tm_year + 1900, tm_utc.tm_mon + 1, tm_utc.tm_mday);
return String(buf);
}
static bool history_open_next_file() {
if (!g_history.active || g_history.done || g_history.error) {
return false;
}
if (g_history.file) {
g_history.file.close();
}
uint32_t day_ts = g_history.start_ts + static_cast<uint32_t>(g_history.day_index) * 86400UL;
if (day_ts > g_history.end_ts) {
g_history.done = true;
return false;
}
String path = String("/dd3/") + g_history.device_id + "/" + history_date_from_epoch(day_ts) + ".csv";
g_history.file = SD.open(path.c_str(), FILE_READ);
g_history.day_index++;
return true;
}
static bool history_parse_line(const char *line, uint32_t &ts_out, float &p_out) {
if (!line || line[0] < '0' || line[0] > '9') {
return false;
}
const char *comma = strchr(line, ',');
if (!comma) {
return false;
}
char ts_buf[16];
size_t ts_len = static_cast<size_t>(comma - line);
if (ts_len >= sizeof(ts_buf)) {
return false;
}
memcpy(ts_buf, line, ts_len);
ts_buf[ts_len] = '\0';
char *end = nullptr;
uint32_t ts = static_cast<uint32_t>(strtoul(ts_buf, &end, 10));
if (end == ts_buf) {
return false;
}
const char *p_start = comma + 1;
const char *p_end = strchr(p_start, ',');
char p_buf[16];
size_t p_len = p_end ? static_cast<size_t>(p_end - p_start) : strlen(p_start);
if (p_len >= sizeof(p_buf)) {
return false;
}
memcpy(p_buf, p_start, p_len);
p_buf[p_len] = '\0';
char *endp = nullptr;
float p = strtof(p_buf, &endp);
if (endp == p_buf) {
return false;
}
ts_out = ts;
p_out = p;
return true;
}
static void history_tick() {
if (!g_history.active || g_history.done || g_history.error) {
return;
}
if (!sd_logger_is_ready()) {
g_history.error = true;
g_history.error_msg = "sd_not_ready";
return;
}
uint32_t start_ms = millis();
while (millis() - start_ms < SD_HISTORY_TIME_BUDGET_MS) {
if (!g_history.file) {
if (!history_open_next_file()) {
if (g_history.done) {
g_history.active = false;
}
return;
}
}
if (!g_history.file.available()) {
g_history.file.close();
continue;
}
char line[160];
size_t n = g_history.file.readBytesUntil('\n', line, sizeof(line) - 1);
line[n] = '\0';
if (n == 0) {
continue;
}
uint32_t ts = 0;
float p = 0.0f;
if (!history_parse_line(line, ts, p)) {
continue;
}
if (ts < g_history.start_ts || ts > g_history.end_ts) {
continue;
}
uint32_t idx = (ts - g_history.start_ts) / g_history.res_sec;
if (idx >= g_history.bins_count) {
continue;
}
HistoryBin &bin = g_history.bins[idx];
if (bin.count == 0) {
bin.ts = g_history.start_ts + idx * g_history.res_sec;
bin.value = p;
bin.count = 1;
g_history.bins_filled++;
} else if (g_history.mode == HistoryMode::Avg) {
bin.value += p;
bin.count++;
} else {
if (p > bin.value) {
bin.value = p;
}
bin.count++;
}
}
}
static String render_sender_block(const SenderStatus &status) { static String render_sender_block(const SenderStatus &status) {
String s; String s;
s += "<div style='margin-bottom:10px;padding:6px;border:1px solid #ccc'>"; s += "<div style='margin-bottom:10px;padding:6px;border:1px solid #ccc'>";
@@ -52,24 +355,86 @@ static String render_sender_block(const SenderStatus &status) {
} }
} }
} }
s += "<strong>" + String(status.last_data.device_id) + "</strong>"; String device_id = status.last_data.device_id;
String device_id_safe = html_escape(device_id);
String device_id_url = url_encode_component(device_id);
s += "<strong><a href='/sender/" + device_id_url + "' target='_blank' rel='noopener noreferrer'>" + device_id_safe + "</a></strong>";
if (status.has_data && status.last_data.link_valid) { if (status.has_data && status.last_data.link_valid) {
s += " R:" + String(status.last_data.link_rssi_dbm) + " S:" + String(status.last_data.link_snr_db, 1); s += " RSSI:" + String(status.last_data.link_rssi_dbm) + " SNR:" + String(status.last_data.link_snr_db, 1);
}
if (status.has_data) {
s += " ack:" + String(status.last_acked_batch_id);
s += " err_tx:" + String(status.last_data.err_lora_tx);
s += " err_last:" + String(static_cast<uint8_t>(status.last_data.last_error));
s += " (" + String(fault_text(status.last_data.last_error)) + ")";
s += " rx_reject:" + String(status.last_data.rx_reject_reason);
s += " (" + String(rx_reject_reason_text(static_cast<RxRejectReason>(status.last_data.rx_reject_reason))) + ")";
} }
s += format_faults(idx); s += format_faults(idx);
s += "<br>"; s += "<br>";
if (!status.has_data) { if (!status.has_data) {
s += "No data"; s += "No data";
} else { } else {
s += "Energy: " + String(status.last_data.energy_total_kwh, 3) + " kWh<br>"; if (status.last_data.energy_multi) {
s += "Power: " + String(status.last_data.total_power_w, 1) + " W<br>"; s += "Energy1: " + String(status.last_data.energy_kwh_int[0]) + " kWh<br>";
s += "Battery: " + String(status.last_data.battery_voltage_v, 2) + " V (" + String(status.last_data.battery_percent) + ")"; s += "Energy2: " + String(status.last_data.energy_kwh_int[1]) + " kWh<br>";
if (status.last_data.energy_meter_count >= 3) {
s += "Energy3: " + String(status.last_data.energy_kwh_int[2]) + " kWh<br>";
}
} else {
s += "Energy: " + String(status.last_data.energy_total_kwh, 2) + " kWh<br>";
s += "Power: " + String(round_power_w(status.last_data.total_power_w)) + " W<br>";
s += "P1/P2/P3: " + String(round_power_w(status.last_data.phase_power_w[0])) + " / " +
String(round_power_w(status.last_data.phase_power_w[1])) + " / " +
String(round_power_w(status.last_data.phase_power_w[2])) + " W<br>";
}
s += "Battery: " + String(status.last_data.battery_percent) + "% (" + String(status.last_data.battery_voltage_v, 2) + " V)";
} }
s += "</div>"; s += "</div>";
return s; return s;
} }
static void append_sd_listing(String &html, const String &dir_path, uint8_t depth, size_t &count) {
if (count >= SD_LIST_MAX_FILES || depth > 4) {
return;
}
File dir = SD.open(dir_path.c_str());
if (!dir || !dir.isDirectory()) {
return;
}
File entry = dir.openNextFile();
while (entry && count < SD_LIST_MAX_FILES) {
String name = entry.name();
String full_path = name;
if (!full_path.startsWith(dir_path)) {
if (!dir_path.endsWith("/")) {
full_path = dir_path + "/" + name;
} else {
full_path = dir_path + name;
}
}
if (entry.isDirectory()) {
html += "<li><strong>" + html_escape(full_path) + "/</strong></li>";
append_sd_listing(html, full_path, depth + 1, count);
} else {
String href = full_path;
if (!href.startsWith("/")) {
href = "/" + href;
}
String href_enc = url_encode_component(href);
html += "<li><a href='/sd/download?path=" + href_enc + "' target='_blank' rel='noopener noreferrer'>" + html_escape(full_path) + "</a>";
html += " (" + String(entry.size()) + " bytes)</li>";
count++;
}
entry = dir.openNextFile();
}
dir.close();
}
static void handle_root() { static void handle_root() {
if (!ensure_auth()) {
return;
}
String html = html_header("DD3 Bridge Status"); String html = html_header("DD3 Bridge Status");
html += g_is_ap ? "<p>Mode: AP</p>" : "<p>Mode: STA</p>"; html += g_is_ap ? "<p>Mode: AP</p>" : "<p>Mode: STA</p>";
@@ -79,6 +444,18 @@ static void handle_root() {
} }
} }
if (sd_logger_is_ready()) {
html += "<h3>SD Files</h3><ul>";
size_t count = 0;
append_sd_listing(html, "/dd3", 0, count);
if (count >= SD_LIST_MAX_FILES) {
html += "<li>Listing truncated...</li>";
}
html += "</ul>";
} else {
html += "<p>SD: not ready</p>";
}
html += "<p><a href='/wifi'>Configure WiFi/MQTT/NTP</a></p>"; html += "<p><a href='/wifi'>Configure WiFi/MQTT/NTP</a></p>";
html += "<p><a href='/manual'>Manual</a></p>"; html += "<p><a href='/manual'>Manual</a></p>";
html += html_footer(); html += html_footer();
@@ -86,16 +463,26 @@ static void handle_root() {
} }
static void handle_wifi_get() { static void handle_wifi_get() {
if (!ensure_auth()) {
return;
}
String html = html_header("WiFi/MQTT Config"); String html = html_header("WiFi/MQTT Config");
html += "<form method='POST' action='/wifi'>"; html += "<form method='POST' action='/wifi'>";
html += "SSID: <input name='ssid' value='" + g_config.ssid + "'><br>"; html += "SSID: <input name='ssid' value='" + html_escape(g_config.ssid) + "'><br>";
html += "Password: <input name='pass' type='password' value='" + g_config.password + "'><br>"; html += "Password: <input name='pass' type='password'> ";
html += "MQTT Host: <input name='mqhost' value='" + g_config.mqtt_host + "'><br>"; html += "<label><input type='checkbox' name='clear_wifi_pass'> Clear password</label><br>";
html += "MQTT Host: <input name='mqhost' value='" + html_escape(g_config.mqtt_host) + "'><br>";
html += "MQTT Port: <input name='mqport' value='" + String(g_config.mqtt_port) + "'><br>"; html += "MQTT Port: <input name='mqport' value='" + String(g_config.mqtt_port) + "'><br>";
html += "MQTT User: <input name='mquser' value='" + g_config.mqtt_user + "'><br>"; html += "MQTT User: <input name='mquser' value='" + html_escape(g_config.mqtt_user) + "'><br>";
html += "MQTT Pass: <input name='mqpass' type='password' value='" + g_config.mqtt_pass + "'><br>"; html += "MQTT Pass: <input name='mqpass' type='password'> ";
html += "NTP Server 1: <input name='ntp1' value='" + g_config.ntp_server_1 + "'><br>"; html += "<label><input type='checkbox' name='clear_mqtt_pass'> Clear password</label><br>";
html += "NTP Server 2: <input name='ntp2' value='" + g_config.ntp_server_2 + "'><br>"; html += "NTP Server 1: <input name='ntp1' value='" + html_escape(g_config.ntp_server_1) + "'><br>";
html += "NTP Server 2: <input name='ntp2' value='" + html_escape(g_config.ntp_server_2) + "'><br>";
html += "<hr>";
html += "Web UI User: <input name='webuser' value='" + html_escape(g_config.web_user) + "'><br>";
html += "Web UI Pass: <input name='webpass' type='password'> ";
html += "<label><input type='checkbox' name='clear_web_pass'> Clear password</label><br>";
html += "<div style='font-size:12px;color:#666;'>Leaving password blank keeps the existing one.</div>";
html += "<button type='submit'>Save</button>"; html += "<button type='submit'>Save</button>";
html += "</form>"; html += "</form>";
html += html_footer(); html += html_footer();
@@ -103,15 +490,38 @@ static void handle_wifi_get() {
} }
static void handle_wifi_post() { static void handle_wifi_post() {
WifiMqttConfig cfg; if (!ensure_auth()) {
cfg.ntp_server_1 = "pool.ntp.org"; return;
cfg.ntp_server_2 = "time.nist.gov"; }
WifiMqttConfig cfg = g_config;
cfg.ntp_server_1 = g_config.ntp_server_1.length() > 0 ? g_config.ntp_server_1 : "pool.ntp.org";
cfg.ntp_server_2 = g_config.ntp_server_2.length() > 0 ? g_config.ntp_server_2 : "time.nist.gov";
cfg.ssid = server.arg("ssid"); cfg.ssid = server.arg("ssid");
cfg.password = server.arg("pass"); String wifi_pass = server.arg("pass");
if (checkbox_checked("clear_wifi_pass")) {
cfg.password = "";
} else if (wifi_pass.length() > 0) {
cfg.password = wifi_pass;
}
cfg.mqtt_host = server.arg("mqhost"); cfg.mqtt_host = server.arg("mqhost");
cfg.mqtt_port = static_cast<uint16_t>(server.arg("mqport").toInt()); cfg.mqtt_port = static_cast<uint16_t>(server.arg("mqport").toInt());
cfg.mqtt_user = server.arg("mquser"); cfg.mqtt_user = server.arg("mquser");
cfg.mqtt_pass = server.arg("mqpass"); String mqtt_pass = server.arg("mqpass");
if (checkbox_checked("clear_mqtt_pass")) {
cfg.mqtt_pass = "";
} else if (mqtt_pass.length() > 0) {
cfg.mqtt_pass = mqtt_pass;
}
String web_user = server.arg("webuser");
if (web_user.length() > 0) {
cfg.web_user = web_user;
}
String web_pass = server.arg("webpass");
if (checkbox_checked("clear_web_pass")) {
cfg.web_pass = "";
} else if (web_pass.length() > 0) {
cfg.web_pass = web_pass;
}
if (server.arg("ntp1").length() > 0) { if (server.arg("ntp1").length() > 0) {
cfg.ntp_server_1 = server.arg("ntp1"); cfg.ntp_server_1 = server.arg("ntp1");
} }
@@ -119,6 +529,9 @@ static void handle_wifi_post() {
cfg.ntp_server_2 = server.arg("ntp2"); cfg.ntp_server_2 = server.arg("ntp2");
} }
cfg.valid = true; cfg.valid = true;
g_config = cfg;
g_web_user = cfg.web_user;
g_web_pass = cfg.web_pass;
wifi_save_config(cfg); wifi_save_config(cfg);
server.send(200, "text/html", "<html><body>Saved. Rebooting...</body></html>"); server.send(200, "text/html", "<html><body>Saved. Rebooting...</body></html>");
delay(1000); delay(1000);
@@ -126,37 +539,103 @@ static void handle_wifi_post() {
} }
static void handle_sender() { static void handle_sender() {
if (!ensure_auth()) {
return;
}
if (!g_statuses) { if (!g_statuses) {
server.send(404, "text/plain", "No senders"); server.send(404, "text/plain", "No senders");
return; return;
} }
String uri = server.uri(); String uri = server.uri();
String device_id = uri.substring(String("/sender/").length()); String device_id = uri.substring(String("/sender/").length());
String device_id_url = url_encode_component(device_id);
for (uint8_t i = 0; i < g_status_count; ++i) { for (uint8_t i = 0; i < g_status_count; ++i) {
if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) { if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) {
String html = html_header("Sender " + device_id); String html = html_header("Sender " + device_id);
html += render_sender_block(g_statuses[i]); html += render_sender_block(g_statuses[i]);
html += "<h3>History (Power)</h3>";
html += "<div>";
html += "Days: <input id='hist_days' type='number' min='1' max='" + String(SD_HISTORY_MAX_DAYS) + "' value='7' style='width:60px'> ";
html += "Res(min): <input id='hist_res' type='number' min='" + String(SD_HISTORY_MIN_RES_MIN) + "' value='5' style='width:60px'> ";
html += "<select id='hist_mode'><option value='avg'>avg</option><option value='max'>max</option></select> ";
html += "<button onclick='drawHistory()'>Draw</button>";
html += "<div id='hist_status' style='font-size:12px;margin-top:4px;color:#666;'></div>";
html += "<canvas id='hist_canvas' width='320' height='140' style='width:100%;max-width:520px;border:1px solid #ccc;margin-top:6px;'></canvas>";
html += "</div>";
html += "<script>";
html += "const deviceId='" + device_id_url + "';";
html += "let histTimer=null;";
html += "function histStatus(msg){document.getElementById('hist_status').textContent=msg;}";
html += "function drawHistory(){";
html += "const days=document.getElementById('hist_days').value;";
html += "const res=document.getElementById('hist_res').value;";
html += "const mode=document.getElementById('hist_mode').value;";
html += "histStatus('Starting...');";
html += "fetch(`/history/start?device_id=${deviceId}&days=${days}&res=${res}&mode=${mode}`)";
html += ".then(r=>r.json()).then(j=>{";
html += "if(!j.ok){histStatus('Error: '+(j.error||'failed'));return;}";
html += "if(histTimer){clearInterval(histTimer);}";
html += "histTimer=setInterval(()=>fetchHistory(),1000);";
html += "fetchHistory();";
html += "});";
html += "}";
html += "function fetchHistory(){";
html += "fetch(`/history/data?device_id=${deviceId}`).then(r=>r.json()).then(j=>{";
html += "if(!j.ready){histStatus(j.error?('Error: '+j.error):('Processing... '+(j.progress||0)+'%'));return;}";
html += "if(histTimer){clearInterval(histTimer);histTimer=null;}";
html += "renderChart(j.series);";
html += "histStatus('Done');";
html += "});";
html += "}";
html += "function renderChart(series){";
html += "const canvas=document.getElementById('hist_canvas');";
html += "const w=canvas.clientWidth;const h=canvas.clientHeight;";
html += "canvas.width=w;canvas.height=h;";
html += "const ctx=canvas.getContext('2d');";
html += "ctx.clearRect(0,0,w,h);";
html += "if(!series||series.length===0){ctx.fillText('No data',10,20);return;}";
html += "let min=Infinity,max=-Infinity;";
html += "for(const p of series){if(p[1]===null)continue; if(p[1]<min)min=p[1]; if(p[1]>max)max=p[1];}";
html += "if(!isFinite(min)||!isFinite(max)){ctx.fillText('No data',10,20);return;}";
html += "if(min===max){min=0;}";
html += "ctx.strokeStyle='#333';ctx.lineWidth=1;ctx.beginPath();";
html += "let first=true;";
html += "for(let i=0;i<series.length;i++){";
html += "const v=series[i][1];";
html += "if(v===null)continue;";
html += "const x=(i/(series.length-1))* (w-2) + 1;";
html += "const y=h-2-((v-min)/(max-min))*(h-4);";
html += "if(first){ctx.moveTo(x,y);first=false;} else {ctx.lineTo(x,y);} }";
html += "ctx.stroke();";
html += "ctx.fillStyle='#666';ctx.fillText(min.toFixed(0)+'W',4,h-4);";
html += "ctx.fillText(max.toFixed(0)+'W',4,12);";
html += "}";
html += "</script>";
if (g_last_batch_count[i] > 0) { if (g_last_batch_count[i] > 0) {
html += "<h3>Last batch (" + String(g_last_batch_count[i]) + " samples)</h3>"; html += "<h3>Last batch (" + String(g_last_batch_count[i]) + " samples)</h3>";
html += "<table border='1' cellspacing='0' cellpadding='3'>"; html += "<table border='1' cellspacing='0' cellpadding='3'>";
html += "<tr><th>#</th><th>ts</th><th>e_kwh</th><th>p_w</th><th>p1_w</th><th>p2_w</th><th>p3_w</th>"; html += "<tr><th>#</th><th>ts</th><th>energy1_kwh</th><th>energy2_kwh</th><th>energy3_kwh</th><th>p_w</th><th>p1_w</th><th>p2_w</th><th>p3_w</th>";
html += "<th>bat_v</th><th>bat_pct</th><th>rssi</th><th>snr</th><th>err_tx</th><th>err_last</th></tr>"; html += "<th>bat_v</th><th>bat_pct</th><th>rssi</th><th>snr</th><th>err_tx</th><th>err_last</th><th>rx_reject</th></tr>";
for (uint8_t r = 0; r < g_last_batch_count[i]; ++r) { for (uint8_t r = 0; r < g_last_batch_count[i]; ++r) {
const MeterData &d = g_last_batch[i][r]; const MeterData &d = g_last_batch[i][r];
html += "<tr>"; html += "<tr>";
html += "<td>" + String(r) + "</td>"; html += "<td>" + String(r) + "</td>";
html += "<td>" + String(d.ts_utc) + "</td>"; html += "<td>" + String(d.ts_utc) + "</td>";
html += "<td>" + String(d.energy_total_kwh, 3) + "</td>"; html += "<td>" + String(d.energy_kwh_int[0]) + "</td>";
html += "<td>" + String(d.total_power_w, 1) + "</td>"; html += "<td>" + String(d.energy_kwh_int[1]) + "</td>";
html += "<td>" + String(d.phase_power_w[0], 1) + "</td>"; html += "<td>" + String(d.energy_kwh_int[2]) + "</td>";
html += "<td>" + String(d.phase_power_w[1], 1) + "</td>"; html += "<td>" + String(round_power_w(d.total_power_w)) + "</td>";
html += "<td>" + String(d.phase_power_w[2], 1) + "</td>"; html += "<td>" + String(round_power_w(d.phase_power_w[0])) + "</td>";
html += "<td>" + String(round_power_w(d.phase_power_w[1])) + "</td>";
html += "<td>" + String(round_power_w(d.phase_power_w[2])) + "</td>";
html += "<td>" + String(d.battery_voltage_v, 2) + "</td>"; html += "<td>" + String(d.battery_voltage_v, 2) + "</td>";
html += "<td>" + String(d.battery_percent) + "</td>"; html += "<td>" + String(d.battery_percent) + "</td>";
html += "<td>" + String(d.link_rssi_dbm) + "</td>"; html += "<td>" + String(d.link_rssi_dbm) + "</td>";
html += "<td>" + String(d.link_snr_db, 1) + "</td>"; html += "<td>" + String(d.link_snr_db, 1) + "</td>";
html += "<td>" + String(d.err_lora_tx) + "</td>"; html += "<td>" + String(d.err_lora_tx) + "</td>";
html += "<td>" + String(static_cast<uint8_t>(d.last_error)) + "</td>"; html += "<td>" + String(static_cast<uint8_t>(d.last_error)) + " (" + String(fault_text(d.last_error)) + ")</td>";
html += "<td>" + String(d.rx_reject_reason) + " (" +
String(rx_reject_reason_text(static_cast<RxRejectReason>(d.rx_reject_reason))) + ")</td>";
html += "</tr>"; html += "</tr>";
} }
html += "</table>"; html += "</table>";
@@ -170,22 +649,198 @@ static void handle_sender() {
} }
static void handle_manual() { static void handle_manual() {
if (!ensure_auth()) {
return;
}
String html = html_header("DD3 Manual"); String html = html_header("DD3 Manual");
html += "<ul>"; html += "<ul>";
html += "<li>Energy: total kWh since meter start.</li>"; html += "<li>Energy: total kWh since meter start.</li>";
html += "<li>Power: total active power in W.</li>"; html += "<li>Power: total active power in W.</li>";
html += "<li>P1/P2/P3: phase power in W.</li>"; html += "<li>P1/P2/P3: phase power in W.</li>";
html += "<li>bat_v: battery voltage (V), bat_pct: estimated percent.</li>"; html += "<li>Battery: percent with voltage in V.</li>";
html += "<li>RSSI/SNR: LoRa link quality from last packet.</li>"; html += "<li>RSSI/SNR: LoRa link quality from last packet.</li>";
html += "<li>err_tx: LoRa TX error count; err_last: last error code.</li>"; html += "<li>err_tx: sender-side LoRa TX error counter.</li>";
html += "<li>faults m/d/tx: meter read/decode/tx counters.</li>"; html += "<li>err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx).</li>";
html += "<li>rx_reject: last RX reject reason (0=None, 1=crc_fail, 2=invalid_msg_kind, 3=length_mismatch, 4=device_id_mismatch, 5=batch_id_mismatch).</li>";
html += "<li>faults m/d/tx: receiver-side counters (meter read fails, decode fails, LoRa TX fails).</li>";
html += "<li>faults last: last receiver-side error code (same mapping as err_last).</li>";
html += "</ul>"; html += "</ul>";
html += html_footer(); html += html_footer();
server.send(200, "text/html", html); server.send(200, "text/html", html);
} }
static void handle_history_start() {
if (!ensure_auth()) {
return;
}
if (!sd_logger_is_ready()) {
server.send(200, "application/json", "{\"ok\":false,\"error\":\"sd_not_ready\"}");
return;
}
if (!time_is_synced()) {
server.send(200, "application/json", "{\"ok\":false,\"error\":\"time_not_synced\"}");
return;
}
String device_id_arg = server.arg("device_id");
String device_id;
if (!sanitize_history_device_id(device_id_arg, device_id)) {
server.send(200, "application/json", "{\"ok\":false,\"error\":\"bad_device_id\"}");
return;
}
uint16_t days = static_cast<uint16_t>(server.arg("days").toInt());
uint16_t res_min = static_cast<uint16_t>(server.arg("res").toInt());
String mode_str = server.arg("mode");
if (device_id.length() == 0 || days == 0 || res_min == 0) {
server.send(200, "application/json", "{\"ok\":false,\"error\":\"bad_params\"}");
return;
}
if (days > SD_HISTORY_MAX_DAYS) {
days = SD_HISTORY_MAX_DAYS;
}
if (res_min < SD_HISTORY_MIN_RES_MIN) {
res_min = SD_HISTORY_MIN_RES_MIN;
}
uint32_t bins = (static_cast<uint32_t>(days) * 24UL * 60UL) / res_min;
if (bins == 0 || bins > SD_HISTORY_MAX_BINS) {
String resp = String("{\"ok\":false,\"error\":\"too_many_bins\",\"max_bins\":") + SD_HISTORY_MAX_BINS + "}";
server.send(200, "application/json", resp);
return;
}
history_reset();
g_history.active = true;
g_history.done = false;
g_history.error = false;
g_history.device_id = device_id;
g_history.mode = (mode_str == "max") ? HistoryMode::Max : HistoryMode::Avg;
g_history.res_sec = static_cast<uint32_t>(res_min) * 60UL;
g_history.bins_count = bins;
g_history.day_index = 0;
g_history.bins = new (std::nothrow) HistoryBin[bins];
if (!g_history.bins) {
g_history.error = true;
g_history.error_msg = "oom";
server.send(200, "application/json", "{\"ok\":false,\"error\":\"oom\"}");
return;
}
for (uint32_t i = 0; i < bins; ++i) {
g_history.bins[i] = {};
}
g_history.end_ts = time_get_utc();
uint32_t span = static_cast<uint32_t>(days) * 86400UL;
g_history.start_ts = g_history.end_ts > span ? (g_history.end_ts - span) : 0;
if (g_history.res_sec > 0) {
g_history.start_ts = (g_history.start_ts / g_history.res_sec) * g_history.res_sec;
}
String resp = String("{\"ok\":true,\"bins\":") + bins + "}";
server.send(200, "application/json", resp);
}
static void handle_history_data() {
if (!ensure_auth()) {
return;
}
String device_id_arg = server.arg("device_id");
String device_id;
if (!sanitize_history_device_id(device_id_arg, device_id)) {
server.send(200, "application/json", "{\"ready\":false,\"error\":\"bad_device_id\"}");
return;
}
if (!g_history.bins || device_id.length() == 0 || device_id != g_history.device_id) {
server.send(200, "application/json", "{\"ready\":false,\"error\":\"no_job\"}");
return;
}
if (g_history.error) {
String resp = String("{\"ready\":false,\"error\":\"") + g_history.error_msg + "\"}";
server.send(200, "application/json", resp);
return;
}
if (g_history.active && !g_history.done) {
uint32_t progress = g_history.bins_count == 0 ? 0 : (g_history.bins_filled * 100UL / g_history.bins_count);
String resp = String("{\"ready\":false,\"progress\":") + progress + "}";
server.send(200, "application/json", resp);
return;
}
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "application/json", "");
server.sendContent("{\"ready\":true,\"series\":[");
bool first = true;
for (uint32_t i = 0; i < g_history.bins_count; ++i) {
const HistoryBin &bin = g_history.bins[i];
if (!first) {
server.sendContent(",");
}
first = false;
float value = NAN;
if (bin.count > 0) {
value = (g_history.mode == HistoryMode::Avg) ? (bin.value / static_cast<float>(bin.count)) : bin.value;
}
if (bin.count == 0) {
server.sendContent(String("[") + bin.ts + ",null]");
} else {
int32_t rounded = round_power_w(value);
server.sendContent(String("[") + bin.ts + "," + String(rounded) + "]");
}
}
server.sendContent("]}");
}
static void handle_sd_download() {
if (!ensure_auth()) {
return;
}
if (!sd_logger_is_ready()) {
server.send(404, "text/plain", "SD not ready");
return;
}
String path = server.arg("path");
String error;
if (!sanitize_sd_download_path(path, error)) {
if (SERIAL_DEBUG_MODE) {
Serial.printf("sd: reject path '%s' reason=%s\n", path.c_str(), error.c_str());
}
server.send(400, "text/plain", "Invalid path");
return;
}
File f = SD.open(path.c_str(), FILE_READ);
if (!f) {
server.send(404, "text/plain", "Not found");
return;
}
size_t size = f.size();
String filename = path.substring(path.lastIndexOf('/') + 1);
bool name_clean = true;
(void)name_clean;
String safe_name = sanitize_download_filename(filename, name_clean);
String cd = "attachment; filename=\"" + safe_name + "\"; filename*=UTF-8''" + url_encode_component(safe_name);
server.sendHeader("Content-Disposition", cd);
server.setContentLength(size);
const char *content_type = "application/octet-stream";
if (filename.endsWith(".csv")) {
content_type = "text/csv";
} else if (filename.endsWith(".txt")) {
content_type = "text/plain";
}
server.send(200, content_type, "");
WiFiClient client = server.client();
uint8_t buf[512];
while (f.available()) {
size_t n = f.read(buf, sizeof(buf));
if (n == 0) {
break;
}
client.write(buf, n);
delay(0);
}
f.close();
}
void web_server_set_config(const WifiMqttConfig &config) { void web_server_set_config(const WifiMqttConfig &config) {
g_config = config; g_config = config;
g_web_user = config.web_user;
g_web_pass = config.web_pass;
} }
void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors) { void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors) {
@@ -213,6 +868,9 @@ void web_server_begin_ap(const SenderStatus *statuses, uint8_t count) {
server.on("/", handle_root); server.on("/", handle_root);
server.on("/manual", handle_manual); server.on("/manual", handle_manual);
server.on("/history/start", handle_history_start);
server.on("/history/data", handle_history_data);
server.on("/sd/download", handle_sd_download);
server.on("/wifi", HTTP_GET, handle_wifi_get); server.on("/wifi", HTTP_GET, handle_wifi_get);
server.on("/wifi", HTTP_POST, handle_wifi_post); server.on("/wifi", HTTP_POST, handle_wifi_post);
server.on("/sender/", handle_sender); server.on("/sender/", handle_sender);
@@ -234,6 +892,9 @@ void web_server_begin_sta(const SenderStatus *statuses, uint8_t count) {
server.on("/", handle_root); server.on("/", handle_root);
server.on("/manual", handle_manual); server.on("/manual", handle_manual);
server.on("/sender/", handle_sender); server.on("/sender/", handle_sender);
server.on("/history/start", handle_history_start);
server.on("/history/data", handle_history_data);
server.on("/sd/download", handle_sd_download);
server.on("/wifi", HTTP_GET, handle_wifi_get); server.on("/wifi", HTTP_GET, handle_wifi_get);
server.on("/wifi", HTTP_POST, handle_wifi_post); server.on("/wifi", HTTP_POST, handle_wifi_post);
server.onNotFound([]() { server.onNotFound([]() {
@@ -247,5 +908,6 @@ void web_server_begin_sta(const SenderStatus *statuses, uint8_t count) {
} }
void web_server_loop() { void web_server_loop() {
history_tick();
server.handleClient(); server.handleClient();
} }

View File

@@ -1,4 +1,5 @@
#include "wifi_manager.h" #include "wifi_manager.h"
#include "config.h"
#include <WiFi.h> #include <WiFi.h>
#include <esp_wifi.h> #include <esp_wifi.h>
@@ -10,9 +11,6 @@ void wifi_manager_init() {
bool wifi_load_config(WifiMqttConfig &config) { bool wifi_load_config(WifiMqttConfig &config) {
config.valid = prefs.getBool("valid", false); config.valid = prefs.getBool("valid", false);
if (!config.valid) {
return false;
}
config.ssid = prefs.getString("ssid", ""); config.ssid = prefs.getString("ssid", "");
config.password = prefs.getString("pass", ""); config.password = prefs.getString("pass", "");
config.mqtt_host = prefs.getString("mqhost", ""); config.mqtt_host = prefs.getString("mqhost", "");
@@ -21,6 +19,11 @@ bool wifi_load_config(WifiMqttConfig &config) {
config.mqtt_pass = prefs.getString("mqpass", ""); config.mqtt_pass = prefs.getString("mqpass", "");
config.ntp_server_1 = prefs.getString("ntp1", "pool.ntp.org"); config.ntp_server_1 = prefs.getString("ntp1", "pool.ntp.org");
config.ntp_server_2 = prefs.getString("ntp2", "time.nist.gov"); config.ntp_server_2 = prefs.getString("ntp2", "time.nist.gov");
config.web_user = prefs.getString("webuser", WEB_AUTH_DEFAULT_USER);
config.web_pass = prefs.getString("webpass", WEB_AUTH_DEFAULT_PASS);
if (!config.valid) {
return false;
}
return config.ssid.length() > 0 && config.mqtt_host.length() > 0; return config.ssid.length() > 0 && config.mqtt_host.length() > 0;
} }
@@ -34,6 +37,8 @@ bool wifi_save_config(const WifiMqttConfig &config) {
prefs.putString("mqpass", config.mqtt_pass); prefs.putString("mqpass", config.mqtt_pass);
prefs.putString("ntp1", config.ntp_server_1); prefs.putString("ntp1", config.ntp_server_1);
prefs.putString("ntp2", config.ntp_server_2); prefs.putString("ntp2", config.ntp_server_2);
prefs.putString("webuser", config.web_user);
prefs.putString("webpass", config.web_pass);
return true; return true;
} }

View File

@@ -0,0 +1,37 @@
#include <Arduino.h>
#include <unity.h>
#include "html_util.h"
static void test_html_escape_basic() {
TEST_ASSERT_EQUAL_STRING("", html_escape("").c_str());
TEST_ASSERT_EQUAL_STRING("plain", html_escape("plain").c_str());
TEST_ASSERT_EQUAL_STRING("a&amp;b", html_escape("a&b").c_str());
TEST_ASSERT_EQUAL_STRING("&lt;tag&gt;", html_escape("<tag>").c_str());
TEST_ASSERT_EQUAL_STRING("&quot;hi&quot;", html_escape("\"hi\"").c_str());
TEST_ASSERT_EQUAL_STRING("it&#39;s", html_escape("it's").c_str());
TEST_ASSERT_EQUAL_STRING("&amp;&lt;&gt;&quot;&#39;", html_escape("&<>\"'").c_str());
}
static void test_sanitize_device_id() {
String out;
TEST_ASSERT_TRUE(sanitize_device_id("F19C", out));
TEST_ASSERT_EQUAL_STRING("dd3-F19C", out.c_str());
TEST_ASSERT_TRUE(sanitize_device_id("dd3-f19c", out));
TEST_ASSERT_EQUAL_STRING("dd3-F19C", out.c_str());
TEST_ASSERT_FALSE(sanitize_device_id("F19G", out));
TEST_ASSERT_FALSE(sanitize_device_id("dd3-12", out));
TEST_ASSERT_FALSE(sanitize_device_id("dd3-12345", out));
TEST_ASSERT_FALSE(sanitize_device_id("../F19C", out));
TEST_ASSERT_FALSE(sanitize_device_id("dd3-%2f", out));
TEST_ASSERT_FALSE(sanitize_device_id("dd3-12/3", out));
TEST_ASSERT_FALSE(sanitize_device_id("dd3-12\\3", out));
}
void setup() {
UNITY_BEGIN();
RUN_TEST(test_html_escape_basic);
RUN_TEST(test_sanitize_device_id);
UNITY_END();
}
void loop() {}