Compare commits
14 Commits
lora-binar
...
lora-refac
| Author | SHA1 | Date | |
|---|---|---|---|
| 195cce2bcf | |||
| 382e506f14 | |||
| 64afa8621e | |||
| 7540af7d71 | |||
| 9d5f5ed513 | |||
| 3a1de36b75 | |||
|
|
595a31f278 | ||
| 290ca55b8b | |||
| f177e5562d | |||
| cb6929bdc1 | |||
| c3e5ba3a53 | |||
| 373667ab8a | |||
| f0503af8c7 | |||
| f08d9a34d3 |
470
README.md
470
README.md
@@ -1,419 +1,135 @@
|
|||||||
# 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 one IEC 62056-21 meter, batches samples, 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`).
|
||||||
- SCK: GPIO5
|
- Sender batches up to `30` samples and retries on missing ACK (`BATCH_MAX_RETRIES=2`, retry policy `Keep`).
|
||||||
- MISO: GPIO19
|
- Sender meter parsing is decoupled from LoRa ACK waits using a dedicated FreeRTOS reader task + queue (`src/main.cpp`).
|
||||||
- MOSI: GPIO27
|
- Receiver uses STA mode when config is valid, otherwise AP fallback with web config.
|
||||||
- NSS/CS: GPIO18
|
- No debug auto-reboot timer is active in normal firmware loops.
|
||||||
- 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
|
On-air frame format:
|
||||||
### 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
|
Transport is chunked (`batch_id`, `chunk_index`, `chunk_count`, `total_len`) and reassembled before payload decode.
|
||||||
light_sleep_until_next_event();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key sender functions**:
|
Payload codec (`src/payload_codec.cpp`) currently uses:
|
||||||
```cpp
|
- `kMagic=0xDDB3`
|
||||||
bool meter_read(MeterData &data); // parse OBIS fields
|
- `kSchema=2`
|
||||||
void read_battery(MeterData &data); // ADC -> volts + percent
|
- metadata: sender, batch, timestamp, interval, battery, fault counters
|
||||||
bool meterDataToJson(const MeterData&, String&);
|
- data arrays: `energy_wh[]`, `p1_w[]`, `p2_w[]`, `p3_w[]`
|
||||||
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)
|
`n == 0` is valid and used for sync request packets.
|
||||||
- WiFi STA connect using stored config; if not available/fails, starts AP.
|
|
||||||
- NTP sync (UTC) and local display in Europe/Berlin.
|
|
||||||
- Receives LoRa packets, verifies CRC16, decompresses 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)**:
|
### `AckDown` (7 bytes)
|
||||||
```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()) {
|
`[flags:1][batch_id_be:2][epoch_utc_be:4]`
|
||||||
time_send_timesync(self_short_id); // always every 60s (receiver is mains-powered)
|
|
||||||
}
|
|
||||||
|
|
||||||
mqtt_loop();
|
- `flags bit0`: `time_valid`
|
||||||
web_server_loop();
|
- Receiver repeats ACK (`ACK_REPEAT_COUNT=3`, `ACK_REPEAT_DELAY_MS=200`).
|
||||||
display_set_receiver_status(...);
|
- Sender accepts time only if `time_valid=1` and `epoch >= MIN_ACCEPTED_EPOCH_UTC` (`2026-02-01 00:00:00 UTC`).
|
||||||
display_tick();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Receiver keeps the SX1276 in continuous RX, re-entering RX after any transmit (ACK or time sync).
|
## Time Bootstrap Guardrail
|
||||||
|
|
||||||
**Key receiver functions**:
|
On sender boot:
|
||||||
```cpp
|
- `g_time_acquired=false`
|
||||||
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms);
|
- no normal sampling/transmit yet
|
||||||
bool jsonToMeterData(const String &json, MeterData &data);
|
- sync request every `SYNC_REQUEST_INTERVAL_MS` (15s)
|
||||||
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)
|
Only after valid ACK time is received:
|
||||||
Enabled by `-DENABLE_TEST_MODE` (see `platformio.ini` test environment).
|
- system time is set
|
||||||
|
- normal 1 Hz sampling and periodic LoRa batch transmit start
|
||||||
|
|
||||||
- Sender: sends 4-digit test code every ~30s in JSON.
|
This blocks pre-threshold timestamps from MQTT/SD paths.
|
||||||
- Receiver: shows last test code per sender and publishes to `/test` topic.
|
|
||||||
- Normal behavior is excluded from test builds.
|
|
||||||
|
|
||||||
**Test sender (pseudo-code)**:
|
Timezone handling:
|
||||||
```cpp
|
- Local time rendering uses `TIMEZONE_TZ` from `include/config.h`.
|
||||||
void test_sender_loop() {
|
- Default value is `CET-1CEST,M3.5.0/2,M10.5.0/3` and can be changed at compile time.
|
||||||
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)**:
|
## Sender Meter Path
|
||||||
```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 loop in `src/main.cpp`:
|
||||||
Packet layout:
|
|
||||||
|
|
||||||
```
|
- UART: `Serial2`, RX pin `GPIO34` (`PIN_METER_RX`), `9600 7E1`
|
||||||
[0] protocol_version (1)
|
- ESP32 RX buffer is enlarged to `8192` bytes to survive long LoRa blocking sections.
|
||||||
[1] role (0=sender, 1=receiver)
|
- Frame detection: starts at `'/'`, ends at `'!'`, timeout protection included (`METER_FRAME_TIMEOUT_MS=20000`).
|
||||||
[2..3] device_id_short (uint16)
|
- Parsing runs in a dedicated sender task and is handed to the main sender loop via queue.
|
||||||
[4] payload_type (0=meter, 1=test, 2=time_sync, 3=meter_batch, 4=ack)
|
- Parsed OBIS values:
|
||||||
[5..N-3] payload bytes (compressed JSON for MeterData, binary for MeterBatch/Test/TimeSync)
|
- `1-0:1.8.0` (total energy)
|
||||||
[N-2..N-1] CRC16 (bytes 0..N-3)
|
- `1-0:16.7.0` (total power)
|
||||||
```
|
- `1-0:36.7.0`, `56.7.0`, `76.7.0` (phase powers)
|
||||||
|
- `1-0:1.8.0*Wh` is automatically scaled to kWh
|
||||||
|
|
||||||
LoRa radio settings:
|
Sender samples every second and transmits batches every 30 seconds.
|
||||||
- Frequency: **433 MHz** or **868 MHz** (set by build env via `LORA_FREQUENCY_HZ`)
|
|
||||||
- SF12, BW 125 kHz, CR 4/5, CRC on, Sync Word 0x34
|
|
||||||
- When `SERIAL_DEBUG_MODE` is enabled, LoRa TX logs include timing breakdowns for `idle/begin/write/end` to diagnose long transmit times.
|
|
||||||
|
|
||||||
## 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 samples, 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
|
State JSON fields (`src/json_codec.cpp`):
|
||||||
- Derived from WiFi STA MAC.
|
- `id`, `ts`, `e_kwh`
|
||||||
- `short_id = (MAC[4] << 8) | MAC[5]`
|
- `p_w`, `p1_w`, `p2_w`, `p3_w`
|
||||||
- `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 is enabled (`ENABLE_HA_DISCOVERY=true`) and publishes config topics under:
|
||||||
```cpp
|
- `homeassistant/sensor/<device_id>/<key>/config`
|
||||||
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
|
- Wi-Fi/MQTT/NTP/web-auth config persists in Preferences (`wifi_manager`).
|
||||||
- Sender disables WiFi/BLE, reads VBAT via ADC, and converts voltage to % using a LiPo curve:
|
- AP fallback SSID prefix: `DD3-Bridge-`.
|
||||||
- 4.2 V = 100%
|
- Default web credentials: `admin/admin`.
|
||||||
- 2.9 V = 0%
|
- SD logging 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 sender’s 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 JSON test frames and publishes to `smartmeter/<device_id>/test`.
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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;
|
||||||
@@ -57,17 +47,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;
|
||||||
@@ -99,6 +79,7 @@ constexpr uint16_t SD_HISTORY_MAX_DAYS = 30;
|
|||||||
constexpr uint16_t SD_HISTORY_MIN_RES_MIN = 1;
|
constexpr uint16_t SD_HISTORY_MIN_RES_MIN = 1;
|
||||||
constexpr uint16_t SD_HISTORY_MAX_BINS = 4000;
|
constexpr uint16_t SD_HISTORY_MAX_BINS = 4000;
|
||||||
constexpr uint16_t SD_HISTORY_TIME_BUDGET_MS = 10;
|
constexpr uint16_t SD_HISTORY_TIME_BUDGET_MS = 10;
|
||||||
|
constexpr const char *TIMEZONE_TZ = "CET-1CEST,M3.5.0/2,M10.5.0/3";
|
||||||
constexpr const char *AP_SSID_PREFIX = "DD3-Bridge-";
|
constexpr const char *AP_SSID_PREFIX = "DD3-Bridge-";
|
||||||
constexpr const char *AP_PASSWORD = "changeme123";
|
constexpr const char *AP_PASSWORD = "changeme123";
|
||||||
constexpr bool WEB_AUTH_REQUIRE_STA = true;
|
constexpr bool WEB_AUTH_REQUIRE_STA = true;
|
||||||
@@ -107,6 +88,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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,17 @@ 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);
|
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]);
|
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;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
#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 constexpr size_t STATE_JSON_DOC_CAPACITY = 512;
|
||||||
|
|
||||||
static float round2(float value) {
|
static float round2(float value) {
|
||||||
if (isnan(value)) {
|
if (isnan(value)) {
|
||||||
@@ -12,20 +12,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 +37,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 +48,28 @@ 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<STATE_JSON_DOC_CAPACITY> 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);
|
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;
|
||||||
@@ -122,197 +90,7 @@ bool meterDataToJson(const MeterData &data, String &out_json) {
|
|||||||
doc["rx_reject"] = data.rx_reject_reason;
|
doc["rx_reject"] = data.rx_reject_reason;
|
||||||
doc["rx_reject_text"] = rx_reject_reason_text(static_cast<RxRejectReason>(data.rx_reject_reason));
|
doc["rx_reject_text"] = rx_reject_reason_text(static_cast<RxRejectReason>(data.rx_reject_reason));
|
||||||
|
|
||||||
out_json = "";
|
|
||||||
size_t len = serializeJson(doc, out_json);
|
|
||||||
return len > 0 && len < 256;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = "";
|
out_json = "";
|
||||||
size_t len = serializeJson(doc, out_json);
|
size_t len = serializeJson(doc, out_json);
|
||||||
return len > 0;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
968
src/main.cpp
968
src/main.cpp
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@
|
|||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
static constexpr uint32_t METER_FRAME_TIMEOUT_MS = 1500;
|
// LoRa TX/RX windows can block the main loop for several seconds at SF12.
|
||||||
|
// Keep partial frame state long enough so valid telegrams are not dropped.
|
||||||
|
static constexpr uint32_t METER_FRAME_TIMEOUT_MS = 20000;
|
||||||
static constexpr size_t METER_FRAME_MAX = 512;
|
static constexpr size_t METER_FRAME_MAX = 512;
|
||||||
|
|
||||||
enum class MeterRxState : uint8_t {
|
enum class MeterRxState : uint8_t {
|
||||||
@@ -24,6 +26,10 @@ static uint32_t g_rx_timeout = 0;
|
|||||||
static uint32_t g_last_log_ms = 0;
|
static uint32_t g_last_log_ms = 0;
|
||||||
|
|
||||||
void meter_init() {
|
void meter_init() {
|
||||||
|
#ifdef ARDUINO_ARCH_ESP32
|
||||||
|
// Buffer enough serial data to survive long LoRa blocking sections.
|
||||||
|
Serial2.setRxBufferSize(8192);
|
||||||
|
#endif
|
||||||
Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1);
|
Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ 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) {
|
||||||
@@ -131,6 +131,11 @@ bool encode_batch(const BatchInput &in, uint8_t *out, size_t out_cap, size_t *ou
|
|||||||
out[pos++] = in.err_last;
|
out[pos++] = in.err_last;
|
||||||
out[pos++] = in.err_rx_reject;
|
out[pos++] = in.err_rx_reject;
|
||||||
|
|
||||||
|
if (in.n == 0) {
|
||||||
|
*out_len = pos;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!ensure_capacity(4, out_cap, pos)) {
|
if (!ensure_capacity(4, out_cap, pos)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -210,9 +215,18 @@ bool decode_batch(const uint8_t *buf, size_t len, BatchInput *out) {
|
|||||||
out->err_last = buf[pos++];
|
out->err_last = buf[pos++];
|
||||||
out->err_rx_reject = buf[pos++];
|
out->err_rx_reject = 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 (pos + 4 > len) {
|
if (pos + 4 > len) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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 "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 @@
|
|||||||
|
|
||||||
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 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) {
|
void test_sender_loop(uint16_t short_id, const char *device_id) {
|
||||||
LoraPacket rx = {};
|
|
||||||
if (lora_receive(rx, 0) && rx.payload_type == PayloadType::TimeSync) {
|
|
||||||
time_handle_timesync_payload(rx.payload, rx.payload_len);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (millis() - g_last_test_ms < TEST_SEND_INTERVAL_MS) {
|
if (millis() - g_last_test_ms < TEST_SEND_INTERVAL_MS) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -60,48 +51,34 @@ 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);
|
|
||||||
lora_send(pkt);
|
lora_send(pkt);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
(void)self_short_id;
|
||||||
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[160];
|
||||||
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<128> doc;
|
||||||
if (deserializeJson(doc, reinterpret_cast<const char *>(decompressed)) != DeserializationError::Ok) {
|
if (deserializeJson(doc, reinterpret_cast<const char *>(decompressed)) != DeserializationError::Ok) {
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
#include "time_manager.h"
|
#include "time_manager.h"
|
||||||
#include "compressor.h"
|
|
||||||
#include "config.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) {
|
||||||
@@ -18,15 +13,20 @@ static void note_last_sync(uint32_t epoch) {
|
|||||||
g_last_sync_utc = epoch;
|
g_last_sync_utc = epoch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void ensure_timezone_set() {
|
||||||
|
if (g_tz_set) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setenv("TZ", TIMEZONE_TZ, 1);
|
||||||
|
tzset();
|
||||||
|
g_tz_set = true;
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
const char *server1 = (ntp_server_1 && ntp_server_1[0] != '\0') ? ntp_server_1 : "pool.ntp.org";
|
const char *server1 = (ntp_server_1 && ntp_server_1[0] != '\0') ? ntp_server_1 : "pool.ntp.org";
|
||||||
const char *server2 = (ntp_server_2 && ntp_server_2[0] != '\0') ? ntp_server_2 : "time.nist.gov";
|
const char *server2 = (ntp_server_2 && ntp_server_2[0] != '\0') ? ntp_server_2 : "time.nist.gov";
|
||||||
configTime(0, 0, server1, server2);
|
configTime(0, 0, server1, server2);
|
||||||
if (!g_tz_set) {
|
ensure_timezone_set();
|
||||||
setenv("TZ", "CET-1CEST,M3.5.0/2,M10.5.0/3", 1);
|
|
||||||
tzset();
|
|
||||||
g_tz_set = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t time_get_utc() {
|
uint32_t time_get_utc() {
|
||||||
@@ -46,74 +46,13 @@ bool time_is_synced() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void time_set_utc(uint32_t epoch) {
|
void time_set_utc(uint32_t epoch) {
|
||||||
if (!g_tz_set) {
|
ensure_timezone_set();
|
||||||
setenv("TZ", "CET-1CEST,M3.5.0/2,M10.5.0/3", 1);
|
|
||||||
tzset();
|
|
||||||
g_tz_set = true;
|
|
||||||
}
|
|
||||||
struct timeval tv;
|
struct timeval tv;
|
||||||
tv.tv_sec = epoch;
|
tv.tv_sec = epoch;
|
||||||
tv.tv_usec = 0;
|
tv.tv_usec = 0;
|
||||||
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 +66,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,46 @@ 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 String format_utc_timestamp(uint32_t ts_utc) {
|
||||||
|
if (ts_utc == 0) {
|
||||||
|
return "n/a";
|
||||||
|
}
|
||||||
|
time_t t = static_cast<time_t>(ts_utc);
|
||||||
|
struct tm tm_utc;
|
||||||
|
gmtime_r(&t, &tm_utc);
|
||||||
|
char buf[32];
|
||||||
|
snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d UTC",
|
||||||
|
tm_utc.tm_year + 1900,
|
||||||
|
tm_utc.tm_mon + 1,
|
||||||
|
tm_utc.tm_mday,
|
||||||
|
tm_utc.tm_hour,
|
||||||
|
tm_utc.tm_min,
|
||||||
|
tm_utc.tm_sec);
|
||||||
|
return String(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t timestamp_age_seconds(uint32_t ts_utc) {
|
||||||
|
uint32_t now_utc = time_get_utc();
|
||||||
|
if (ts_utc == 0 || now_utc < ts_utc) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return now_utc - ts_utc;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +109,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";
|
||||||
}
|
}
|
||||||
@@ -360,10 +400,16 @@ 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>";
|
s += "Last update: " + format_utc_timestamp(status.last_update_ts_utc);
|
||||||
s += "Power: " + String(status.last_data.total_power_w, 1) + " W<br>";
|
if (time_is_synced()) {
|
||||||
s += "P1/P2/P3: " + String(status.last_data.phase_power_w[0], 1) + " / " + String(status.last_data.phase_power_w[1], 1) +
|
s += " (" + String(timestamp_age_seconds(status.last_update_ts_utc)) + "s ago)";
|
||||||
" / " + String(status.last_data.phase_power_w[2], 1) + " W<br>";
|
}
|
||||||
|
s += "<br>";
|
||||||
|
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>";
|
||||||
@@ -597,11 +643,11 @@ static void handle_sender() {
|
|||||||
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_total_kwh, 2) + "</td>";
|
||||||
html += "<td>" + String(d.total_power_w, 1) + "</td>";
|
html += "<td>" + String(round_power_w(d.total_power_w)) + "</td>";
|
||||||
html += "<td>" + String(d.phase_power_w[0], 1) + "</td>";
|
html += "<td>" + String(round_power_w(d.phase_power_w[0])) + "</td>";
|
||||||
html += "<td>" + String(d.phase_power_w[1], 1) + "</td>";
|
html += "<td>" + String(round_power_w(d.phase_power_w[1])) + "</td>";
|
||||||
html += "<td>" + String(d.phase_power_w[2], 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 +680,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 +800,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("]}");
|
||||||
|
|||||||
Reference in New Issue
Block a user