11 Commits

24 changed files with 935 additions and 1678 deletions

464
README.md
View File

@@ -1,419 +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 and transmit compact binary batches 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
- microSD (on-board)
- CS: GPIO13
- MOSI: GPIO15
- SCK: GPIO14
- MISO: GPIO2
- I2C RTC (DS3231)
- SDA: GPIO21
- SCL: GPIO22
- I2C address: 0x68
- Battery ADC: GPIO35 (via on-board divider)
- **Role select**: GPIO14 (INPUT_PULLDOWN, sampled at boot, **shared with SD SCK**)
- HIGH = Sender
- LOW/floating = Receiver
- **OLED control**: GPIO13 (INPUT_PULLDOWN, sender only, **shared with SD CS**)
- HIGH = force OLED on
- LOW = allow auto-off after timeout
- Not used on receiver (OLED always on)
- 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.
- GPIO14 is shared between role select and SD SCK. **Do not attach the role jumper in Receiver mode if the SD card is connected/used**, and never force GPIO14 high when using SD.
- GPIO13 is shared between OLED control and SD CS. Avoid driving OLED control when SD is active.
- Receiver firmware releases GPIO14 to `INPUT` (no pulldown) after boot before SD SPI init.
## 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
- Meter input is parsed via a non-blocking RX state machine; the last valid frame is reused for 1 Hz sampling.
- Reads battery voltage and estimates SoC.
- Builds compact binary batch payload, 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`:
payload = encode_batch(samples, batch_id); // compact binary batch - `0` = `BatchUp`
lora_send(packet(MeterBatch, payload)); - `1` = `AckDown`
}
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&); // MeterData only
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 MeterData JSON, decodes binary batches.
- 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) {
batch = reassemble_and_decode_batch(pkt);
for (sample in batch) {
update_sender_status(sample);
mqtt_publish_state(sample);
}
}
}
if (time_to_send_timesync()) { ### `AckDown` (7 bytes)
time_send_timesync(self_short_id); // always every 60s (receiver is mains-powered)
}
mqtt_loop(); `[flags:1][batch_id_be:2][epoch_utc_be:4]`
web_server_loop();
display_set_receiver_status(...);
display_tick();
}
```
Receiver keeps the SX1276 in continuous RX, re-entering RX after any transmit (ACK or time sync). - `flags bit0`: `time_valid`
- Receiver sends ACK repeatedly (`ACK_REPEAT_COUNT=3`, `ACK_REPEAT_DELAY_MS=200`).
- Sender accepts time only if `time_valid=1` and `epoch >= MIN_ACCEPTED_EPOCH_UTC` (`2026-02-01 00:00:00 UTC`).
**Key receiver functions**: ## Time Bootstrap Guardrail
```cpp
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms);
bool jsonToMeterData(const String &json, MeterData &data);
bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out);
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) On sender boot:
Enabled by `-DENABLE_TEST_MODE` (see `platformio.ini` test environment). - `g_time_acquired=false`
- only sync requests every `SYNC_REQUEST_INTERVAL_MS` (15s)
- no normal sampling/transmit until valid ACK time received
- Sender: sends 4-digit test code every ~30s in JSON. This prevents publishing/storing pre-threshold timestamps.
- Receiver: shows last test code per sender and publishes to `/test` topic.
- Normal behavior is excluded from test builds.
**Test sender (pseudo-code)**: ## Multi-Meter Sender Behavior
```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)**: Implemented in `src/meter_driver.cpp` + sender path in `src/main.cpp`:
```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 - Meter protocol: IEC 62056-21 ASCII, Mode D style framing (`/ ... !`)
Packet layout: - UART settings: `9600 7E1`
- Parsed OBIS: `1-0:1.8.0`
- Conversion: floor to integer kWh (`floorf`)
``` Meter count is build-dependent (`include/config.h`):
[0] protocol_version (1) - Debug builds (`SERIAL_DEBUG_MODE=1`): `METER_COUNT=2`
[1] role (0=sender, 1=receiver) - Prod builds (`SERIAL_DEBUG_MODE=0`): `METER_COUNT=3`
[2..3] device_id_short (uint16)
[4] payload_type (0=meter, 1=test, 2=time_sync, 3=meter_batch, 4=ack)
[5..N-3] payload bytes (compressed JSON for MeterData, binary for MeterBatch/Test/TimeSync)
[N-2..N-1] CRC16 (bytes 0..N-3)
```
LoRa radio settings: Default RX pins:
- Frequency: **433 MHz** or **868 MHz** (set by build env via `LORA_FREQUENCY_HZ`) - Meter1: `GPIO34` (`Serial2`)
- SF12, BW 125 kHz, CR 4/5, CRC on, Sync Word 0x34 - Meter2: `GPIO25` (`Serial1`)
- When `SERIAL_DEBUG_MODE` is enabled, LoRa TX logs include timing breakdowns for `idle/begin/write/end` to diagnose long transmit times. - Meter3: `GPIO3` (`Serial`, prod only because debug serial is disabled)
## Data Format ## Receiver Behavior
MeterData JSON (sender + MQTT):
```json For valid `BatchUp` decode:
{ 1. Reassemble chunks and decode payload.
"id": "F19C", 2. Send `AckDown` immediately.
"ts": 1737200000, 3. Drop duplicate batches per sender (`batch_id` tracking).
"e_kwh": 1234.57, 4. If `n==0`: treat as sync request only.
"p_w": 950.00, 5. Else convert to `MeterData`, log to SD, update web UI, publish MQTT.
"p1_w": 500.00,
"p2_w": 450.00,
"p3_w": 0.00,
"bat_v": 3.92,
"bat_pct": 78,
"rx_reject": 0,
"rx_reject_text": "none"
}
```
### Binary MeterBatch Payload (LoRa) ## MQTT Topics and Payloads
Fixed header (little-endian):
- `magic` u16 = 0xDDB3
- `schema` u8 = 2
- `flags` u8 = 0x01 (bit0 = signed phases)
- `sender_id` u16 (1..NUM_SENDERS, maps to `EXPECTED_SENDER_IDS`)
- `batch_id` u16
- `t_last` u32 (unix seconds of last sample)
- `dt_s` u8 (seconds, >0)
- `n` u8 (sample count, <=30)
- `battery_mV` u16
- `err_m` u8 (meter read failures, sender-side counter)
- `err_d` u8 (decode failures, sender-side counter)
- `err_tx` u8 (LoRa TX failures, sender-side counter)
- `err_last` u8 (last error code: 0=None, 1=MeterRead, 2=Decode, 3=LoraTx, 4=TimeSync)
- `err_rx_reject` u8 (last RX reject reason)
- `err_rx_reject` u8 (last RX reject reason: 0=None, 1=crc_fail, 2=bad_protocol_version, 3=wrong_role, 4=wrong_payload_type, 5=length_mismatch, 6=device_id_mismatch, 7=batch_id_mismatch)
- MQTT faults payload also includes `err_last_text` (string) and `err_last_age` (seconds).
Body: State topic:
- `E0` u32 (absolute energy in Wh) - `smartmeter/<device_id>/state`
- `dE[1..n-1]` ULEB128 (delta vs previous, >=0)
- `P1_0` s16 (absolute W)
- `dP1[1..n-1]` signed varint (ZigZag + ULEB128)
- `P2_0` s16
- `dP2[1..n-1]` signed varint
- `P3_0` s16
- `dP3[1..n-1]` signed varint
Notes: Fault topic (retained):
- Receiver reconstructs timestamps from `t_last` and `dt_s`. - `smartmeter/<device_id>/faults`
- Total power is computed on receiver as `p1 + p2 + p3`.
- Sender error counters are carried in the batch header and applied to all samples.
- Receiver ACKs MeterBatch as soon as the batch is reassembled, before MQTT/web/UI work, to avoid missing the sender ACK window.
- Receiver repeats ACKs (`ACK_REPEAT_COUNT`) spaced by `ACK_REPEAT_DELAY_MS` to cover sender RX latency.
- Sender ACK RX window is derived from LoRa airtime (bounded min/max) and retried once if the first window misses.
## Device IDs For `EnergyMulti` samples, state JSON includes:
- Derived from WiFi STA MAC. - `id`, `ts`
- `short_id = (MAC[4] << 8) | MAC[5]` - `energy1_kwh`, `energy2_kwh`, optional `energy3_kwh`
- `device_id = dd3-%04X` - `bat_v`, `bat_pct`
- JSON `id` uses only the last 4 hex digits (e.g., `F19C`) to save airtime. - optional link fields: `rssi`, `snr`
- fault/reject fields: `err_last`, `rx_reject`, `rx_reject_text` (+ non-zero counters)
Receiver expects known senders in `include/config.h` via: 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`.
```cpp
constexpr uint8_t NUM_SENDERS = 1;
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C };
```
## OLED Behavior ## Web UI, Wi-Fi, Storage
- Sender: OLED stays on for `OLED_AUTO_OFF_MS` after boot or last activity.
- Activity is detected while `PIN_OLED_CTRL` is held high, or on the high->low edge when the control is released.
- Receiver: OLED is always on (no auto-off).
- Pages rotate every 4s.
## Power & Battery - STA config is stored in Preferences (`wifi_manager`).
- Sender disables WiFi/BLE, reads VBAT via ADC, and converts voltage to % using a LiPo curve: - If STA/MQTT config is unavailable, receiver starts AP mode with SSID prefix `DD3-Bridge-`.
- 4.2 V = 100% - Web auth defaults are `admin/admin` (`WEB_AUTH_DEFAULT_USER/PASS`).
- 2.9 V = 0% - SD logging is enabled (`ENABLE_SD_LOGGING=true`).
- linear interpolation between curve points
- Uses deep sleep between cycles (`SENDER_WAKE_INTERVAL_SEC`).
- Sender CPU is throttled to 80 MHz and LoRa RX is only enabled in short windows (ACK wait or time-sync).
- Battery sampling averages 5 ADC reads and updates at most once per `BATTERY_SAMPLE_INTERVAL_MS` (default 60s).
- `BATTERY_CAL` applies a scale factor to match measured VBAT.
- When `SERIAL_DEBUG_MODE` is enabled, each ADC read logs the 5 raw samples, average, and computed voltage.
## Web UI
- AP SSID: `DD3-Bridge-<short_id>` (prefix configurable)
- AP password: `changeme123` (configurable)
- Endpoints:
- `/`: status overview
- `/wifi`: WiFi/MQTT/NTP config (AP and STA)
- `/sender/<device_id>`: per-sender details
- Sender IDs on `/` are clickable (open sender page in a new tab).
- In STA mode, the UI is also available via the board's IP/hostname on your WiFi network.
- Main page shows SD card file listing (downloadable).
- Sender page includes a history chart (power) with configurable range/resolution/mode.
## Security
- Basic Auth is supported for the web UI. In STA mode it is enabled by default; AP mode is optional.
- Config flags in `include/config.h`:
- `WEB_AUTH_REQUIRE_STA` (default `true`)
- `WEB_AUTH_REQUIRE_AP` (default `false`)
- `WEB_AUTH_DEFAULT_USER` / `WEB_AUTH_DEFAULT_PASS`
- Web credentials are stored in NVS. `/wifi`, `/sd/download`, `/history/*`, `/`, `/sender/*`, and `/manual` require auth when enabled.
- Password inputs are not prefilled. Leaving a password blank keeps the stored value; use the "clear password" checkbox to erase it.
- User-controlled strings are HTML-escaped before embedding in pages.
## 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`.
- Receiver keeps sending time sync every 60 seconds.
- If a senders timestamps drift from receiver time by more than `TIME_SYNC_DRIFT_THRESHOLD_SEC`, the receiver enters a burst mode (every `TIME_SYNC_BURST_INTERVAL_MS` for `TIME_SYNC_BURST_DURATION_MS`).
- Sender raises a local `TimeSync` error if it has not received a time beacon for `TIME_SYNC_ERROR_TIMEOUT_MS` (default 2 days). This is shown on the sender OLED only and is not sent over LoRa.
- RTC loads are validated (reject out-of-range epochs) so LoRa TimeSync can recover if the RTC is wrong.
- Sender uses a short “fast acquisition” mode on boot (until first LoRa TimeSync) with wider RX windows to avoid phase-miss.
## Build Environments ## Build Environments
- `lilygo-t3-v1-6-1`: production build (debug on)
- `lilygo-t3-v1-6-1-test`: test build with `ENABLE_TEST_MODE`
- `lilygo-t3-v1-6-1-868`: production build for 868 MHz modules (debug on)
- `lilygo-t3-v1-6-1-868-test`: test build for 868 MHz modules
- `lilygo-t3-v1-6-1-payload-test`: build with `PAYLOAD_CODEC_TEST`
- `lilygo-t3-v1-6-1-868-payload-test`: 868 MHz build with `PAYLOAD_CODEC_TEST`
- `lilygo-t3-v1-6-1-prod`: production build with serial debug off
- `lilygo-t3-v1-6-1-868-prod`: 868 MHz production build with serial debug off
## Config Knobs From `platformio.ini`:
Key timing settings in `include/config.h`:
- `METER_SAMPLE_INTERVAL_MS`
- `METER_SEND_INTERVAL_MS`
- `BATTERY_SAMPLE_INTERVAL_MS`
- `BATTERY_CAL`
- `BATCH_ACK_TIMEOUT_MS`
- `BATCH_MAX_RETRIES`
- `BATCH_QUEUE_DEPTH`
- `BATCH_RETRY_POLICY` (keep or drop on retry exhaustion)
- `SERIAL_DEBUG_MODE_FLAG` (build flag) / `SERIAL_DEBUG_DUMP_JSON`
- `LORA_SEND_BYPASS` (debug only)
- `ENABLE_SD_LOGGING` / `PIN_SD_CS`
- `SENDER_TIMESYNC_WINDOW_MS`
- `SENDER_TIMESYNC_CHECK_SEC_FAST` / `SENDER_TIMESYNC_CHECK_SEC_SLOW`
- `TIME_SYNC_DRIFT_THRESHOLD_SEC`
- `TIME_SYNC_BURST_INTERVAL_MS` / `TIME_SYNC_BURST_DURATION_MS`
- `TIME_SYNC_ERROR_TIMEOUT_MS`
- `SD_HISTORY_MAX_DAYS` / `SD_HISTORY_MIN_RES_MIN`
- `SD_HISTORY_MAX_BINS` / `SD_HISTORY_TIME_BUDGET_MS`
- `WEB_AUTH_REQUIRE_STA` / `WEB_AUTH_REQUIRE_AP` / `WEB_AUTH_DEFAULT_USER` / `WEB_AUTH_DEFAULT_PASS`
## Limits & Known Constraints - `lilygo-t3-v1-6-1`
- **Compression**: MeterData 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); binary batch frames are chunked and reassembled (typically 1 chunk). - `lilygo-t3-v1-6-1-868-test`
- **Battery ADC**: uses a divider (R44/R45 = 100K/100K) with a configurable `BATTERY_CAL` scale and LiPo % curve. - `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`
## SD Logging (Receiver) Example:
Optional CSV logging to microSD (FAT32) when `ENABLE_SD_LOGGING = true`.
- Path: `/dd3/<device_id>/YYYY-MM-DD.csv`
- Columns:
`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`
- `err_last` is written as text (`meter`, `decode`, `loratx`) only on the last sample of a batch that reports an error.
- Files are downloadable from the main UI page.
- Downloads only allow absolute paths under `/dd3/`, reject `..`, backslashes, and repeated slashes, and enforce a max path length.
- History chart on sender page stream-parses CSVs and bins data in the background.
- SD uses the on-board microSD SPI pins (CS=13, MOSI=15, SCK=14, MISO=2).
## Files & Modules
- `include/config.h`, `src/config.cpp`: pins, radio settings, sender IDs
- `include/data_model.h`, `src/data_model.cpp`: MeterData + ID init
- `include/json_codec.h`, `src/json_codec.cpp`: JSON encode/decode
- `include/compressor.h`, `src/compressor.cpp`: RLE compression
- `include/lora_transport.h`, `src/lora_transport.cpp`: LoRa packet + CRC
- `src/payload_codec.h`, `src/payload_codec.cpp`: binary batch encoder/decoder
- `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 GPIO14:
- LOW: sender
- HIGH: receiver
2. OLED control on GPIO13:
- 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;
@@ -42,7 +32,9 @@ constexpr uint8_t PIN_BAT_ADC = 35;
constexpr uint8_t PIN_ROLE = 14; constexpr uint8_t PIN_ROLE = 14;
constexpr uint8_t PIN_OLED_CTRL = 13; 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
@@ -57,17 +49,7 @@ 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 uint32_t SENDER_TIMESYNC_WINDOW_MS = 300;
constexpr uint32_t SENDER_TIMESYNC_CHECK_SEC_FAST = 60;
constexpr uint32_t SENDER_TIMESYNC_CHECK_SEC_SLOW = 3600;
constexpr uint32_t TIME_SYNC_DRIFT_THRESHOLD_SEC = 10;
constexpr uint32_t TIME_SYNC_BURST_INTERVAL_MS = 10000;
constexpr uint32_t TIME_SYNC_BURST_DURATION_MS = 10UL * 60UL * 1000UL;
constexpr uint32_t TIME_SYNC_ERROR_TIMEOUT_MS = 2UL * 24UL * 60UL * 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;
@@ -88,6 +70,9 @@ constexpr bool ENABLE_HA_DISCOVERY = true;
#define SERIAL_DEBUG_MODE_FLAG 0 #define SERIAL_DEBUG_MODE_FLAG 0
#endif #endif
constexpr bool SERIAL_DEBUG_MODE = SERIAL_DEBUG_MODE_FLAG != 0; 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 bool ENABLE_SD_LOGGING = true;
@@ -107,6 +92,7 @@ constexpr const char *WEB_AUTH_DEFAULT_USER = "admin";
constexpr const char *WEB_AUTH_DEFAULT_PASS = "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

@@ -6,19 +6,16 @@ enum class FaultType : uint8_t {
None = 0, None = 0,
MeterRead = 1, MeterRead = 1,
Decode = 2, Decode = 2,
LoraTx = 3, LoraTx = 3
TimeSync = 4
}; };
enum class RxRejectReason : uint8_t { enum class RxRejectReason : uint8_t {
None = 0, None = 0,
CrcFail = 1, CrcFail = 1,
BadProtocol = 2, InvalidMsgKind = 2,
WrongRole = 3, LengthMismatch = 3,
WrongPayloadType = 4, DeviceIdMismatch = 4,
LengthMismatch = 5, BatchIdMismatch = 5
DeviceIdMismatch = 6,
BatchIdMismatch = 7
}; };
struct FaultCounters { struct FaultCounters {
@@ -31,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;
@@ -50,6 +50,7 @@ struct MeterData {
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;
}; };

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

@@ -5,12 +5,23 @@
#include "data_model.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;
@@ -21,6 +32,7 @@ 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(); 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_idle();
void lora_sleep(); void lora_sleep();
void lora_receive_continuous(); void lora_receive_continuous();

View File

@@ -1,9 +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();
bool meter_poll_frame(const char *&frame, size_t &len); uint8_t meter_count();
bool meter_parse_frame(const char *frame, size_t len, MeterData &data); bool meter_get_last_energy_kwh(uint8_t meter_idx, uint32_t &out_energy_kwh);

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

@@ -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

@@ -13,12 +13,8 @@ const char *rx_reject_reason_text(RxRejectReason reason) {
switch (reason) { switch (reason) {
case RxRejectReason::CrcFail: case RxRejectReason::CrcFail:
return "crc_fail"; return "crc_fail";
case RxRejectReason::BadProtocol: case RxRejectReason::InvalidMsgKind:
return "bad_protocol_version"; return "invalid_msg_kind";
case RxRejectReason::WrongRole:
return "wrong_role";
case RxRejectReason::WrongPayloadType:
return "wrong_payload_type";
case RxRejectReason::LengthMismatch: case RxRejectReason::LengthMismatch:
return "length_mismatch"; return "length_mismatch";
case RxRejectReason::DeviceIdMismatch: case RxRejectReason::DeviceIdMismatch:

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);
@@ -161,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;
@@ -172,8 +188,6 @@ static bool render_last_error_line(uint8_t y) {
label = "decode"; label = "decode";
} else if (g_last_error == FaultType::LoraTx) { } else if (g_last_error == FaultType::LoraTx) {
label = "lora"; label = "lora";
} else if (g_last_error == FaultType::TimeSync) {
label = "timesync";
} }
display.setCursor(0, y); display.setCursor(0, y);
display.printf("Err: %s %lus", label, static_cast<unsigned long>(age_seconds(g_last_error_ts, g_last_error_ms))); display.printf("Err: %s %lus", label, static_cast<unsigned long>(age_seconds(g_last_error_ts, g_last_error_ms)));
@@ -238,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();
} }
@@ -336,17 +350,26 @@ 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.printf("E1 %lu E2 %lu", static_cast<unsigned long>(status.last_data.energy_kwh_int[0]),
static_cast<unsigned long>(status.last_data.energy_kwh_int[1]));
} else {
display.printf("E %.2f kWh", status.last_data.energy_total_kwh);
}
display.setCursor(0, 22); display.setCursor(0, 22);
display.printf("L1 %.0fW", status.last_data.phase_power_w[0]); if (status.last_data.energy_multi && status.last_data.energy_meter_count >= 3) {
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.setCursor(0, 32);
display.printf("L2 %.0fW", status.last_data.phase_power_w[1]); display.printf("L2 %dW", static_cast<int>(round_power_w(status.last_data.phase_power_w[1])));
display.setCursor(0, 42); display.setCursor(0, 42);
display.printf("L3 %.0fW", status.last_data.phase_power_w[2]); display.printf("L3 %dW", static_cast<int>(round_power_w(status.last_data.phase_power_w[2])));
display.setCursor(0, 52); display.setCursor(0, 52);
display.print("P"); display.print("P");
char p_buf[16]; char p_buf[16];
snprintf(p_buf, sizeof(p_buf), "%.0fW", status.last_data.total_power_w); 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 x1 = 0;
int16_t y1 = 0; int16_t y1 = 0;
uint16_t w = 0; uint16_t w = 0;

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];
format_float_2(buf, sizeof(buf), data.energy_total_kwh); if (data.energy_multi) {
doc["e_kwh"] = serialized(buf); doc["energy1_kwh"] = data.energy_kwh_int[0];
format_float_2(buf, sizeof(buf), data.total_power_w); doc["energy2_kwh"] = data.energy_kwh_int[1];
doc["p_w"] = serialized(buf); if (data.energy_meter_count >= 3) {
format_float_2(buf, sizeof(buf), data.phase_power_w[0]); doc["energy3_kwh"] = data.energy_kwh_int[2];
doc["p1_w"] = serialized(buf); }
format_float_2(buf, sizeof(buf), data.phase_power_w[1]); } else {
doc["p2_w"] = serialized(buf); format_float_2(buf, sizeof(buf), data.energy_total_kwh);
format_float_2(buf, sizeof(buf), data.phase_power_w[2]); doc["e_kwh"] = serialized(buf);
doc["p3_w"] = serialized(buf); set_int_or_null(doc, "p_w", data.total_power_w);
set_int_or_null(doc, "p1_w", data.phase_power_w[0]);
set_int_or_null(doc, "p2_w", data.phase_power_w[1]);
set_int_or_null(doc, "p3_w", data.phase_power_w[2]);
}
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;
@@ -124,195 +98,5 @@ bool meterDataToJson(const MeterData &data, String &out_json) {
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);
data.rx_reject_reason = static_cast<uint8_t>(doc["rx_reject"] | 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;
}
}
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

@@ -5,6 +5,9 @@
static RxRejectReason g_last_rx_reject_reason = RxRejectReason::None; static RxRejectReason g_last_rx_reject_reason = RxRejectReason::None;
static uint32_t g_last_rx_reject_log_ms = 0; 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) { static void note_reject(RxRejectReason reason) {
g_last_rx_reject_reason = reason; g_last_rx_reject_reason = reason;
@@ -23,6 +26,15 @@ RxRejectReason lora_get_last_rx_reject_reason() {
return reason; 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) {
@@ -65,13 +77,11 @@ bool lora_send(const LoraPacket &pkt) {
if (SERIAL_DEBUG_MODE) { if (SERIAL_DEBUG_MODE) {
t1 = millis(); t1 = millis();
} }
uint8_t buffer[5 + LORA_MAX_PAYLOAD + 2]; 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;
@@ -111,7 +121,11 @@ 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();
} }
@@ -119,13 +133,23 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
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); note_reject(RxRejectReason::LengthMismatch);
return false; return false;
} }
@@ -136,23 +160,22 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
note_reject(RxRejectReason::CrcFail); note_reject(RxRejectReason::CrcFail);
return false; return false;
} }
if (buffer[0] != PROTOCOL_VERSION) { uint8_t msg_kind = buffer[0];
note_reject(RxRejectReason::BadProtocol); 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); 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;
} }
@@ -179,6 +202,9 @@ bool lora_receive_window(LoraPacket &pkt, uint32_t timeout_ms) {
if (timeout_ms == 0) { if (timeout_ms == 0) {
return false; return false;
} }
g_last_rx_signal_valid = false;
g_last_rx_rssi_dbm = 0;
g_last_rx_snr_db = 0.0f;
LoRa.receive(); LoRa.receive();
bool got = lora_receive(pkt, timeout_ms); bool got = lora_receive(pkt, timeout_ms);
LoRa.sleep(); LoRa.sleep();

File diff suppressed because it is too large Load Diff

View File

@@ -12,20 +12,23 @@ enum class MeterRxState : uint8_t {
InFrame = 1 InFrame = 1
}; };
static MeterRxState g_rx_state = MeterRxState::WaitStart; struct MeterPort {
static char g_frame_buf[METER_FRAME_MAX + 1]; HardwareSerial *serial;
static size_t g_frame_len = 0; MeterRxState state;
static uint32_t g_last_rx_ms = 0; char frame_buf[METER_FRAME_MAX + 1];
static uint32_t g_bytes_rx = 0; size_t frame_len;
static uint32_t g_frames_ok = 0; uint32_t last_rx_ms;
static uint32_t g_frames_parse_fail = 0; uint32_t bytes_rx;
static uint32_t g_rx_overflow = 0; uint32_t frames_ok;
static uint32_t g_rx_timeout = 0; uint32_t frames_parse_fail;
static uint32_t g_last_log_ms = 0; uint32_t rx_overflow;
uint32_t rx_timeout;
uint32_t last_energy_kwh;
bool has_energy;
};
void meter_init() { static MeterPort g_ports[METER_COUNT] = {};
Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1); 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);
@@ -62,34 +65,30 @@ 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); char line[128];
if (!p) { size_t line_len = 0;
return false; for (size_t i = 0; i < len; ++i) {
} char c = frame[i];
const char *asterisk = strchr(p, '*'); if (c == '\r') {
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; continue;
} }
unit_buf[ulen++] = *c; if (c == '\n' || c == '!') {
} line[line_len] = '\0';
unit_buf[ulen] = '\0'; float value = NAN;
if (ulen == 0) { if (parse_obis_ascii_value(line, "1-0:1.8.0", value) && !isnan(value) && value >= 0.0f) {
return false; out_kwh = static_cast<uint32_t>(floorf(value));
} return true;
if (strcmp(unit_buf, "Wh") == 0) { }
value *= 0.001f; line_len = 0;
return true; if (c == '!') {
break;
}
continue;
}
if (line_len + 1 < sizeof(line)) {
line[line_len++] = c;
}
} }
return false; return false;
} }
@@ -103,162 +102,105 @@ static void meter_debug_log() {
return; return;
} }
g_last_log_ms = now_ms; g_last_log_ms = now_ms;
Serial.printf("meter: ok=%lu parse_fail=%lu overflow=%lu timeout=%lu bytes=%lu\n", for (uint8_t i = 0; i < METER_COUNT; ++i) {
static_cast<unsigned long>(g_frames_ok), const MeterPort &p = g_ports[i];
static_cast<unsigned long>(g_frames_parse_fail), Serial.printf("meter%u: ok=%lu parse_fail=%lu overflow=%lu timeout=%lu bytes=%lu e=%lu valid=%u\n",
static_cast<unsigned long>(g_rx_overflow), static_cast<unsigned>(i + 1),
static_cast<unsigned long>(g_rx_timeout), static_cast<unsigned long>(p.frames_ok),
static_cast<unsigned long>(g_bytes_rx)); 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);
}
} }
bool meter_poll_frame(const char *&frame, size_t &len) { void meter_init() {
frame = nullptr; g_ports[0].serial = &Serial2;
len = 0; 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;
}
if (METER_COUNT >= 3) {
g_ports[2].serial = &Serial;
g_ports[2].serial->begin(9600, SERIAL_7E1, PIN_METER3_RX, -1);
g_ports[2].state = MeterRxState::WaitStart;
}
}
static void meter_poll_port(MeterPort &port) {
if (!port.serial) {
return;
}
uint32_t now_ms = millis(); uint32_t now_ms = millis();
if (port.state == MeterRxState::InFrame && (now_ms - port.last_rx_ms > METER_FRAME_TIMEOUT_MS)) {
if (g_rx_state == MeterRxState::InFrame && (now_ms - g_last_rx_ms > METER_FRAME_TIMEOUT_MS)) { port.rx_timeout++;
g_rx_timeout++; port.state = MeterRxState::WaitStart;
g_rx_state = MeterRxState::WaitStart; port.frame_len = 0;
g_frame_len = 0;
} }
while (Serial2.available()) { while (port.serial->available()) {
char c = static_cast<char>(Serial2.read()); char c = static_cast<char>(port.serial->read());
g_bytes_rx++; port.bytes_rx++;
g_last_rx_ms = now_ms; port.last_rx_ms = now_ms;
if (g_rx_state == MeterRxState::WaitStart) { if (port.state == MeterRxState::WaitStart) {
if (c == '/') { if (c == '/') {
g_rx_state = MeterRxState::InFrame; port.state = MeterRxState::InFrame;
g_frame_len = 0; port.frame_len = 0;
g_frame_buf[g_frame_len++] = c; port.frame_buf[port.frame_len++] = c;
} }
continue; continue;
} }
if (g_frame_len + 1 >= sizeof(g_frame_buf)) { if (port.frame_len + 1 >= sizeof(port.frame_buf)) {
g_rx_overflow++; port.rx_overflow++;
g_rx_state = MeterRxState::WaitStart; port.state = MeterRxState::WaitStart;
g_frame_len = 0; port.frame_len = 0;
continue; continue;
} }
g_frame_buf[g_frame_len++] = c; port.frame_buf[port.frame_len++] = c;
if (c == '!') { if (c == '!') {
g_frame_buf[g_frame_len] = '\0'; port.frame_buf[port.frame_len] = '\0';
frame = g_frame_buf; uint32_t energy_kwh = 0;
len = g_frame_len; if (parse_energy_kwh_floor(port.frame_buf, port.frame_len, energy_kwh)) {
g_rx_state = MeterRxState::WaitStart; port.last_energy_kwh = energy_kwh;
g_frame_len = 0; port.has_energy = true;
meter_debug_log(); port.frames_ok++;
return true;
}
}
meter_debug_log();
return false;
}
bool meter_parse_frame(const char *frame, size_t len, MeterData &data) {
if (!frame || len == 0) {
return 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];
size_t line_len = 0;
for (size_t i = 0; i < len; ++i) {
char c = frame[i];
if (c == '\r') {
continue;
}
if (c == '!') {
if (line_len + 1 < sizeof(line)) {
line[line_len++] = c;
}
line[line_len] = '\0';
data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok;
if (data.valid) {
g_frames_ok++;
} else { } else {
g_frames_parse_fail++; port.frames_parse_fail++;
} }
return data.valid; port.state = MeterRxState::WaitStart;
} port.frame_len = 0;
if (c == '\n') {
line[line_len] = '\0';
if (line[0] == '!') {
data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok;
if (data.valid) {
g_frames_ok++;
} else {
g_frames_parse_fail++;
}
return data.valid;
}
float value = NAN;
if (parse_obis_ascii_value(line, "1-0:1.8.0", value)) {
parse_obis_ascii_unit_scale(line, "1-0:1.8.0", value);
data.energy_total_kwh = value;
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;
continue;
}
if (line_len + 1 < sizeof(line)) {
line[line_len++] = c;
} }
} }
data.valid = got_any;
if (data.valid) {
g_frames_ok++;
} else {
g_frames_parse_fail++;
}
return data.valid;
} }
bool meter_read(MeterData &data) { void meter_poll() {
data.energy_total_kwh = NAN; for (uint8_t i = 0; i < METER_COUNT; ++i) {
data.total_power_w = NAN; meter_poll_port(g_ports[i]);
data.phase_power_w[0] = NAN; }
data.phase_power_w[1] = NAN; meter_debug_log();
data.phase_power_w[2] = NAN; }
data.valid = false;
const char *frame = nullptr; uint8_t meter_count() {
size_t len = 0; return METER_COUNT;
if (!meter_poll_frame(frame, len)) { }
bool meter_get_last_energy_kwh(uint8_t meter_idx, uint32_t &out_energy_kwh) {
if (meter_idx >= METER_COUNT) {
return false; return false;
} }
return meter_parse_frame(frame, len, data); if (!g_ports[meter_idx].has_energy) {
return false;
}
out_energy_kwh = g_ports[meter_idx].last_energy_kwh;
return true;
} }

View File

@@ -18,8 +18,6 @@ static const char *fault_text(FaultType fault) {
return "decode"; return "decode";
case FaultType::LoraTx: case FaultType::LoraTx:
return "loratx"; return "loratx";
case FaultType::TimeSync:
return "timesync";
default: default:
return "none"; return "none";
} }

View File

@@ -5,6 +5,8 @@ static constexpr uint16_t kMagic = 0xDDB3;
static constexpr uint8_t kSchema = 2; static constexpr uint8_t kSchema = 2;
static constexpr uint8_t kFlags = 0x01; static constexpr uint8_t kFlags = 0x01;
static constexpr size_t kMaxSamples = 30; 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) { static void write_u16_le(uint8_t *dst, uint16_t value) {
dst[0] = static_cast<uint8_t>(value & 0xFF); dst[0] = static_cast<uint8_t>(value & 0xFF);
@@ -101,20 +103,21 @@ bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *ou
if (!out || !out_len) { if (!out || !out_len) {
return false; return false;
} }
if (in.n == 0 || in.n > kMaxSamples) { if (in.n > kMaxSamples) {
return false; return false;
} }
if (in.dt_s == 0) { if (in.dt_s == 0) {
return false; return false;
} }
size_t pos = 0; size_t pos = 0;
if (!ensure_capacity(21, out_cap, pos)) { if (!ensure_capacity(23, out_cap, pos)) {
return false; return false;
} }
write_u16_le(&out[pos], kMagic); write_u16_le(&out[pos], kMagic);
pos += 2; pos += 2;
out[pos++] = kSchema; out[pos++] = kSchema;
out[pos++] = kFlags; out[pos++] = kFlags;
out[pos++] = in.schema_id;
write_u16_le(&out[pos], in.sender_id); write_u16_le(&out[pos], in.sender_id);
pos += 2; pos += 2;
write_u16_le(&out[pos], in.batch_id); write_u16_le(&out[pos], in.batch_id);
@@ -130,6 +133,31 @@ bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *ou
out[pos++] = in.err_tx; out[pos++] = in.err_tx;
out[pos++] = in.err_last; out[pos++] = in.err_last;
out[pos++] = in.err_rx_reject; 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)) { if (!ensure_capacity(4, out_cap, pos)) {
return false; return false;
@@ -184,7 +212,7 @@ bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) {
return false; return false;
} }
size_t pos = 0; size_t pos = 0;
if (len < 21) { if (len < 23) {
return false; return false;
} }
uint16_t magic = read_u16_le(&buf[pos]); uint16_t magic = read_u16_le(&buf[pos]);
@@ -194,6 +222,7 @@ bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) {
if (magic != kMagic || schema != kSchema || (flags & 0x01) == 0) { if (magic != kMagic || schema != kSchema || (flags & 0x01) == 0) {
return false; return false;
} }
out->schema_id = buf[pos++];
out->sender_id = read_u16_le(&buf[pos]); out->sender_id = read_u16_le(&buf[pos]);
pos += 2; pos += 2;
out->batch_id = read_u16_le(&buf[pos]); out->batch_id = read_u16_le(&buf[pos]);
@@ -209,10 +238,43 @@ bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) {
out->err_tx = buf[pos++]; out->err_tx = buf[pos++];
out->err_last = buf[pos++]; out->err_last = buf[pos++];
out->err_rx_reject = buf[pos++]; out->err_rx_reject = buf[pos++];
out->meter_count = buf[pos++];
if (out->n == 0 || out->n > kMaxSamples || out->dt_s == 0) { if (out->n > kMaxSamples || out->dt_s == 0) {
return false; 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) { if (pos + 4 > len) {
return false; return false;
} }
@@ -275,6 +337,7 @@ bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) {
#ifdef PAYLOAD_CODEC_TEST #ifdef PAYLOAD_CODEC_TEST
bool payload_codec_self_test() { bool payload_codec_self_test() {
BatchInput in = {}; BatchInput in = {};
in.schema_id = kPayloadSchemaLegacy;
in.sender_id = 1; in.sender_id = 1;
in.batch_id = 42; in.batch_id = 42;
in.t_last = 1700000000; in.t_last = 1700000000;
@@ -286,6 +349,7 @@ bool payload_codec_self_test() {
in.err_tx = 3; in.err_tx = 3;
in.err_last = 2; in.err_last = 2;
in.err_rx_reject = 1; in.err_rx_reject = 1;
in.meter_count = 0;
in.energy_wh[0] = 100000; in.energy_wh[0] = 100000;
in.energy_wh[1] = 100001; in.energy_wh[1] = 100001;
in.energy_wh[2] = 100050; in.energy_wh[2] = 100050;

View File

@@ -3,17 +3,22 @@
#include <Arduino.h> #include <Arduino.h>
struct BatchInput { struct BatchInput {
uint8_t schema_id;
uint16_t sender_id; uint16_t sender_id;
uint16_t batch_id; uint16_t batch_id;
uint32_t t_last; uint32_t t_last;
uint8_t dt_s; uint8_t dt_s;
uint8_t n; uint8_t n;
uint8_t meter_count;
uint16_t battery_mV; uint16_t battery_mV;
uint8_t err_m; uint8_t err_m;
uint8_t err_d; uint8_t err_d;
uint8_t err_tx; uint8_t err_tx;
uint8_t err_last; uint8_t err_last;
uint8_t err_rx_reject; 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]; uint32_t energy_wh[30];
int16_t p1_w[30]; int16_t p1_w[30];
int16_t p2_w[30]; int16_t p2_w[30];

View File

@@ -1,125 +0,0 @@
#include "rtc_ds3231.h"
#include "config.h"
#include <Wire.h>
#include <string>
#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);
}
const char *old_tz = getenv("TZ");
// getenv() may return a pointer into mutable storage that becomes invalid after setenv().
std::string old_tz_copy = old_tz ? old_tz : "";
setenv("TZ", "UTC0", 1);
tzset();
time_t t = mktime(tm_utc);
if (!old_tz_copy.empty()) {
setenv("TZ", old_tz_copy.c_str(), 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));
}

View File

@@ -15,8 +15,6 @@ static const char *fault_text(FaultType fault) {
return "decode"; return "decode";
case FaultType::LoraTx: case FaultType::LoraTx:
return "loratx"; return "loratx";
case FaultType::TimeSync:
return "timesync";
default: default:
return ""; return "";
} }

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,15 +1,9 @@
#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 constexpr uint32_t kMinValidEpoch = 1672531200UL; // 2023-01-01
static constexpr uint32_t kMaxValidEpoch = 4102444800UL; // 2100-01-01
static void note_last_sync(uint32_t epoch) { static void note_last_sync(uint32_t epoch) {
if (epoch == 0) { if (epoch == 0) {
@@ -57,63 +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);
bool ok = lora_send(pkt);
if (ok) {
lora_receive_continuous();
}
return ok;
}
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) {
@@ -127,43 +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) {
if (SERIAL_DEBUG_MODE) {
Serial.println("rtc: read failed");
}
return false;
}
bool valid = epoch >= kMinValidEpoch && epoch <= kMaxValidEpoch;
if (SERIAL_DEBUG_MODE) {
Serial.printf("rtc: epoch=%lu %s\n", static_cast<unsigned long>(epoch), valid ? "accepted" : "rejected");
}
if (!valid) {
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

@@ -9,6 +9,8 @@
#include <WiFi.h> #include <WiFi.h>
#include <time.h> #include <time.h>
#include <new> #include <new>
#include <limits.h>
#include <math.h>
#include <stdlib.h> #include <stdlib.h>
static WebServer server(80); static WebServer server(80);
@@ -55,6 +57,20 @@ static HistoryJob g_history = {};
static constexpr size_t SD_LIST_MAX_FILES = 200; static constexpr size_t SD_LIST_MAX_FILES = 200;
static constexpr size_t SD_DOWNLOAD_MAX_PATH = 160; 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() { static bool auth_required() {
return g_is_ap ? WEB_AUTH_REQUIRE_AP : WEB_AUTH_REQUIRE_STA; return g_is_ap ? WEB_AUTH_REQUIRE_AP : WEB_AUTH_REQUIRE_STA;
} }
@@ -67,8 +83,6 @@ static const char *fault_text(FaultType fault) {
return "decode"; return "decode";
case FaultType::LoraTx: case FaultType::LoraTx:
return "loratx"; return "loratx";
case FaultType::TimeSync:
return "timesync";
default: default:
return "none"; return "none";
} }
@@ -349,6 +363,7 @@ static String render_sender_block(const SenderStatus &status) {
s += " RSSI:" + String(status.last_data.link_rssi_dbm) + " SNR:" + 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) { if (status.has_data) {
s += " ack:" + String(status.last_acked_batch_id);
s += " err_tx:" + String(status.last_data.err_lora_tx); s += " err_tx:" + String(status.last_data.err_lora_tx);
s += " err_last:" + String(static_cast<uint8_t>(status.last_data.last_error)); s += " err_last:" + String(static_cast<uint8_t>(status.last_data.last_error));
s += " (" + String(fault_text(status.last_data.last_error)) + ")"; s += " (" + String(fault_text(status.last_data.last_error)) + ")";
@@ -360,10 +375,19 @@ static String render_sender_block(const SenderStatus &status) {
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 += "P1/P2/P3: " + String(status.last_data.phase_power_w[0], 1) + " / " + String(status.last_data.phase_power_w[1], 1) + s += "Energy2: " + String(status.last_data.energy_kwh_int[1]) + " kWh<br>";
" / " + String(status.last_data.phase_power_w[2], 1) + " W<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 += "Battery: " + String(status.last_data.battery_percent) + "% (" + String(status.last_data.battery_voltage_v, 2) + " V)";
} }
s += "</div>"; s += "</div>";
@@ -590,18 +614,20 @@ static void handle_sender() {
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><th>rx_reject</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>";
@@ -634,8 +660,8 @@ static void handle_manual() {
html += "<li>Battery: percent with voltage in V.</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: sender-side LoRa TX error counter.</li>"; html += "<li>err_tx: sender-side LoRa TX error counter.</li>";
html += "<li>err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx, 4=TimeSync).</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=bad_protocol_version, 3=wrong_role, 4=wrong_payload_type, 5=length_mismatch, 6=device_id_mismatch, 7=batch_id_mismatch).</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 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 += "<li>faults last: last receiver-side error code (same mapping as err_last).</li>";
html += "</ul>"; html += "</ul>";
@@ -754,7 +780,8 @@ static void handle_history_data() {
if (bin.count == 0) { if (bin.count == 0) {
server.sendContent(String("[") + bin.ts + ",null]"); server.sendContent(String("[") + bin.ts + ",null]");
} else { } else {
server.sendContent(String("[") + bin.ts + "," + String(value, 2) + "]"); int32_t rounded = round_power_w(value);
server.sendContent(String("[") + bin.ts + "," + String(rounded) + "]");
} }
} }
server.sendContent("]}"); server.sendContent("]}");