Compare commits
15 Commits
main
...
lora-savin
| Author | SHA1 | Date | |
|---|---|---|---|
| d27b68c1cc | |||
| 01f4494f00 | |||
| 50436cd0bb | |||
| a0080b249d | |||
| 876c572bb3 | |||
| 13b4025443 | |||
| 7f31b9dd95 | |||
| 660d1cde94 | |||
| f9bcfbd5f2 | |||
| fbd18b2e78 | |||
| b4344db828 | |||
| 22ed41b55c | |||
| 430b0d7054 | |||
| 16c65744e3 | |||
| 8fba67fcf3 |
70
README.md
70
README.md
@@ -45,7 +45,6 @@ Variants:
|
|||||||
- Energy total: 1-0:1.8.0*255
|
- Energy total: 1-0:1.8.0*255
|
||||||
- Total power: 1-0:16.7.0*255
|
- Total power: 1-0:16.7.0*255
|
||||||
- Phase power: 36.7 / 56.7 / 76.7
|
- Phase power: 36.7 / 56.7 / 76.7
|
||||||
- Phase voltage: 32.7 / 52.7 / 72.7
|
|
||||||
- Reads battery voltage and estimates SoC.
|
- Reads battery voltage and estimates SoC.
|
||||||
- Builds JSON payload, compresses, wraps in LoRa packet, transmits.
|
- Builds JSON payload, compresses, wraps in LoRa packet, transmits.
|
||||||
- Light sleeps between meter reads; batches are sent every 30s.
|
- Light sleeps between meter reads; batches are sent every 30s.
|
||||||
@@ -56,11 +55,11 @@ Variants:
|
|||||||
**Sender flow (pseudo-code)**:
|
**Sender flow (pseudo-code)**:
|
||||||
```cpp
|
```cpp
|
||||||
void sender_loop() {
|
void sender_loop() {
|
||||||
meter_read_every_second(); // SML/OBIS -> MeterData samples
|
meter_read_every_second(); // OBIS -> MeterData samples
|
||||||
read_battery(data); // VBAT + SoC
|
read_battery(data); // VBAT + SoC
|
||||||
|
|
||||||
if (time_to_send_batch()) {
|
if (time_to_send_batch()) {
|
||||||
json = meterBatchToJson(samples);
|
json = meterBatchToJson(samples, batch_id); // bat_v per batch, t_first/t_last included
|
||||||
compressed = compressBuffer(json);
|
compressed = compressBuffer(json);
|
||||||
lora_send(packet(MeterBatch, compressed));
|
lora_send(packet(MeterBatch, compressed));
|
||||||
}
|
}
|
||||||
@@ -77,7 +76,7 @@ void sender_loop() {
|
|||||||
|
|
||||||
**Key sender functions**:
|
**Key sender functions**:
|
||||||
```cpp
|
```cpp
|
||||||
bool meter_read(MeterData &data); // parse SML frame, set OBIS fields
|
bool meter_read(MeterData &data); // parse OBIS fields
|
||||||
void read_battery(MeterData &data); // ADC -> volts + percent
|
void read_battery(MeterData &data); // ADC -> volts + percent
|
||||||
bool meterDataToJson(const MeterData&, String&);
|
bool meterDataToJson(const MeterData&, String&);
|
||||||
bool compressBuffer(const uint8_t*, size_t, uint8_t*, size_t, size_t&);
|
bool compressBuffer(const uint8_t*, size_t, uint8_t*, size_t, size_t&);
|
||||||
@@ -89,10 +88,11 @@ bool lora_send(const LoraPacket &pkt); // add header + CRC16 and transmit
|
|||||||
- NTP sync (UTC) and local display in Europe/Berlin.
|
- NTP sync (UTC) and local display in Europe/Berlin.
|
||||||
- Receives LoRa packets, verifies CRC16, decompresses, parses JSON.
|
- Receives LoRa packets, verifies CRC16, decompresses, parses JSON.
|
||||||
- Publishes meter JSON to MQTT.
|
- Publishes meter JSON to MQTT.
|
||||||
|
- Sends ACKs for MeterBatch packets and de-duplicates by batch_id.
|
||||||
- Web UI:
|
- Web UI:
|
||||||
- AP mode: status + WiFi/MQTT config.
|
- AP mode: status + WiFi/MQTT config.
|
||||||
- STA mode: status + per-sender pages.
|
- STA mode: status + per-sender pages.
|
||||||
- OLED cycles through receiver status and per-sender pages.
|
- OLED cycles through receiver status and per-sender pages (receiver OLED never sleeps).
|
||||||
|
|
||||||
**Receiver loop (pseudo-code)**:
|
**Receiver loop (pseudo-code)**:
|
||||||
```cpp
|
```cpp
|
||||||
@@ -106,7 +106,7 @@ void receiver_loop() {
|
|||||||
}
|
}
|
||||||
} else if (pkt.type == MeterBatch) {
|
} else if (pkt.type == MeterBatch) {
|
||||||
json = reassemble_and_decompress_batch(pkt);
|
json = reassemble_and_decompress_batch(pkt);
|
||||||
for (sample in jsonToMeterBatch(json)) {
|
for (sample in jsonToMeterBatch(json)) { // uses t_first/t_last for jittered timestamps
|
||||||
update_sender_status(sample);
|
update_sender_status(sample);
|
||||||
mqtt_publish_state(sample);
|
mqtt_publish_state(sample);
|
||||||
}
|
}
|
||||||
@@ -169,14 +169,14 @@ Packet layout:
|
|||||||
[0] protocol_version (1)
|
[0] protocol_version (1)
|
||||||
[1] role (0=sender, 1=receiver)
|
[1] role (0=sender, 1=receiver)
|
||||||
[2..3] device_id_short (uint16)
|
[2..3] device_id_short (uint16)
|
||||||
[4] payload_type (0=meter, 1=test, 2=time_sync, 3=meter_batch)
|
[4] payload_type (0=meter, 1=test, 2=time_sync, 3=meter_batch, 4=ack)
|
||||||
[5..N-3] compressed payload
|
[5..N-3] compressed payload
|
||||||
[N-2..N-1] CRC16 (bytes 0..N-3)
|
[N-2..N-1] CRC16 (bytes 0..N-3)
|
||||||
```
|
```
|
||||||
|
|
||||||
LoRa radio settings:
|
LoRa radio settings:
|
||||||
- Frequency: **433 MHz** or **868 MHz** (set by build env via `LORA_FREQUENCY_HZ`)
|
- 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
|
- SF10, BW 125 kHz, CR 4/5, CRC on, Sync Word 0x34
|
||||||
|
|
||||||
## Data Format
|
## Data Format
|
||||||
JSON payload (sender + MQTT):
|
JSON payload (sender + MQTT):
|
||||||
@@ -190,14 +190,42 @@ JSON payload (sender + MQTT):
|
|||||||
"p1_w": 500.00,
|
"p1_w": 500.00,
|
||||||
"p2_w": 450.00,
|
"p2_w": 450.00,
|
||||||
"p3_w": 0.00,
|
"p3_w": 0.00,
|
||||||
"v1_v": 230.10,
|
|
||||||
"v2_v": 229.80,
|
|
||||||
"v3_v": 231.00,
|
|
||||||
"bat_v": 3.92,
|
"bat_v": 3.92,
|
||||||
"bat_pct": 78
|
"bat_pct": 78
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
MeterBatch JSON (compressed over LoRa) uses per-field arrays with integer units for easier ingestion:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"sender": "s01",
|
||||||
|
"batch_id": 1842,
|
||||||
|
"t0": 1738288000,
|
||||||
|
"t_first": 1738288000,
|
||||||
|
"t_last": 1738288030,
|
||||||
|
"dt_s": 1,
|
||||||
|
"n": 3,
|
||||||
|
"e_wh": [123456700, 123456701, 123456701],
|
||||||
|
"p_w": [930, 940, 950],
|
||||||
|
"p1_w": [480, 490, 500],
|
||||||
|
"p2_w": [450, 450, 450],
|
||||||
|
"p3_w": [0, 0, 0],
|
||||||
|
"bat_v": 3.92,
|
||||||
|
"meta": {
|
||||||
|
"rssi": -92,
|
||||||
|
"snr": 7.5,
|
||||||
|
"rx_ts": 1738288031
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `sender` maps to `EXPECTED_SENDER_IDS` order (`s01` = first sender).
|
||||||
|
- `meta` is injected by the receiver after batch reassembly.
|
||||||
|
- `bat_v` is a single batch-level value (percent is calculated locally).
|
||||||
|
|
||||||
## Device IDs
|
## Device IDs
|
||||||
- Derived from WiFi STA MAC.
|
- Derived from WiFi STA MAC.
|
||||||
- `short_id = (MAC[4] << 8) | MAC[5]`
|
- `short_id = (MAC[4] << 8) | MAC[5]`
|
||||||
@@ -212,9 +240,7 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C };
|
|||||||
|
|
||||||
## OLED Behavior
|
## OLED Behavior
|
||||||
- Sender: OLED stays **ON for 10 seconds** on each wake, then powers down for sleep.
|
- Sender: OLED stays **ON for 10 seconds** on each wake, then powers down for sleep.
|
||||||
- Receiver: OLED follows the 10-minute auto-off behavior:
|
- Receiver: OLED is always on (no auto-off).
|
||||||
- GPIO14 HIGH: OLED forced ON.
|
|
||||||
- GPIO14 LOW: auto-off after 10 minutes.
|
|
||||||
- Pages rotate every 4s.
|
- Pages rotate every 4s.
|
||||||
|
|
||||||
## Power & Battery
|
## Power & Battery
|
||||||
@@ -253,12 +279,24 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C };
|
|||||||
- `lilygo-t3-v1-6-1-868`: production build for 868 MHz modules
|
- `lilygo-t3-v1-6-1-868`: production build for 868 MHz modules
|
||||||
- `lilygo-t3-v1-6-1-868-test`: test build for 868 MHz modules
|
- `lilygo-t3-v1-6-1-868-test`: test build for 868 MHz modules
|
||||||
|
|
||||||
|
## Config Knobs
|
||||||
|
Key timing settings in `include/config.h`:
|
||||||
|
- `METER_SAMPLE_INTERVAL_MS`
|
||||||
|
- `METER_SEND_INTERVAL_MS`
|
||||||
|
- `BATCH_ACK_TIMEOUT_MS`
|
||||||
|
- `BATCH_MAX_RETRIES`
|
||||||
|
- `BATCH_QUEUE_DEPTH`
|
||||||
|
- `BATCH_RETRY_POLICY` (keep or drop on retry exhaustion)
|
||||||
|
- `SERIAL_DEBUG_MODE` / `SERIAL_DEBUG_DUMP_JSON`
|
||||||
|
- `LORA_SEND_BYPASS` (debug only)
|
||||||
|
|
||||||
## Limits & Known Constraints
|
## Limits & Known Constraints
|
||||||
- **Compression**: uses lightweight RLE (good for JSON but not optimal).
|
- **Compression**: uses lightweight RLE (good for JSON but not optimal).
|
||||||
- **OBIS parsing**: supports IEC 62056-21 ASCII (Mode D) and SML; may need tuning for some meters.
|
- **OBIS parsing**: supports IEC 62056-21 ASCII (Mode D); may need tuning for some meters.
|
||||||
- **Payload size**: single JSON frames < 256 bytes (ArduinoJson static doc); batch frames are chunked and reassembled.
|
- **Payload size**: single JSON frames < 256 bytes (ArduinoJson static doc); batch frames are chunked and reassembled.
|
||||||
- **Battery ADC**: uses simple linear calibration constant in `power_manager.cpp`.
|
- **Battery ADC**: uses simple linear calibration constant in `power_manager.cpp`.
|
||||||
- **OLED**: no hardware reset line is used (matches working reference).
|
- **OLED**: no hardware reset line is used (matches working reference).
|
||||||
|
- **Batch ACKs**: sender waits for ACK after a batch and retries up to `BATCH_MAX_RETRIES` with `BATCH_ACK_TIMEOUT_MS` between attempts.
|
||||||
|
|
||||||
## Files & Modules
|
## Files & Modules
|
||||||
- `include/config.h`, `src/config.cpp`: pins, radio settings, sender IDs
|
- `include/config.h`, `src/config.cpp`: pins, radio settings, sender IDs
|
||||||
@@ -266,7 +304,7 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C };
|
|||||||
- `include/json_codec.h`, `src/json_codec.cpp`: JSON encode/decode
|
- `include/json_codec.h`, `src/json_codec.cpp`: JSON encode/decode
|
||||||
- `include/compressor.h`, `src/compressor.cpp`: RLE compression
|
- `include/compressor.h`, `src/compressor.cpp`: RLE compression
|
||||||
- `include/lora_transport.h`, `src/lora_transport.cpp`: LoRa packet + CRC
|
- `include/lora_transport.h`, `src/lora_transport.cpp`: LoRa packet + CRC
|
||||||
- `include/meter_driver.h`, `src/meter_driver.cpp`: IEC 62056-21 ASCII + SML parse
|
- `include/meter_driver.h`, `src/meter_driver.cpp`: IEC 62056-21 ASCII parse
|
||||||
- `include/power_manager.h`, `src/power_manager.cpp`: ADC + sleep
|
- `include/power_manager.h`, `src/power_manager.cpp`: ADC + sleep
|
||||||
- `include/time_manager.h`, `src/time_manager.cpp`: NTP + time sync
|
- `include/time_manager.h`, `src/time_manager.cpp`: NTP + time sync
|
||||||
- `include/wifi_manager.h`, `src/wifi_manager.cpp`: NVS config + WiFi
|
- `include/wifi_manager.h`, `src/wifi_manager.cpp`: NVS config + WiFi
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ enum class PayloadType : uint8_t {
|
|||||||
MeterData = 0,
|
MeterData = 0,
|
||||||
TestCode = 1,
|
TestCode = 1,
|
||||||
TimeSync = 2,
|
TimeSync = 2,
|
||||||
MeterBatch = 3
|
MeterBatch = 3,
|
||||||
|
Ack = 4
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class BatchRetryPolicy : uint8_t {
|
||||||
|
Keep = 0,
|
||||||
|
Drop = 1
|
||||||
};
|
};
|
||||||
|
|
||||||
constexpr uint8_t PROTOCOL_VERSION = 1;
|
constexpr uint8_t PROTOCOL_VERSION = 1;
|
||||||
@@ -43,10 +49,11 @@ constexpr uint8_t PIN_METER_RX = 34;
|
|||||||
#define LORA_FREQUENCY_HZ 433E6
|
#define LORA_FREQUENCY_HZ 433E6
|
||||||
#endif
|
#endif
|
||||||
constexpr long LORA_FREQUENCY = LORA_FREQUENCY_HZ;
|
constexpr long LORA_FREQUENCY = LORA_FREQUENCY_HZ;
|
||||||
constexpr uint8_t LORA_SPREADING_FACTOR = 12;
|
constexpr uint8_t LORA_SPREADING_FACTOR = 10;
|
||||||
constexpr long LORA_BANDWIDTH = 125E3;
|
constexpr long LORA_BANDWIDTH = 125E3;
|
||||||
constexpr uint8_t LORA_CODING_RATE = 5;
|
constexpr uint8_t LORA_CODING_RATE = 5;
|
||||||
constexpr uint8_t LORA_SYNC_WORD = 0x34;
|
constexpr uint8_t LORA_SYNC_WORD = 0x34;
|
||||||
|
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;
|
||||||
@@ -59,9 +66,16 @@ constexpr uint32_t OLED_AUTO_OFF_MS = 10UL * 60UL * 1000UL;
|
|||||||
constexpr uint32_t SENDER_OLED_READ_MS = 10000;
|
constexpr uint32_t SENDER_OLED_READ_MS = 10000;
|
||||||
constexpr uint32_t METER_SAMPLE_INTERVAL_MS = 1000;
|
constexpr uint32_t METER_SAMPLE_INTERVAL_MS = 1000;
|
||||||
constexpr uint32_t METER_SEND_INTERVAL_MS = 30000;
|
constexpr uint32_t METER_SEND_INTERVAL_MS = 30000;
|
||||||
|
constexpr uint32_t BATCH_ACK_TIMEOUT_MS = 3000;
|
||||||
|
constexpr uint8_t BATCH_MAX_RETRIES = 2;
|
||||||
constexpr uint8_t METER_BATCH_MAX_SAMPLES = 30;
|
constexpr uint8_t METER_BATCH_MAX_SAMPLES = 30;
|
||||||
|
constexpr uint8_t BATCH_QUEUE_DEPTH = 10;
|
||||||
|
constexpr BatchRetryPolicy BATCH_RETRY_POLICY = BatchRetryPolicy::Keep;
|
||||||
constexpr uint32_t WATCHDOG_TIMEOUT_SEC = 120;
|
constexpr uint32_t WATCHDOG_TIMEOUT_SEC = 120;
|
||||||
constexpr bool ENABLE_HA_DISCOVERY = true;
|
constexpr bool ENABLE_HA_DISCOVERY = true;
|
||||||
|
constexpr bool SERIAL_DEBUG_MODE = false;
|
||||||
|
constexpr bool SERIAL_DEBUG_DUMP_JSON = false;
|
||||||
|
constexpr bool LORA_SEND_BYPASS = false;
|
||||||
|
|
||||||
constexpr uint8_t NUM_SENDERS = 1;
|
constexpr uint8_t NUM_SENDERS = 1;
|
||||||
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = {
|
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ struct MeterData {
|
|||||||
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;
|
||||||
float phase_voltage_v[3];
|
|
||||||
float battery_voltage_v;
|
float battery_voltage_v;
|
||||||
uint8_t battery_percent;
|
uint8_t battery_percent;
|
||||||
bool valid;
|
bool valid;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ void display_set_sender_statuses(const SenderStatus *statuses, uint8_t count);
|
|||||||
void display_set_last_meter(const MeterData &data);
|
void display_set_last_meter(const MeterData &data);
|
||||||
void display_set_last_read(bool ok, uint32_t ts_utc);
|
void display_set_last_read(bool ok, uint32_t ts_utc);
|
||||||
void display_set_last_tx(bool ok, uint32_t ts_utc);
|
void display_set_last_tx(bool ok, uint32_t ts_utc);
|
||||||
|
void display_set_sender_queue(uint8_t depth, bool build_pending);
|
||||||
|
void display_set_sender_batches(uint16_t last_acked_batch_id, uint16_t current_batch_id);
|
||||||
void display_set_last_error(FaultType type, uint32_t ts_utc, uint32_t ts_ms);
|
void display_set_last_error(FaultType type, uint32_t ts_utc, uint32_t ts_ms);
|
||||||
void display_set_receiver_status(bool ap_mode, const char *ssid, bool mqtt_ok);
|
void display_set_receiver_status(bool ap_mode, const char *ssid, bool mqtt_ok);
|
||||||
void display_power_down();
|
void display_power_down();
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
|
|
||||||
bool meterDataToJson(const MeterData &data, String &out_json);
|
bool meterDataToJson(const MeterData &data, String &out_json);
|
||||||
bool jsonToMeterData(const String &json, MeterData &data);
|
bool jsonToMeterData(const String &json, MeterData &data);
|
||||||
bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json, const FaultCounters *faults = nullptr, FaultType last_error = FaultType::None);
|
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);
|
bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_count, size_t &out_count);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
|
||||||
constexpr size_t LORA_MAX_PAYLOAD = 200;
|
constexpr size_t LORA_MAX_PAYLOAD = 230;
|
||||||
|
|
||||||
struct LoraPacket {
|
struct LoraPacket {
|
||||||
uint8_t protocol_version;
|
uint8_t protocol_version;
|
||||||
@@ -20,3 +20,4 @@ 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);
|
||||||
void lora_sleep();
|
void lora_sleep();
|
||||||
|
uint32_t lora_airtime_ms(size_t packet_len);
|
||||||
|
|||||||
@@ -7,4 +7,6 @@
|
|||||||
void web_server_begin_ap(const SenderStatus *statuses, uint8_t count);
|
void web_server_begin_ap(const SenderStatus *statuses, uint8_t count);
|
||||||
void web_server_begin_sta(const SenderStatus *statuses, uint8_t count);
|
void web_server_begin_sta(const SenderStatus *statuses, uint8_t count);
|
||||||
void web_server_set_config(const WifiMqttConfig &config);
|
void web_server_set_config(const WifiMqttConfig &config);
|
||||||
|
void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors);
|
||||||
|
void web_server_set_last_batch(uint8_t sender_index, const MeterData *samples, size_t count);
|
||||||
void web_server_loop();
|
void web_server_loop();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
; https://docs.platformio.org/page/projectconf.html
|
; https://docs.platformio.org/page/projectconf.html
|
||||||
|
|
||||||
[env:lilygo-t3-v1-6-1]
|
[env:lilygo-t3-v1-6-1]
|
||||||
platform = espressif32
|
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip
|
||||||
board = ttgo-lora32-v1
|
board = ttgo-lora32-v1
|
||||||
framework = arduino
|
framework = arduino
|
||||||
lib_deps =
|
lib_deps =
|
||||||
@@ -20,7 +20,7 @@ lib_deps =
|
|||||||
knolleary/PubSubClient@^2.8
|
knolleary/PubSubClient@^2.8
|
||||||
|
|
||||||
[env:lilygo-t3-v1-6-1-test]
|
[env:lilygo-t3-v1-6-1-test]
|
||||||
platform = espressif32
|
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip
|
||||||
board = ttgo-lora32-v1
|
board = ttgo-lora32-v1
|
||||||
framework = arduino
|
framework = arduino
|
||||||
lib_deps =
|
lib_deps =
|
||||||
@@ -33,7 +33,7 @@ build_flags =
|
|||||||
-DENABLE_TEST_MODE
|
-DENABLE_TEST_MODE
|
||||||
|
|
||||||
[env:lilygo-t3-v1-6-1-868]
|
[env:lilygo-t3-v1-6-1-868]
|
||||||
platform = espressif32
|
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip
|
||||||
board = ttgo-lora32-v1
|
board = ttgo-lora32-v1
|
||||||
framework = arduino
|
framework = arduino
|
||||||
lib_deps =
|
lib_deps =
|
||||||
@@ -46,7 +46,7 @@ build_flags =
|
|||||||
-DLORA_FREQUENCY_HZ=868E6
|
-DLORA_FREQUENCY_HZ=868E6
|
||||||
|
|
||||||
[env:lilygo-t3-v1-6-1-868-test]
|
[env:lilygo-t3-v1-6-1-868-test]
|
||||||
platform = espressif32
|
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip
|
||||||
board = ttgo-lora32-v1
|
board = ttgo-lora32-v1
|
||||||
framework = arduino
|
framework = arduino
|
||||||
lib_deps =
|
lib_deps =
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ static uint32_t g_last_read_ts = 0;
|
|||||||
static uint32_t g_last_tx_ts = 0;
|
static uint32_t g_last_tx_ts = 0;
|
||||||
static uint32_t g_last_read_ms = 0;
|
static uint32_t g_last_read_ms = 0;
|
||||||
static uint32_t g_last_tx_ms = 0;
|
static uint32_t g_last_tx_ms = 0;
|
||||||
|
static uint8_t g_sender_queue_depth = 0;
|
||||||
|
static bool g_sender_build_pending = false;
|
||||||
|
static uint16_t g_sender_last_acked_batch_id = 0;
|
||||||
|
static uint16_t g_sender_current_batch_id = 0;
|
||||||
static FaultType g_last_error = FaultType::None;
|
static FaultType g_last_error = FaultType::None;
|
||||||
static uint32_t g_last_error_ts = 0;
|
static uint32_t g_last_error_ts = 0;
|
||||||
static uint32_t g_last_error_ms = 0;
|
static uint32_t g_last_error_ms = 0;
|
||||||
@@ -111,6 +115,16 @@ void display_set_last_tx(bool ok, uint32_t ts_utc) {
|
|||||||
g_last_tx_ms = millis();
|
g_last_tx_ms = millis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void display_set_sender_queue(uint8_t depth, bool build_pending) {
|
||||||
|
g_sender_queue_depth = depth;
|
||||||
|
g_sender_build_pending = build_pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
void display_set_sender_batches(uint16_t last_acked_batch_id, uint16_t current_batch_id) {
|
||||||
|
g_sender_last_acked_batch_id = last_acked_batch_id;
|
||||||
|
g_sender_current_batch_id = current_batch_id;
|
||||||
|
}
|
||||||
|
|
||||||
void display_set_last_error(FaultType type, uint32_t ts_utc, uint32_t ts_ms) {
|
void display_set_last_error(FaultType type, uint32_t ts_utc, uint32_t ts_ms) {
|
||||||
g_last_error = type;
|
g_last_error = type;
|
||||||
g_last_error_ts = ts_utc;
|
g_last_error_ts = ts_utc;
|
||||||
@@ -195,7 +209,13 @@ static void render_sender_status() {
|
|||||||
display.printf("Read %s %lus ago", g_last_read_ok ? "OK" : "ERR", static_cast<unsigned long>(age_seconds(g_last_read_ts, g_last_read_ms)));
|
display.printf("Read %s %lus ago", g_last_read_ok ? "OK" : "ERR", static_cast<unsigned long>(age_seconds(g_last_read_ts, g_last_read_ms)));
|
||||||
|
|
||||||
display.setCursor(0, 36);
|
display.setCursor(0, 36);
|
||||||
display.printf("TX %s %lus ago", g_last_tx_ok ? "OK" : "ERR", static_cast<unsigned long>(age_seconds(g_last_tx_ts, g_last_tx_ms)));
|
display.printf("TX %s %lus Q%u%s A%u C%u",
|
||||||
|
g_last_tx_ok ? "OK" : "ERR",
|
||||||
|
static_cast<unsigned long>(age_seconds(g_last_tx_ts, g_last_tx_ms)),
|
||||||
|
g_sender_queue_depth,
|
||||||
|
g_sender_build_pending ? "+" : "",
|
||||||
|
g_sender_last_acked_batch_id,
|
||||||
|
g_sender_current_batch_id);
|
||||||
|
|
||||||
#ifdef ENABLE_TEST_MODE
|
#ifdef ENABLE_TEST_MODE
|
||||||
if (strlen(g_test_code) > 0) {
|
if (strlen(g_test_code) > 0) {
|
||||||
@@ -219,11 +239,11 @@ static void render_sender_measurement() {
|
|||||||
display.setCursor(0, 12);
|
display.setCursor(0, 12);
|
||||||
display.printf("P %.0fW", g_last_meter.total_power_w);
|
display.printf("P %.0fW", g_last_meter.total_power_w);
|
||||||
display.setCursor(0, 24);
|
display.setCursor(0, 24);
|
||||||
display.printf("L1 %.0fV %.0fW", g_last_meter.phase_voltage_v[0], g_last_meter.phase_power_w[0]);
|
display.printf("L1 %.0fW", g_last_meter.phase_power_w[0]);
|
||||||
display.setCursor(0, 36);
|
display.setCursor(0, 36);
|
||||||
display.printf("L2 %.0fV %.0fW", g_last_meter.phase_voltage_v[1], g_last_meter.phase_power_w[1]);
|
display.printf("L2 %.0fW", g_last_meter.phase_power_w[1]);
|
||||||
display.setCursor(0, 48);
|
display.setCursor(0, 48);
|
||||||
display.printf("L3 %.0fV %.0fW", g_last_meter.phase_voltage_v[2], g_last_meter.phase_power_w[2]);
|
display.printf("L3 %.0fW", g_last_meter.phase_power_w[2]);
|
||||||
display.display();
|
display.display();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +312,15 @@ static void render_receiver_sender(uint8_t index) {
|
|||||||
display.setCursor(0, 0);
|
display.setCursor(0, 0);
|
||||||
uint8_t bat = status.has_data ? status.last_data.battery_percent : 0;
|
uint8_t bat = status.has_data ? status.last_data.battery_percent : 0;
|
||||||
if (status.has_data) {
|
if (status.has_data) {
|
||||||
display.printf("%s B%u", status.last_data.device_id, bat);
|
const char *device_id = status.last_data.device_id;
|
||||||
|
if (strlen(device_id) >= 4 && strncmp(device_id, "dd3-", 4) == 0) {
|
||||||
|
device_id += 4;
|
||||||
|
}
|
||||||
|
if (status.last_data.link_valid) {
|
||||||
|
display.printf("%s R:%d S:%.1f", device_id, status.last_data.link_rssi_dbm, status.last_data.link_snr_db);
|
||||||
|
} else {
|
||||||
|
display.printf("%s B%u", device_id, bat);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
display.printf("%s B--", status.last_data.device_id);
|
display.printf("%s B--", status.last_data.device_id);
|
||||||
}
|
}
|
||||||
@@ -318,15 +346,11 @@ static void render_receiver_sender(uint8_t index) {
|
|||||||
display.setCursor(0, 24);
|
display.setCursor(0, 24);
|
||||||
display.printf("P %.0fW", status.last_data.total_power_w);
|
display.printf("P %.0fW", status.last_data.total_power_w);
|
||||||
display.setCursor(0, 36);
|
display.setCursor(0, 36);
|
||||||
display.printf("L1 %.0fV %.0fW", status.last_data.phase_voltage_v[0], status.last_data.phase_power_w[0]);
|
display.printf("L1 %.0fW", status.last_data.phase_power_w[0]);
|
||||||
display.setCursor(0, 48);
|
display.setCursor(0, 48);
|
||||||
display.printf("L2 %.0fV %.0fW", status.last_data.phase_voltage_v[1], status.last_data.phase_power_w[1]);
|
display.printf("L2 %.0fW", status.last_data.phase_power_w[1]);
|
||||||
display.setCursor(0, 56);
|
display.setCursor(0, 56);
|
||||||
if (status.last_data.link_valid) {
|
display.printf("L3 %.0fW", status.last_data.phase_power_w[2]);
|
||||||
display.printf("R:%d S:%.1f", status.last_data.link_rssi_dbm, status.last_data.link_snr_db);
|
|
||||||
} else {
|
|
||||||
display.printf("L3 %.0fV %.0fW", status.last_data.phase_voltage_v[2], status.last_data.phase_power_w[2]);
|
|
||||||
}
|
|
||||||
display.display();
|
display.display();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +372,10 @@ void display_tick() {
|
|||||||
bool ctrl_high = digitalRead(PIN_OLED_CTRL) == HIGH;
|
bool ctrl_high = digitalRead(PIN_OLED_CTRL) == HIGH;
|
||||||
|
|
||||||
bool in_boot_window = (millis() - g_boot_ms) < OLED_AUTO_OFF_MS;
|
bool in_boot_window = (millis() - g_boot_ms) < OLED_AUTO_OFF_MS;
|
||||||
if (in_boot_window) {
|
if (g_role == DeviceRole::Receiver) {
|
||||||
|
g_oled_on = true;
|
||||||
|
g_oled_off_start = 0;
|
||||||
|
} else if (in_boot_window) {
|
||||||
g_oled_on = true;
|
g_oled_on = true;
|
||||||
} else {
|
} else {
|
||||||
if (ctrl_high) {
|
if (ctrl_high) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#include "json_codec.h"
|
#include "json_codec.h"
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
|
#include <limits.h>
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
|
#include "config.h"
|
||||||
#include "power_manager.h"
|
#include "power_manager.h"
|
||||||
|
|
||||||
static float round2(float value) {
|
static float round2(float value) {
|
||||||
@@ -10,6 +12,34 @@ 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) {
|
||||||
|
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 const char *short_id_from_device_id(const char *device_id) {
|
static const char *short_id_from_device_id(const char *device_id) {
|
||||||
if (!device_id) {
|
if (!device_id) {
|
||||||
return "";
|
return "";
|
||||||
@@ -21,6 +51,31 @@ 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;
|
||||||
@@ -47,12 +102,6 @@ bool meterDataToJson(const MeterData &data, String &out_json) {
|
|||||||
doc["p2_w"] = serialized(buf);
|
doc["p2_w"] = serialized(buf);
|
||||||
format_float_2(buf, sizeof(buf), data.phase_power_w[2]);
|
format_float_2(buf, sizeof(buf), data.phase_power_w[2]);
|
||||||
doc["p3_w"] = serialized(buf);
|
doc["p3_w"] = serialized(buf);
|
||||||
format_float_2(buf, sizeof(buf), data.phase_voltage_v[0]);
|
|
||||||
doc["v1_v"] = serialized(buf);
|
|
||||||
format_float_2(buf, sizeof(buf), data.phase_voltage_v[1]);
|
|
||||||
doc["v2_v"] = serialized(buf);
|
|
||||||
format_float_2(buf, sizeof(buf), data.phase_voltage_v[2]);
|
|
||||||
doc["v3_v"] = 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;
|
||||||
@@ -78,13 +127,6 @@ bool meterDataToJson(const MeterData &data, String &out_json) {
|
|||||||
return len > 0 && len < 256;
|
return len > 0 && len < 256;
|
||||||
}
|
}
|
||||||
|
|
||||||
static float read_float_or_legacy(JsonDocument &doc, const char *key, const char *legacy_key) {
|
|
||||||
if (doc[key].isNull()) {
|
|
||||||
return doc[legacy_key] | NAN;
|
|
||||||
}
|
|
||||||
return doc[key] | NAN;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool jsonToMeterData(const String &json, MeterData &data) {
|
bool jsonToMeterData(const String &json, MeterData &data) {
|
||||||
StaticJsonDocument<256> doc;
|
StaticJsonDocument<256> doc;
|
||||||
DeserializationError err = deserializeJson(doc, json);
|
DeserializationError err = deserializeJson(doc, json);
|
||||||
@@ -101,14 +143,11 @@ bool jsonToMeterData(const String &json, MeterData &data) {
|
|||||||
data.device_id[sizeof(data.device_id) - 1] = '\0';
|
data.device_id[sizeof(data.device_id) - 1] = '\0';
|
||||||
|
|
||||||
data.ts_utc = doc["ts"] | 0;
|
data.ts_utc = doc["ts"] | 0;
|
||||||
data.energy_total_kwh = read_float_or_legacy(doc, "e_kwh", "energy_kwh");
|
data.energy_total_kwh = doc["e_kwh"] | NAN;
|
||||||
data.total_power_w = read_float_or_legacy(doc, "p_w", "p_total_w");
|
data.total_power_w = doc["p_w"] | NAN;
|
||||||
data.phase_power_w[0] = doc["p1_w"] | NAN;
|
data.phase_power_w[0] = doc["p1_w"] | NAN;
|
||||||
data.phase_power_w[1] = doc["p2_w"] | NAN;
|
data.phase_power_w[1] = doc["p2_w"] | NAN;
|
||||||
data.phase_power_w[2] = doc["p3_w"] | NAN;
|
data.phase_power_w[2] = doc["p3_w"] | NAN;
|
||||||
data.phase_voltage_v[0] = doc["v1_v"] | NAN;
|
|
||||||
data.phase_voltage_v[1] = doc["v2_v"] | NAN;
|
|
||||||
data.phase_voltage_v[2] = doc["v3_v"] | NAN;
|
|
||||||
data.battery_voltage_v = doc["bat_v"] | NAN;
|
data.battery_voltage_v = doc["bat_v"] | NAN;
|
||||||
if (doc["bat_pct"].isNull() && !isnan(data.battery_voltage_v)) {
|
if (doc["bat_pct"].isNull() && !isnan(data.battery_voltage_v)) {
|
||||||
data.battery_percent = battery_percent_from_voltage(data.battery_voltage_v);
|
data.battery_percent = battery_percent_from_voltage(data.battery_voltage_v);
|
||||||
@@ -132,15 +171,23 @@ bool jsonToMeterData(const String &json, MeterData &data) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json, const FaultCounters *faults, FaultType last_error) {
|
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) {
|
if (!samples || count == 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
DynamicJsonDocument doc(8192);
|
DynamicJsonDocument doc(8192);
|
||||||
doc["id"] = short_id_from_device_id(samples[count - 1].device_id);
|
doc["schema"] = 1;
|
||||||
doc["bat_v"] = round2(samples[count - 1].battery_voltage_v);
|
char sender_label[8] = {};
|
||||||
doc["bat_pct"] = samples[count - 1].battery_percent;
|
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) {
|
||||||
if (faults->meter_read_fail > 0) {
|
if (faults->meter_read_fail > 0) {
|
||||||
doc["err_m"] = faults->meter_read_fail;
|
doc["err_m"] = faults->meter_read_fail;
|
||||||
@@ -152,19 +199,23 @@ bool meterBatchToJson(const MeterData *samples, size_t count, String &out_json,
|
|||||||
if (last_error != FaultType::None) {
|
if (last_error != FaultType::None) {
|
||||||
doc["err_last"] = static_cast<uint8_t>(last_error);
|
doc["err_last"] = static_cast<uint8_t>(last_error);
|
||||||
}
|
}
|
||||||
JsonArray arr = doc.createNestedArray("s");
|
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) {
|
for (size_t i = 0; i < count; ++i) {
|
||||||
JsonArray row = arr.createNestedArray();
|
energy.add(kwh_to_wh(samples[i].energy_total_kwh));
|
||||||
row.add(samples[i].ts_utc);
|
p_w.add(round_to_i32(samples[i].total_power_w));
|
||||||
row.add(round2(samples[i].energy_total_kwh));
|
p1_w.add(round_to_i32(samples[i].phase_power_w[0]));
|
||||||
row.add(round2(samples[i].total_power_w));
|
p2_w.add(round_to_i32(samples[i].phase_power_w[1]));
|
||||||
row.add(round2(samples[i].phase_power_w[0]));
|
p3_w.add(round_to_i32(samples[i].phase_power_w[2]));
|
||||||
row.add(round2(samples[i].phase_power_w[1]));
|
|
||||||
row.add(round2(samples[i].phase_power_w[2]));
|
|
||||||
row.add(round2(samples[i].phase_voltage_v[0]));
|
|
||||||
row.add(round2(samples[i].phase_voltage_v[1]));
|
|
||||||
row.add(round2(samples[i].phase_voltage_v[2]));
|
|
||||||
row.add(samples[i].valid ? 1 : 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out_json = "";
|
out_json = "";
|
||||||
@@ -184,47 +235,68 @@ bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_cou
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonArray arr = doc["s"].as<JsonArray>();
|
|
||||||
if (arr.isNull()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const char *id = doc["id"] | "";
|
const char *id = doc["id"] | "";
|
||||||
float bat_v = doc["bat_v"] | NAN;
|
const char *sender = doc["sender"] | "";
|
||||||
uint8_t bat_pct = doc["bat_pct"] | 0;
|
|
||||||
uint32_t err_m = doc["err_m"] | 0;
|
uint32_t err_m = doc["err_m"] | 0;
|
||||||
uint32_t err_tx = doc["err_tx"] | 0;
|
uint32_t err_tx = doc["err_tx"] | 0;
|
||||||
FaultType last_error = static_cast<FaultType>(doc["err_last"] | 0);
|
FaultType last_error = static_cast<FaultType>(doc["err_last"] | 0);
|
||||||
|
float bat_v = doc["bat_v"] | NAN;
|
||||||
|
|
||||||
size_t idx = 0;
|
if (!doc["schema"].isNull()) {
|
||||||
for (JsonArray row : arr) {
|
if ((doc["schema"] | 0) != 1) {
|
||||||
if (idx >= max_count) {
|
return false;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
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];
|
MeterData &data = out_samples[idx];
|
||||||
data = {};
|
data = {};
|
||||||
if (strlen(id) == 4) {
|
uint16_t short_id = short_id_from_sender_label(sender);
|
||||||
snprintf(data.device_id, sizeof(data.device_id), "dd3-%s", id);
|
if (short_id != 0) {
|
||||||
} else {
|
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));
|
strncpy(data.device_id, id, sizeof(data.device_id));
|
||||||
}
|
|
||||||
data.device_id[sizeof(data.device_id) - 1] = '\0';
|
data.device_id[sizeof(data.device_id) - 1] = '\0';
|
||||||
data.ts_utc = row[0] | 0;
|
} else {
|
||||||
data.energy_total_kwh = row[1] | NAN;
|
snprintf(data.device_id, sizeof(data.device_id), "dd3-0000");
|
||||||
data.total_power_w = row[2] | NAN;
|
}
|
||||||
data.phase_power_w[0] = row[3] | NAN;
|
|
||||||
data.phase_power_w[1] = row[4] | NAN;
|
if (count > 1 && t_last >= t_first) {
|
||||||
data.phase_power_w[2] = row[5] | NAN;
|
uint32_t span = t_last - t_first;
|
||||||
data.phase_voltage_v[0] = row[6] | NAN;
|
uint32_t step = span / static_cast<uint32_t>(count - 1);
|
||||||
data.phase_voltage_v[1] = row[7] | NAN;
|
data.ts_utc = t_first + static_cast<uint32_t>(idx) * step;
|
||||||
data.phase_voltage_v[2] = row[8] | NAN;
|
} else {
|
||||||
data.valid = (row[9] | 1) != 0;
|
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;
|
data.battery_voltage_v = bat_v;
|
||||||
if (doc["bat_pct"].isNull() && !isnan(bat_v)) {
|
if (!isnan(bat_v)) {
|
||||||
data.battery_percent = battery_percent_from_voltage(bat_v);
|
data.battery_percent = battery_percent_from_voltage(bat_v);
|
||||||
} else {
|
} else {
|
||||||
data.battery_percent = bat_pct;
|
data.battery_percent = 0;
|
||||||
}
|
}
|
||||||
|
data.valid = true;
|
||||||
data.link_valid = false;
|
data.link_valid = false;
|
||||||
data.link_rssi_dbm = 0;
|
data.link_rssi_dbm = 0;
|
||||||
data.link_snr_db = NAN;
|
data.link_snr_db = NAN;
|
||||||
@@ -233,13 +305,15 @@ bool jsonToMeterBatch(const String &json, MeterData *out_samples, size_t max_cou
|
|||||||
data.err_lora_tx = err_tx;
|
data.err_lora_tx = err_tx;
|
||||||
data.last_error = last_error;
|
data.last_error = last_error;
|
||||||
|
|
||||||
if (strlen(data.device_id) >= 8) {
|
if (data.short_id == 0 && strlen(data.device_id) >= 8) {
|
||||||
const char *suffix = data.device_id + strlen(data.device_id) - 4;
|
const char *suffix = data.device_id + strlen(data.device_id) - 4;
|
||||||
data.short_id = static_cast<uint16_t>(strtoul(suffix, nullptr, 16));
|
data.short_id = static_cast<uint16_t>(strtoul(suffix, nullptr, 16));
|
||||||
}
|
}
|
||||||
idx++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out_count = idx;
|
out_count = count;
|
||||||
return idx > 0;
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "lora_transport.h"
|
#include "lora_transport.h"
|
||||||
#include <LoRa.h>
|
#include <LoRa.h>
|
||||||
#include <SPI.h>
|
#include <SPI.h>
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
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;
|
||||||
@@ -29,6 +30,9 @@ void lora_init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool lora_send(const LoraPacket &pkt) {
|
bool lora_send(const LoraPacket &pkt) {
|
||||||
|
if (LORA_SEND_BYPASS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
uint8_t buffer[5 + LORA_MAX_PAYLOAD + 2];
|
uint8_t buffer[5 + LORA_MAX_PAYLOAD + 2];
|
||||||
size_t idx = 0;
|
size_t idx = 0;
|
||||||
buffer[idx++] = pkt.protocol_version;
|
buffer[idx++] = pkt.protocol_version;
|
||||||
@@ -106,3 +110,28 @@ bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
|
|||||||
void lora_sleep() {
|
void lora_sleep() {
|
||||||
LoRa.sleep();
|
LoRa.sleep();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint32_t lora_airtime_ms(size_t packet_len) {
|
||||||
|
if (packet_len == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double bw = static_cast<double>(LORA_BANDWIDTH);
|
||||||
|
const double sf = static_cast<double>(LORA_SPREADING_FACTOR);
|
||||||
|
const double cr = static_cast<double>(LORA_CODING_RATE - 4); // coding rate denominator: 4/(4+cr)
|
||||||
|
const double tsym = (1 << LORA_SPREADING_FACTOR) / bw;
|
||||||
|
const double t_preamble = (static_cast<double>(LORA_PREAMBLE_LEN) + 4.25) * tsym;
|
||||||
|
|
||||||
|
const bool low_data_rate_opt = (LORA_SPREADING_FACTOR >= 11) && (LORA_BANDWIDTH <= 125000);
|
||||||
|
const double de = low_data_rate_opt ? 1.0 : 0.0;
|
||||||
|
const double ih = 0.0;
|
||||||
|
const double crc = 1.0;
|
||||||
|
|
||||||
|
const double payload_symb_nb = 8.0 + max(
|
||||||
|
ceil((8.0 * packet_len - 4.0 * sf + 28.0 + 16.0 * crc - 20.0 * ih) / (4.0 * (sf - 2.0 * de))) * (cr + 4.0),
|
||||||
|
0.0);
|
||||||
|
const double t_payload = payload_symb_nb * tsym;
|
||||||
|
const double t_packet = t_preamble + t_payload;
|
||||||
|
|
||||||
|
return static_cast<uint32_t>(ceil(t_packet * 1000.0));
|
||||||
|
}
|
||||||
|
|||||||
462
src/main.cpp
462
src/main.cpp
@@ -12,8 +12,11 @@
|
|||||||
#include "web_server.h"
|
#include "web_server.h"
|
||||||
#include "display_ui.h"
|
#include "display_ui.h"
|
||||||
#include "test_mode.h"
|
#include "test_mode.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <stdarg.h>
|
||||||
#ifdef ARDUINO_ARCH_ESP32
|
#ifdef ARDUINO_ARCH_ESP32
|
||||||
#include <esp_task_wdt.h>
|
#include <esp_task_wdt.h>
|
||||||
|
#include <esp_system.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
static DeviceRole g_role = DeviceRole::Sender;
|
static DeviceRole g_role = DeviceRole::Sender;
|
||||||
@@ -49,14 +52,76 @@ static constexpr size_t BATCH_HEADER_SIZE = 6;
|
|||||||
static constexpr size_t BATCH_CHUNK_PAYLOAD = LORA_MAX_PAYLOAD - BATCH_HEADER_SIZE;
|
static constexpr size_t BATCH_CHUNK_PAYLOAD = LORA_MAX_PAYLOAD - BATCH_HEADER_SIZE;
|
||||||
static constexpr size_t BATCH_MAX_COMPRESSED = 4096;
|
static constexpr size_t BATCH_MAX_COMPRESSED = 4096;
|
||||||
static constexpr size_t BATCH_MAX_DECOMPRESSED = 8192;
|
static constexpr size_t BATCH_MAX_DECOMPRESSED = 8192;
|
||||||
static constexpr uint32_t BATCH_RX_TIMEOUT_MS = 2000;
|
static constexpr uint32_t BATCH_RX_MARGIN_MS = 800;
|
||||||
|
|
||||||
|
struct BatchBuffer {
|
||||||
|
uint16_t batch_id;
|
||||||
|
bool batch_id_valid;
|
||||||
|
uint8_t count;
|
||||||
|
MeterData samples[METER_BATCH_MAX_SAMPLES];
|
||||||
|
};
|
||||||
|
|
||||||
|
static BatchBuffer g_batch_queue[BATCH_QUEUE_DEPTH];
|
||||||
|
static uint8_t g_batch_head = 0;
|
||||||
|
static uint8_t g_batch_tail = 0;
|
||||||
|
static uint8_t g_batch_count = 0;
|
||||||
|
|
||||||
|
static MeterData g_build_samples[METER_BATCH_MAX_SAMPLES];
|
||||||
|
static uint8_t g_build_count = 0;
|
||||||
|
|
||||||
static MeterData g_meter_samples[METER_BATCH_MAX_SAMPLES];
|
|
||||||
static uint8_t g_meter_sample_count = 0;
|
|
||||||
static uint8_t g_meter_sample_head = 0;
|
|
||||||
static uint32_t g_last_sample_ms = 0;
|
static uint32_t g_last_sample_ms = 0;
|
||||||
|
static uint32_t g_last_sample_ts_utc = 0;
|
||||||
static uint32_t g_last_send_ms = 0;
|
static uint32_t g_last_send_ms = 0;
|
||||||
|
static uint32_t g_last_batch_send_ms = 0;
|
||||||
|
static float g_last_battery_voltage_v = NAN;
|
||||||
|
static uint8_t g_last_battery_percent = 0;
|
||||||
|
static uint32_t g_last_battery_ms = 0;
|
||||||
static uint16_t g_batch_id = 1;
|
static uint16_t g_batch_id = 1;
|
||||||
|
static uint16_t g_last_sent_batch_id = 0;
|
||||||
|
static uint16_t g_last_acked_batch_id = 0;
|
||||||
|
static uint8_t g_batch_retry_count = 0;
|
||||||
|
static bool g_batch_ack_pending = false;
|
||||||
|
static uint32_t g_batch_ack_timeout_ms = BATCH_ACK_TIMEOUT_MS;
|
||||||
|
static MeterData g_inflight_samples[METER_BATCH_MAX_SAMPLES];
|
||||||
|
static uint8_t g_inflight_count = 0;
|
||||||
|
static uint16_t g_inflight_batch_id = 0;
|
||||||
|
static bool g_inflight_active = false;
|
||||||
|
static uint32_t g_last_debug_log_ms = 0;
|
||||||
|
|
||||||
|
static void watchdog_kick();
|
||||||
|
|
||||||
|
static void serial_debug_printf(const char *fmt, ...) {
|
||||||
|
if (!SERIAL_DEBUG_MODE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char buf[256];
|
||||||
|
va_list args;
|
||||||
|
va_start(args, fmt);
|
||||||
|
vsnprintf(buf, sizeof(buf), fmt, args);
|
||||||
|
va_end(args);
|
||||||
|
Serial.println(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void serial_debug_print_json(const String &json) {
|
||||||
|
if (!SERIAL_DEBUG_MODE || !SERIAL_DEBUG_DUMP_JSON) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const char *data = json.c_str();
|
||||||
|
size_t len = json.length();
|
||||||
|
const size_t chunk = 128;
|
||||||
|
for (size_t i = 0; i < len; i += chunk) {
|
||||||
|
size_t n = len - i;
|
||||||
|
if (n > chunk) {
|
||||||
|
n = chunk;
|
||||||
|
}
|
||||||
|
Serial.write(reinterpret_cast<const uint8_t *>(data + i), n);
|
||||||
|
watchdog_kick();
|
||||||
|
delay(0);
|
||||||
|
}
|
||||||
|
Serial.write('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint16_t g_last_batch_id_rx[NUM_SENDERS] = {};
|
||||||
|
|
||||||
struct BatchRxState {
|
struct BatchRxState {
|
||||||
bool active;
|
bool active;
|
||||||
@@ -66,6 +131,7 @@ struct BatchRxState {
|
|||||||
uint16_t total_len;
|
uint16_t total_len;
|
||||||
uint16_t received_len;
|
uint16_t received_len;
|
||||||
uint32_t last_rx_ms;
|
uint32_t last_rx_ms;
|
||||||
|
uint32_t timeout_ms;
|
||||||
uint8_t buffer[BATCH_MAX_COMPRESSED];
|
uint8_t buffer[BATCH_MAX_COMPRESSED];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,33 +154,65 @@ static void init_sender_statuses() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void push_meter_sample(const MeterData &data) {
|
static void update_battery_cache() {
|
||||||
g_meter_samples[g_meter_sample_head] = data;
|
MeterData tmp = {};
|
||||||
g_meter_sample_head = (g_meter_sample_head + 1) % METER_BATCH_MAX_SAMPLES;
|
read_battery(tmp);
|
||||||
if (g_meter_sample_count < METER_BATCH_MAX_SAMPLES) {
|
g_last_battery_voltage_v = tmp.battery_voltage_v;
|
||||||
g_meter_sample_count++;
|
g_last_battery_percent = tmp.battery_percent;
|
||||||
}
|
g_last_battery_ms = millis();
|
||||||
}
|
}
|
||||||
|
|
||||||
static size_t copy_meter_samples(MeterData *out, size_t max_count) {
|
static bool batch_queue_drop_oldest() {
|
||||||
if (!out || max_count == 0 || g_meter_sample_count == 0) {
|
if (g_batch_count == 0) {
|
||||||
return 0;
|
return false;
|
||||||
}
|
}
|
||||||
size_t count = g_meter_sample_count < max_count ? g_meter_sample_count : max_count;
|
bool dropped_inflight = g_inflight_active && g_batch_queue[g_batch_tail].batch_id_valid &&
|
||||||
size_t start = (g_meter_sample_head + METER_BATCH_MAX_SAMPLES - count) % METER_BATCH_MAX_SAMPLES;
|
g_inflight_batch_id == g_batch_queue[g_batch_tail].batch_id;
|
||||||
for (size_t i = 0; i < count; ++i) {
|
if (dropped_inflight) {
|
||||||
out[i] = g_meter_samples[(start + i) % METER_BATCH_MAX_SAMPLES];
|
g_batch_ack_pending = false;
|
||||||
|
g_batch_retry_count = 0;
|
||||||
|
g_inflight_active = false;
|
||||||
|
g_inflight_count = 0;
|
||||||
|
g_inflight_batch_id = 0;
|
||||||
}
|
}
|
||||||
return count;
|
g_batch_tail = (g_batch_tail + 1) % BATCH_QUEUE_DEPTH;
|
||||||
|
g_batch_count--;
|
||||||
|
return dropped_inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
static BatchBuffer *batch_queue_peek() {
|
||||||
|
if (g_batch_count == 0) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return &g_batch_queue[g_batch_tail];
|
||||||
|
}
|
||||||
|
|
||||||
|
static void batch_queue_enqueue(const MeterData *samples, uint8_t count) {
|
||||||
|
if (!samples || count == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g_batch_count >= BATCH_QUEUE_DEPTH) {
|
||||||
|
if (batch_queue_drop_oldest()) {
|
||||||
|
g_batch_id++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BatchBuffer &slot = g_batch_queue[g_batch_head];
|
||||||
|
slot.batch_id = 0;
|
||||||
|
slot.batch_id_valid = false;
|
||||||
|
slot.count = count;
|
||||||
|
for (uint8_t i = 0; i < count; ++i) {
|
||||||
|
slot.samples[i] = samples[i];
|
||||||
|
}
|
||||||
|
g_batch_head = (g_batch_head + 1) % BATCH_QUEUE_DEPTH;
|
||||||
|
g_batch_count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
static uint32_t last_sample_ts() {
|
static uint32_t last_sample_ts() {
|
||||||
if (g_meter_sample_count == 0) {
|
if (g_last_sample_ts_utc == 0) {
|
||||||
uint32_t now_utc = time_get_utc();
|
uint32_t now_utc = time_get_utc();
|
||||||
return now_utc > 0 ? now_utc : millis() / 1000;
|
return now_utc > 0 ? now_utc : millis() / 1000;
|
||||||
}
|
}
|
||||||
size_t idx = (g_meter_sample_head + METER_BATCH_MAX_SAMPLES - 1) % METER_BATCH_MAX_SAMPLES;
|
return g_last_sample_ts_utc;
|
||||||
return g_meter_samples[idx].ts_utc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void note_fault(FaultCounters &counters, FaultType &last_type, uint32_t &last_ts_utc, uint32_t &last_ts_ms, FaultType type) {
|
static void note_fault(FaultCounters &counters, FaultType &last_type, uint32_t &last_ts_utc, uint32_t &last_ts_ms, FaultType type) {
|
||||||
@@ -159,7 +257,12 @@ static void publish_faults_if_needed(const char *device_id, const FaultCounters
|
|||||||
|
|
||||||
#ifdef ARDUINO_ARCH_ESP32
|
#ifdef ARDUINO_ARCH_ESP32
|
||||||
static void watchdog_init() {
|
static void watchdog_init() {
|
||||||
esp_task_wdt_init(WATCHDOG_TIMEOUT_SEC, true);
|
esp_task_wdt_deinit();
|
||||||
|
esp_task_wdt_config_t config = {};
|
||||||
|
config.timeout_ms = WATCHDOG_TIMEOUT_SEC * 1000;
|
||||||
|
config.idle_core_mask = 0;
|
||||||
|
config.trigger_panic = true;
|
||||||
|
esp_task_wdt_init(&config);
|
||||||
esp_task_wdt_add(nullptr);
|
esp_task_wdt_add(nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +283,46 @@ static uint16_t read_u16_le(const uint8_t *src) {
|
|||||||
return static_cast<uint16_t>(src[0]) | (static_cast<uint16_t>(src[1]) << 8);
|
return static_cast<uint16_t>(src[0]) | (static_cast<uint16_t>(src[1]) << 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_display) {
|
static uint32_t compute_batch_rx_timeout_ms(uint16_t total_len, uint8_t chunk_count) {
|
||||||
|
if (total_len == 0 || chunk_count == 0) {
|
||||||
|
return 10000;
|
||||||
|
}
|
||||||
|
size_t max_chunk_payload = total_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : total_len;
|
||||||
|
size_t payload_len = BATCH_HEADER_SIZE + max_chunk_payload;
|
||||||
|
size_t packet_len = 5 + payload_len + 2;
|
||||||
|
uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len);
|
||||||
|
uint32_t timeout_ms = static_cast<uint32_t>(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS;
|
||||||
|
return timeout_ms < 10000 ? 10000 : timeout_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t compute_batch_ack_timeout_ms(size_t payload_len) {
|
||||||
|
if (payload_len == 0) {
|
||||||
|
return 10000;
|
||||||
|
}
|
||||||
|
uint8_t chunk_count = static_cast<uint8_t>((payload_len + BATCH_CHUNK_PAYLOAD - 1) / BATCH_CHUNK_PAYLOAD);
|
||||||
|
size_t packet_len = 5 + BATCH_HEADER_SIZE + (payload_len > BATCH_CHUNK_PAYLOAD ? BATCH_CHUNK_PAYLOAD : payload_len) + 2;
|
||||||
|
uint32_t per_chunk_toa_ms = lora_airtime_ms(packet_len);
|
||||||
|
uint32_t timeout_ms = static_cast<uint32_t>(chunk_count) * per_chunk_toa_ms + BATCH_RX_MARGIN_MS;
|
||||||
|
return timeout_ms < 10000 ? 10000 : timeout_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool inject_batch_meta(String &json, int16_t rssi_dbm, float snr_db, uint32_t rx_ts_utc) {
|
||||||
|
DynamicJsonDocument doc(8192);
|
||||||
|
DeserializationError err = deserializeJson(doc, json);
|
||||||
|
if (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject meta = doc.createNestedObject("meta");
|
||||||
|
meta["rssi"] = rssi_dbm;
|
||||||
|
meta["snr"] = snr_db;
|
||||||
|
meta["rx_ts"] = rx_ts_utc;
|
||||||
|
|
||||||
|
json = "";
|
||||||
|
return serializeJson(doc, json) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_display, uint16_t batch_id) {
|
||||||
if (!data || len == 0 || len > BATCH_MAX_COMPRESSED) {
|
if (!data || len == 0 || len > BATCH_MAX_COMPRESSED) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -204,55 +346,147 @@ static bool send_batch_payload(const uint8_t *data, size_t len, uint32_t ts_for_
|
|||||||
pkt.payload_len = chunk_len + BATCH_HEADER_SIZE;
|
pkt.payload_len = chunk_len + BATCH_HEADER_SIZE;
|
||||||
|
|
||||||
uint8_t *payload = pkt.payload;
|
uint8_t *payload = pkt.payload;
|
||||||
write_u16_le(&payload[0], g_batch_id);
|
write_u16_le(&payload[0], batch_id);
|
||||||
payload[2] = i;
|
payload[2] = i;
|
||||||
payload[3] = chunk_count;
|
payload[3] = chunk_count;
|
||||||
write_u16_le(&payload[4], static_cast<uint16_t>(len));
|
write_u16_le(&payload[4], static_cast<uint16_t>(len));
|
||||||
memcpy(&payload[BATCH_HEADER_SIZE], data + offset, chunk_len);
|
memcpy(&payload[BATCH_HEADER_SIZE], data + offset, chunk_len);
|
||||||
|
|
||||||
|
watchdog_kick();
|
||||||
|
uint32_t tx_start = millis();
|
||||||
bool ok = lora_send(pkt);
|
bool ok = lora_send(pkt);
|
||||||
|
uint32_t tx_ms = millis() - tx_start;
|
||||||
all_ok = all_ok && ok;
|
all_ok = all_ok && ok;
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx);
|
note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx);
|
||||||
display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms);
|
display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms);
|
||||||
}
|
}
|
||||||
|
if (SERIAL_DEBUG_MODE && tx_ms > 500) {
|
||||||
|
serial_debug_printf("tx: chunk %u/%u took %lums ok=%u", static_cast<unsigned>(i + 1),
|
||||||
|
static_cast<unsigned>(chunk_count), static_cast<unsigned long>(tx_ms), ok ? 1 : 0);
|
||||||
|
}
|
||||||
offset += chunk_len;
|
offset += chunk_len;
|
||||||
delay(10);
|
delay(10);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (all_ok) {
|
|
||||||
g_batch_id++;
|
|
||||||
}
|
|
||||||
display_set_last_tx(all_ok, ts_for_display);
|
display_set_last_tx(all_ok, ts_for_display);
|
||||||
return all_ok;
|
return all_ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool send_meter_batch(uint32_t ts_for_display) {
|
static void send_batch_ack(uint16_t batch_id, uint16_t sender_id) {
|
||||||
MeterData ordered[METER_BATCH_MAX_SAMPLES];
|
LoraPacket ack = {};
|
||||||
size_t count = copy_meter_samples(ordered, METER_BATCH_MAX_SAMPLES);
|
ack.protocol_version = PROTOCOL_VERSION;
|
||||||
if (count == 0) {
|
ack.role = DeviceRole::Receiver;
|
||||||
|
ack.device_id_short = g_short_id;
|
||||||
|
ack.payload_type = PayloadType::Ack;
|
||||||
|
ack.payload_len = 6;
|
||||||
|
write_u16_le(&ack.payload[0], batch_id);
|
||||||
|
write_u16_le(&ack.payload[2], sender_id);
|
||||||
|
write_u16_le(&ack.payload[4], g_short_id);
|
||||||
|
lora_send(ack);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool prepare_inflight_from_queue() {
|
||||||
|
if (g_inflight_active) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
BatchBuffer *batch = batch_queue_peek();
|
||||||
|
if (!batch || batch->count == 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!batch->batch_id_valid) {
|
||||||
|
batch->batch_id = g_batch_id;
|
||||||
|
batch->batch_id_valid = true;
|
||||||
|
}
|
||||||
|
g_inflight_count = batch->count;
|
||||||
|
g_inflight_batch_id = batch->batch_id;
|
||||||
|
for (uint8_t i = 0; i < g_inflight_count; ++i) {
|
||||||
|
g_inflight_samples[i] = batch->samples[i];
|
||||||
|
}
|
||||||
|
g_inflight_active = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
String json;
|
static bool send_inflight_batch(uint32_t ts_for_display) {
|
||||||
if (!meterBatchToJson(ordered, count, json, &g_sender_faults, g_sender_last_error)) {
|
if (!g_inflight_active || g_inflight_count == 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
uint32_t json_start = millis();
|
||||||
|
String json;
|
||||||
|
if (!meterBatchToJson(g_inflight_samples, g_inflight_count, g_inflight_batch_id, json, &g_sender_faults, g_sender_last_error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint32_t json_ms = millis() - json_start;
|
||||||
|
if (SERIAL_DEBUG_MODE) {
|
||||||
|
serial_debug_printf("tx: batch_id=%u count=%u json_len=%u", g_inflight_batch_id, g_inflight_count, static_cast<unsigned>(json.length()));
|
||||||
|
if (json_ms > 200) {
|
||||||
|
serial_debug_printf("tx: json encode took %lums", static_cast<unsigned long>(json_ms));
|
||||||
|
}
|
||||||
|
serial_debug_print_json(json);
|
||||||
|
}
|
||||||
|
|
||||||
static uint8_t compressed[BATCH_MAX_COMPRESSED];
|
static uint8_t compressed[BATCH_MAX_COMPRESSED];
|
||||||
size_t compressed_len = 0;
|
size_t compressed_len = 0;
|
||||||
|
uint32_t compress_start = millis();
|
||||||
if (!compressBuffer(reinterpret_cast<const uint8_t *>(json.c_str()), json.length(), compressed, sizeof(compressed), compressed_len)) {
|
if (!compressBuffer(reinterpret_cast<const uint8_t *>(json.c_str()), json.length(), compressed, sizeof(compressed), compressed_len)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
uint32_t compress_ms = millis() - compress_start;
|
||||||
|
if (SERIAL_DEBUG_MODE && compress_ms > 200) {
|
||||||
|
serial_debug_printf("tx: compress took %lums", static_cast<unsigned long>(compress_ms));
|
||||||
|
}
|
||||||
|
g_batch_ack_timeout_ms = compute_batch_ack_timeout_ms(compressed_len);
|
||||||
|
|
||||||
bool ok = send_batch_payload(compressed, compressed_len, ts_for_display);
|
uint32_t send_start = millis();
|
||||||
|
bool ok = send_batch_payload(compressed, compressed_len, ts_for_display, g_inflight_batch_id);
|
||||||
|
uint32_t send_ms = millis() - send_start;
|
||||||
|
if (SERIAL_DEBUG_MODE && send_ms > 1000) {
|
||||||
|
serial_debug_printf("tx: send batch took %lums", static_cast<unsigned long>(send_ms));
|
||||||
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
g_meter_sample_count = 0;
|
g_last_batch_send_ms = millis();
|
||||||
g_meter_sample_head = 0;
|
serial_debug_printf("tx: sent batch_id=%u len=%u", g_inflight_batch_id, static_cast<unsigned>(compressed_len));
|
||||||
|
} else {
|
||||||
|
serial_debug_printf("tx: send failed batch_id=%u", g_inflight_batch_id);
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool send_meter_batch(uint32_t ts_for_display) {
|
||||||
|
if (!prepare_inflight_from_queue()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool ok = send_inflight_batch(ts_for_display);
|
||||||
|
if (ok) {
|
||||||
|
g_last_sent_batch_id = g_inflight_batch_id;
|
||||||
|
g_batch_ack_pending = true;
|
||||||
|
} else {
|
||||||
|
g_inflight_active = false;
|
||||||
|
g_inflight_count = 0;
|
||||||
|
g_inflight_batch_id = 0;
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool resend_inflight_batch(uint32_t ts_for_display) {
|
||||||
|
if (!g_batch_ack_pending || !g_inflight_active || g_inflight_count == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return send_inflight_batch(ts_for_display);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void finish_inflight_batch() {
|
||||||
|
if (g_batch_count > 0) {
|
||||||
|
batch_queue_drop_oldest();
|
||||||
|
}
|
||||||
|
g_batch_ack_pending = false;
|
||||||
|
g_batch_retry_count = 0;
|
||||||
|
g_inflight_active = false;
|
||||||
|
g_inflight_count = 0;
|
||||||
|
g_inflight_batch_id = 0;
|
||||||
|
g_batch_id++;
|
||||||
|
}
|
||||||
|
|
||||||
static void reset_batch_rx() {
|
static void reset_batch_rx() {
|
||||||
g_batch_rx.active = false;
|
g_batch_rx.active = false;
|
||||||
g_batch_rx.batch_id = 0;
|
g_batch_rx.batch_id = 0;
|
||||||
@@ -261,9 +495,10 @@ static void reset_batch_rx() {
|
|||||||
g_batch_rx.total_len = 0;
|
g_batch_rx.total_len = 0;
|
||||||
g_batch_rx.received_len = 0;
|
g_batch_rx.received_len = 0;
|
||||||
g_batch_rx.last_rx_ms = 0;
|
g_batch_rx.last_rx_ms = 0;
|
||||||
|
g_batch_rx.timeout_ms = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool &decode_error) {
|
static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool &decode_error, uint16_t &out_batch_id) {
|
||||||
decode_error = false;
|
decode_error = false;
|
||||||
if (pkt.payload_len < BATCH_HEADER_SIZE) {
|
if (pkt.payload_len < BATCH_HEADER_SIZE) {
|
||||||
return false;
|
return false;
|
||||||
@@ -276,7 +511,7 @@ static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool &
|
|||||||
size_t chunk_len = pkt.payload_len - BATCH_HEADER_SIZE;
|
size_t chunk_len = pkt.payload_len - BATCH_HEADER_SIZE;
|
||||||
uint32_t now_ms = millis();
|
uint32_t now_ms = millis();
|
||||||
|
|
||||||
if (!g_batch_rx.active || batch_id != g_batch_rx.batch_id || (now_ms - g_batch_rx.last_rx_ms > BATCH_RX_TIMEOUT_MS)) {
|
if (!g_batch_rx.active || batch_id != g_batch_rx.batch_id || (now_ms - g_batch_rx.last_rx_ms > g_batch_rx.timeout_ms)) {
|
||||||
if (chunk_index != 0) {
|
if (chunk_index != 0) {
|
||||||
reset_batch_rx();
|
reset_batch_rx();
|
||||||
return false;
|
return false;
|
||||||
@@ -291,6 +526,7 @@ static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool &
|
|||||||
g_batch_rx.total_len = total_len;
|
g_batch_rx.total_len = total_len;
|
||||||
g_batch_rx.received_len = 0;
|
g_batch_rx.received_len = 0;
|
||||||
g_batch_rx.next_index = 0;
|
g_batch_rx.next_index = 0;
|
||||||
|
g_batch_rx.timeout_ms = compute_batch_rx_timeout_ms(total_len, chunk_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!g_batch_rx.active || chunk_index != g_batch_rx.next_index || chunk_count != g_batch_rx.expected_chunks) {
|
if (!g_batch_rx.active || chunk_index != g_batch_rx.next_index || chunk_count != g_batch_rx.expected_chunks) {
|
||||||
@@ -323,6 +559,7 @@ static bool process_batch_packet(const LoraPacket &pkt, String &out_json, bool &
|
|||||||
}
|
}
|
||||||
decompressed[decompressed_len] = '\0';
|
decompressed[decompressed_len] = '\0';
|
||||||
out_json = String(reinterpret_cast<const char *>(decompressed));
|
out_json = String(reinterpret_cast<const char *>(decompressed));
|
||||||
|
out_batch_id = batch_id;
|
||||||
reset_batch_rx();
|
reset_batch_rx();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -338,6 +575,13 @@ void setup() {
|
|||||||
g_boot_ms = millis();
|
g_boot_ms = millis();
|
||||||
g_role = detect_role();
|
g_role = detect_role();
|
||||||
init_device_ids(g_short_id, g_device_id, sizeof(g_device_id));
|
init_device_ids(g_short_id, g_device_id, sizeof(g_device_id));
|
||||||
|
if (SERIAL_DEBUG_MODE) {
|
||||||
|
#ifdef ARDUINO_ARCH_ESP32
|
||||||
|
serial_debug_printf("boot: reset_reason=%d", static_cast<int>(esp_reset_reason()));
|
||||||
|
#endif
|
||||||
|
serial_debug_printf("boot: role=%s short_id=%04X dev=%s", g_role == DeviceRole::Sender ? "sender" : "receiver",
|
||||||
|
g_short_id, g_device_id);
|
||||||
|
}
|
||||||
|
|
||||||
lora_init();
|
lora_init();
|
||||||
display_init();
|
display_init();
|
||||||
@@ -351,6 +595,7 @@ void setup() {
|
|||||||
meter_init();
|
meter_init();
|
||||||
g_last_sample_ms = millis() - METER_SAMPLE_INTERVAL_MS;
|
g_last_sample_ms = millis() - METER_SAMPLE_INTERVAL_MS;
|
||||||
g_last_send_ms = millis();
|
g_last_send_ms = millis();
|
||||||
|
update_battery_cache();
|
||||||
} else {
|
} else {
|
||||||
power_receiver_init();
|
power_receiver_init();
|
||||||
wifi_manager_init();
|
wifi_manager_init();
|
||||||
@@ -363,6 +608,7 @@ void setup() {
|
|||||||
time_receiver_init(g_cfg.ntp_server_1.c_str(), g_cfg.ntp_server_2.c_str());
|
time_receiver_init(g_cfg.ntp_server_1.c_str(), g_cfg.ntp_server_2.c_str());
|
||||||
mqtt_init(g_cfg, g_device_id);
|
mqtt_init(g_cfg, g_device_id);
|
||||||
web_server_set_config(g_cfg);
|
web_server_set_config(g_cfg);
|
||||||
|
web_server_set_sender_faults(g_sender_faults_remote, g_sender_last_error_remote);
|
||||||
web_server_begin_sta(g_sender_statuses, NUM_SENDERS);
|
web_server_begin_sta(g_sender_statuses, NUM_SENDERS);
|
||||||
} else {
|
} else {
|
||||||
g_ap_mode = true;
|
g_ap_mode = true;
|
||||||
@@ -376,6 +622,7 @@ void setup() {
|
|||||||
g_cfg.ntp_server_2 = "time.nist.gov";
|
g_cfg.ntp_server_2 = "time.nist.gov";
|
||||||
}
|
}
|
||||||
web_server_set_config(g_cfg);
|
web_server_set_config(g_cfg);
|
||||||
|
web_server_set_sender_faults(g_sender_faults_remote, g_sender_last_error_remote);
|
||||||
web_server_begin_ap(g_sender_statuses, NUM_SENDERS);
|
web_server_begin_ap(g_sender_statuses, NUM_SENDERS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,6 +631,19 @@ void setup() {
|
|||||||
static void sender_loop() {
|
static void sender_loop() {
|
||||||
watchdog_kick();
|
watchdog_kick();
|
||||||
uint32_t now_ms = millis();
|
uint32_t now_ms = millis();
|
||||||
|
display_set_sender_queue(g_batch_count, g_build_count > 0);
|
||||||
|
display_set_sender_batches(g_last_acked_batch_id, g_batch_id);
|
||||||
|
if (SERIAL_DEBUG_MODE && now_ms - g_last_debug_log_ms >= 5000) {
|
||||||
|
g_last_debug_log_ms = now_ms;
|
||||||
|
serial_debug_printf("state: Q=%u%s A=%u C=%u inflight=%u ack_pending=%u retries=%u",
|
||||||
|
g_batch_count,
|
||||||
|
g_build_count > 0 ? "+" : "",
|
||||||
|
g_last_acked_batch_id,
|
||||||
|
g_batch_id,
|
||||||
|
g_inflight_count,
|
||||||
|
g_batch_ack_pending ? 1 : 0,
|
||||||
|
g_batch_retry_count);
|
||||||
|
}
|
||||||
|
|
||||||
if (now_ms - g_last_sample_ms >= METER_SAMPLE_INTERVAL_MS) {
|
if (now_ms - g_last_sample_ms >= METER_SAMPLE_INTERVAL_MS) {
|
||||||
g_last_sample_ms = now_ms;
|
g_last_sample_ms = now_ms;
|
||||||
@@ -396,25 +656,91 @@ static void sender_loop() {
|
|||||||
note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::MeterRead);
|
note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::MeterRead);
|
||||||
display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms);
|
display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms);
|
||||||
}
|
}
|
||||||
read_battery(data);
|
if (g_build_count == 0) {
|
||||||
|
update_battery_cache();
|
||||||
|
}
|
||||||
|
data.battery_voltage_v = g_last_battery_voltage_v;
|
||||||
|
data.battery_percent = g_last_battery_percent;
|
||||||
|
|
||||||
uint32_t now_utc = time_get_utc();
|
uint32_t now_utc = time_get_utc();
|
||||||
data.ts_utc = now_utc > 0 ? now_utc : millis() / 1000;
|
data.ts_utc = now_utc > 0 ? now_utc : millis() / 1000;
|
||||||
data.valid = meter_ok;
|
data.valid = meter_ok;
|
||||||
|
|
||||||
push_meter_sample(data);
|
g_last_sample_ts_utc = data.ts_utc;
|
||||||
|
g_build_samples[g_build_count++] = data;
|
||||||
|
if (g_build_count >= METER_BATCH_MAX_SAMPLES) {
|
||||||
|
batch_queue_enqueue(g_build_samples, g_build_count);
|
||||||
|
g_build_count = 0;
|
||||||
|
}
|
||||||
display_set_last_meter(data);
|
display_set_last_meter(data);
|
||||||
display_set_last_read(meter_ok, data.ts_utc);
|
display_set_last_read(meter_ok, data.ts_utc);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (now_ms - g_last_send_ms >= METER_SEND_INTERVAL_MS) {
|
if (!g_batch_ack_pending && now_ms - g_last_send_ms >= METER_SEND_INTERVAL_MS) {
|
||||||
g_last_send_ms = now_ms;
|
g_last_send_ms = now_ms;
|
||||||
send_meter_batch(last_sample_ts());
|
send_meter_batch(last_sample_ts());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (g_batch_ack_pending) {
|
||||||
|
uint32_t end_ms = millis() + 400;
|
||||||
|
while (millis() < end_ms) {
|
||||||
|
LoraPacket ack_pkt = {};
|
||||||
|
if (!lora_receive(ack_pkt, 0) || ack_pkt.protocol_version != PROTOCOL_VERSION) {
|
||||||
|
delay(5);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ack_pkt.payload_type == PayloadType::Ack && ack_pkt.payload_len >= 6 && ack_pkt.role == DeviceRole::Receiver) {
|
||||||
|
uint16_t ack_id = read_u16_le(ack_pkt.payload);
|
||||||
|
uint16_t ack_sender = read_u16_le(&ack_pkt.payload[2]);
|
||||||
|
uint16_t ack_receiver = read_u16_le(&ack_pkt.payload[4]);
|
||||||
|
if (ack_sender == g_short_id && ack_receiver == ack_pkt.device_id_short &&
|
||||||
|
g_batch_ack_pending && ack_id == g_last_sent_batch_id) {
|
||||||
|
g_last_acked_batch_id = ack_id;
|
||||||
|
serial_debug_printf("ack: ok batch_id=%u", ack_id);
|
||||||
|
finish_inflight_batch();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LoraPacket rx = {};
|
LoraPacket rx = {};
|
||||||
if (lora_receive(rx, 0) && rx.protocol_version == PROTOCOL_VERSION && rx.payload_type == PayloadType::TimeSync) {
|
if (lora_receive(rx, 0) && rx.protocol_version == PROTOCOL_VERSION) {
|
||||||
|
if (rx.payload_type == PayloadType::TimeSync) {
|
||||||
time_handle_timesync_payload(rx.payload, rx.payload_len);
|
time_handle_timesync_payload(rx.payload, rx.payload_len);
|
||||||
|
} else if (rx.payload_type == PayloadType::Ack && rx.payload_len >= 6 && rx.role == DeviceRole::Receiver) {
|
||||||
|
uint16_t ack_id = read_u16_le(rx.payload);
|
||||||
|
uint16_t ack_sender = read_u16_le(&rx.payload[2]);
|
||||||
|
uint16_t ack_receiver = read_u16_le(&rx.payload[4]);
|
||||||
|
if (ack_sender == g_short_id && ack_receiver == rx.device_id_short &&
|
||||||
|
g_batch_ack_pending && ack_id == g_last_sent_batch_id) {
|
||||||
|
g_last_acked_batch_id = ack_id;
|
||||||
|
serial_debug_printf("ack: ok batch_id=%u", ack_id);
|
||||||
|
finish_inflight_batch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_batch_ack_pending && (now_ms - g_last_batch_send_ms >= g_batch_ack_timeout_ms)) {
|
||||||
|
if (g_batch_retry_count < BATCH_MAX_RETRIES) {
|
||||||
|
g_batch_retry_count++;
|
||||||
|
serial_debug_printf("ack: timeout batch_id=%u retry=%u", g_inflight_batch_id, g_batch_retry_count);
|
||||||
|
resend_inflight_batch(last_sample_ts());
|
||||||
|
} else {
|
||||||
|
serial_debug_printf("ack: failed batch_id=%u policy=%s", g_inflight_batch_id,
|
||||||
|
BATCH_RETRY_POLICY == BatchRetryPolicy::Drop ? "drop" : "keep");
|
||||||
|
if (BATCH_RETRY_POLICY == BatchRetryPolicy::Drop) {
|
||||||
|
finish_inflight_batch();
|
||||||
|
} else {
|
||||||
|
g_batch_ack_pending = false;
|
||||||
|
g_batch_retry_count = 0;
|
||||||
|
g_inflight_active = false;
|
||||||
|
g_inflight_count = 0;
|
||||||
|
g_inflight_batch_id = 0;
|
||||||
|
}
|
||||||
|
note_fault(g_sender_faults, g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms, FaultType::LoraTx);
|
||||||
|
display_set_last_error(g_sender_last_error, g_sender_last_error_utc, g_sender_last_error_ms);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
display_tick();
|
display_tick();
|
||||||
@@ -422,7 +748,7 @@ static void sender_loop() {
|
|||||||
uint32_t next_sample_due = g_last_sample_ms + METER_SAMPLE_INTERVAL_MS;
|
uint32_t next_sample_due = g_last_sample_ms + METER_SAMPLE_INTERVAL_MS;
|
||||||
uint32_t next_send_due = g_last_send_ms + METER_SEND_INTERVAL_MS;
|
uint32_t next_send_due = g_last_send_ms + METER_SEND_INTERVAL_MS;
|
||||||
uint32_t next_due = next_sample_due < next_send_due ? next_sample_due : next_send_due;
|
uint32_t next_due = next_sample_due < next_send_due ? next_sample_due : next_send_due;
|
||||||
if (next_due > now_ms) {
|
if (!g_batch_ack_pending && next_due > now_ms) {
|
||||||
watchdog_kick();
|
watchdog_kick();
|
||||||
light_sleep_ms(next_due - now_ms);
|
light_sleep_ms(next_due - now_ms);
|
||||||
}
|
}
|
||||||
@@ -482,12 +808,28 @@ static void receiver_loop() {
|
|||||||
} else if (pkt.payload_type == PayloadType::MeterBatch) {
|
} else if (pkt.payload_type == PayloadType::MeterBatch) {
|
||||||
String json;
|
String json;
|
||||||
bool decode_error = false;
|
bool decode_error = false;
|
||||||
if (process_batch_packet(pkt, json, decode_error)) {
|
uint16_t batch_id = 0;
|
||||||
|
if (process_batch_packet(pkt, json, decode_error, batch_id)) {
|
||||||
|
uint32_t rx_ts_utc = time_get_utc();
|
||||||
|
if (rx_ts_utc == 0) {
|
||||||
|
rx_ts_utc = millis() / 1000;
|
||||||
|
}
|
||||||
|
inject_batch_meta(json, pkt.rssi_dbm, pkt.snr_db, rx_ts_utc);
|
||||||
MeterData samples[METER_BATCH_MAX_SAMPLES];
|
MeterData samples[METER_BATCH_MAX_SAMPLES];
|
||||||
size_t count = 0;
|
size_t count = 0;
|
||||||
if (jsonToMeterBatch(json, samples, METER_BATCH_MAX_SAMPLES, count)) {
|
int8_t sender_idx = -1;
|
||||||
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
|
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
|
||||||
if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) {
|
if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) {
|
||||||
|
sender_idx = static_cast<int8_t>(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool duplicate = sender_idx >= 0 && g_last_batch_id_rx[sender_idx] == batch_id;
|
||||||
|
if (duplicate) {
|
||||||
|
send_batch_ack(batch_id, pkt.device_id_short);
|
||||||
|
} else if (jsonToMeterBatch(json, samples, METER_BATCH_MAX_SAMPLES, count)) {
|
||||||
|
if (sender_idx >= 0) {
|
||||||
|
web_server_set_last_batch(static_cast<uint8_t>(sender_idx), samples, count);
|
||||||
for (size_t s = 0; s < count; ++s) {
|
for (size_t s = 0; s < count; ++s) {
|
||||||
samples[s].link_valid = true;
|
samples[s].link_valid = true;
|
||||||
samples[s].link_rssi_dbm = pkt.rssi_dbm;
|
samples[s].link_rssi_dbm = pkt.rssi_dbm;
|
||||||
@@ -496,23 +838,23 @@ static void receiver_loop() {
|
|||||||
mqtt_publish_state(samples[s]);
|
mqtt_publish_state(samples[s]);
|
||||||
}
|
}
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
g_sender_statuses[i].last_data = samples[count - 1];
|
g_sender_statuses[sender_idx].last_data = samples[count - 1];
|
||||||
g_sender_statuses[i].last_update_ts_utc = samples[count - 1].ts_utc;
|
g_sender_statuses[sender_idx].last_update_ts_utc = samples[count - 1].ts_utc;
|
||||||
g_sender_statuses[i].has_data = true;
|
g_sender_statuses[sender_idx].has_data = true;
|
||||||
g_sender_faults_remote[i].meter_read_fail = samples[count - 1].err_meter_read;
|
g_sender_faults_remote[sender_idx].meter_read_fail = samples[count - 1].err_meter_read;
|
||||||
g_sender_faults_remote[i].lora_tx_fail = samples[count - 1].err_lora_tx;
|
g_sender_faults_remote[sender_idx].lora_tx_fail = samples[count - 1].err_lora_tx;
|
||||||
g_sender_last_error_remote[i] = samples[count - 1].last_error;
|
g_sender_last_error_remote[sender_idx] = samples[count - 1].last_error;
|
||||||
g_sender_last_error_remote_utc[i] = time_get_utc();
|
g_sender_last_error_remote_utc[sender_idx] = time_get_utc();
|
||||||
g_sender_last_error_remote_ms[i] = millis();
|
g_sender_last_error_remote_ms[sender_idx] = millis();
|
||||||
if (ENABLE_HA_DISCOVERY && !g_sender_discovery_sent[i]) {
|
if (ENABLE_HA_DISCOVERY && !g_sender_discovery_sent[sender_idx]) {
|
||||||
g_sender_discovery_sent[i] = mqtt_publish_discovery(samples[count - 1].device_id);
|
g_sender_discovery_sent[sender_idx] = mqtt_publish_discovery(samples[count - 1].device_id);
|
||||||
}
|
}
|
||||||
publish_faults_if_needed(samples[count - 1].device_id, g_sender_faults_remote[i], g_sender_faults_remote_published[i],
|
publish_faults_if_needed(samples[count - 1].device_id, g_sender_faults_remote[sender_idx], g_sender_faults_remote_published[sender_idx],
|
||||||
g_sender_last_error_remote[i], g_sender_last_error_remote_published[i],
|
g_sender_last_error_remote[sender_idx], g_sender_last_error_remote_published[sender_idx],
|
||||||
g_sender_last_error_remote_utc[i], g_sender_last_error_remote_ms[i]);
|
g_sender_last_error_remote_utc[sender_idx], g_sender_last_error_remote_ms[sender_idx]);
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
g_last_batch_id_rx[sender_idx] = batch_id;
|
||||||
|
send_batch_ack(batch_id, pkt.device_id_short);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
|
note_fault(g_receiver_faults, g_receiver_last_error, g_receiver_last_error_utc, g_receiver_last_error_ms, FaultType::Decode);
|
||||||
|
|||||||
@@ -5,167 +5,11 @@
|
|||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
static constexpr uint32_t METER_READ_TIMEOUT_MS = 2000;
|
static constexpr uint32_t METER_READ_TIMEOUT_MS = 2000;
|
||||||
static constexpr size_t SML_BUFFER_SIZE = 2048;
|
|
||||||
|
|
||||||
static const uint8_t OBIS_ENERGY_TOTAL[6] = {0x01, 0x00, 0x01, 0x08, 0x00, 0xFF};
|
|
||||||
static const uint8_t OBIS_TOTAL_POWER[6] = {0x01, 0x00, 0x10, 0x07, 0x00, 0xFF};
|
|
||||||
static const uint8_t OBIS_P1[6] = {0x01, 0x00, 0x24, 0x07, 0x00, 0xFF};
|
|
||||||
static const uint8_t OBIS_P2[6] = {0x01, 0x00, 0x38, 0x07, 0x00, 0xFF};
|
|
||||||
static const uint8_t OBIS_P3[6] = {0x01, 0x00, 0x4C, 0x07, 0x00, 0xFF};
|
|
||||||
static const uint8_t OBIS_V1[6] = {0x01, 0x00, 0x20, 0x07, 0x00, 0xFF};
|
|
||||||
static const uint8_t OBIS_V2[6] = {0x01, 0x00, 0x34, 0x07, 0x00, 0xFF};
|
|
||||||
static const uint8_t OBIS_V3[6] = {0x01, 0x00, 0x48, 0x07, 0x00, 0xFF};
|
|
||||||
|
|
||||||
static bool find_obis_value(const uint8_t *buf, size_t len, const uint8_t *obis, float &out_value) {
|
|
||||||
for (size_t i = 0; i + 6 < len; ++i) {
|
|
||||||
if (memcmp(&buf[i], obis, 6) == 0) {
|
|
||||||
int8_t scaler = 0;
|
|
||||||
bool scaler_found = false;
|
|
||||||
bool value_found = false;
|
|
||||||
int64_t value = 0;
|
|
||||||
size_t cursor = i + 6;
|
|
||||||
size_t limit = (i + 6 + 120 < len) ? i + 6 + 120 : len;
|
|
||||||
|
|
||||||
while (cursor < limit) {
|
|
||||||
uint8_t tl = buf[cursor++];
|
|
||||||
if (tl == 0x00) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
uint8_t type = (tl >> 4) & 0x0F;
|
|
||||||
uint8_t tlen = tl & 0x0F;
|
|
||||||
if (tlen == 0 || cursor + tlen > len) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type == 0x05 || type == 0x06) {
|
|
||||||
int64_t val = 0;
|
|
||||||
for (uint8_t b = 0; b < tlen; ++b) {
|
|
||||||
val = (val << 8) | buf[cursor + b];
|
|
||||||
}
|
|
||||||
if (type == 0x05) {
|
|
||||||
int64_t sign_bit = 1LL << ((tlen * 8) - 1);
|
|
||||||
if (val & sign_bit) {
|
|
||||||
int64_t mask = (1LL << (tlen * 8)) - 1;
|
|
||||||
val = -((~val + 1) & mask);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!scaler_found && tlen <= 2 && val >= -6 && val <= 6) {
|
|
||||||
scaler = static_cast<int8_t>(val);
|
|
||||||
scaler_found = true;
|
|
||||||
} else if (!value_found) {
|
|
||||||
value = val;
|
|
||||||
value_found = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor += tlen;
|
|
||||||
if (value_found && scaler_found) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value_found) {
|
|
||||||
out_value = static_cast<float>(value) * powf(10.0f, scaler);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void meter_init() {
|
void meter_init() {
|
||||||
Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1);
|
Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool meter_read_sml(MeterData &data) {
|
|
||||||
uint8_t buffer[SML_BUFFER_SIZE];
|
|
||||||
size_t len = 0;
|
|
||||||
bool started = false;
|
|
||||||
uint32_t start = millis();
|
|
||||||
const uint8_t start_seq[] = {0x1B, 0x1B, 0x1B, 0x1B, 0x01, 0x01, 0x01, 0x01};
|
|
||||||
const uint8_t end_seq[] = {0x1B, 0x1B, 0x1B, 0x1B, 0x1A};
|
|
||||||
|
|
||||||
while (millis() - start < METER_READ_TIMEOUT_MS) {
|
|
||||||
while (Serial2.available()) {
|
|
||||||
uint8_t b = Serial2.read();
|
|
||||||
if (!started) {
|
|
||||||
buffer[len++] = b;
|
|
||||||
if (len >= sizeof(start_seq)) {
|
|
||||||
if (memcmp(&buffer[len - sizeof(start_seq)], start_seq, sizeof(start_seq)) == 0) {
|
|
||||||
started = true;
|
|
||||||
len = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (len >= sizeof(buffer)) {
|
|
||||||
len = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (len < sizeof(buffer)) {
|
|
||||||
buffer[len++] = b;
|
|
||||||
if (len >= sizeof(end_seq)) {
|
|
||||||
if (memcmp(&buffer[len - sizeof(end_seq)], end_seq, sizeof(end_seq)) == 0) {
|
|
||||||
start = millis();
|
|
||||||
goto parse_frame;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delay(5);
|
|
||||||
}
|
|
||||||
|
|
||||||
parse_frame:
|
|
||||||
if (!started || len == 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
data.energy_total_kwh = NAN;
|
|
||||||
data.total_power_w = NAN;
|
|
||||||
data.phase_power_w[0] = NAN;
|
|
||||||
data.phase_power_w[1] = NAN;
|
|
||||||
data.phase_power_w[2] = NAN;
|
|
||||||
data.phase_voltage_v[0] = NAN;
|
|
||||||
data.phase_voltage_v[1] = NAN;
|
|
||||||
data.phase_voltage_v[2] = NAN;
|
|
||||||
|
|
||||||
bool ok = true;
|
|
||||||
float value = 0.0f;
|
|
||||||
|
|
||||||
if (find_obis_value(buffer, len, OBIS_ENERGY_TOTAL, value)) {
|
|
||||||
data.energy_total_kwh = value;
|
|
||||||
} else {
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (find_obis_value(buffer, len, OBIS_TOTAL_POWER, value)) {
|
|
||||||
data.total_power_w = value;
|
|
||||||
} else {
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (find_obis_value(buffer, len, OBIS_P1, value)) {
|
|
||||||
data.phase_power_w[0] = value;
|
|
||||||
}
|
|
||||||
if (find_obis_value(buffer, len, OBIS_P2, value)) {
|
|
||||||
data.phase_power_w[1] = value;
|
|
||||||
}
|
|
||||||
if (find_obis_value(buffer, len, OBIS_P3, value)) {
|
|
||||||
data.phase_power_w[2] = value;
|
|
||||||
}
|
|
||||||
if (find_obis_value(buffer, len, OBIS_V1, value)) {
|
|
||||||
data.phase_voltage_v[0] = value;
|
|
||||||
}
|
|
||||||
if (find_obis_value(buffer, len, OBIS_V2, value)) {
|
|
||||||
data.phase_voltage_v[1] = value;
|
|
||||||
}
|
|
||||||
if (find_obis_value(buffer, len, OBIS_V3, value)) {
|
|
||||||
data.phase_voltage_v[2] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
data.valid = ok;
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
if (!p) {
|
if (!p) {
|
||||||
@@ -243,10 +87,6 @@ static bool meter_read_ascii(MeterData &data) {
|
|||||||
bool p1_ok = false;
|
bool p1_ok = false;
|
||||||
bool p2_ok = false;
|
bool p2_ok = false;
|
||||||
bool p3_ok = false;
|
bool p3_ok = false;
|
||||||
bool v1_ok = false;
|
|
||||||
bool v2_ok = false;
|
|
||||||
bool v3_ok = false;
|
|
||||||
|
|
||||||
char line[128];
|
char line[128];
|
||||||
size_t line_len = 0;
|
size_t line_len = 0;
|
||||||
|
|
||||||
@@ -298,21 +138,6 @@ static bool meter_read_ascii(MeterData &data) {
|
|||||||
p3_ok = true;
|
p3_ok = true;
|
||||||
got_any = true;
|
got_any = true;
|
||||||
}
|
}
|
||||||
if (parse_obis_ascii_value(line, "1-0:32.7.0", value)) {
|
|
||||||
data.phase_voltage_v[0] = value;
|
|
||||||
v1_ok = true;
|
|
||||||
got_any = true;
|
|
||||||
}
|
|
||||||
if (parse_obis_ascii_value(line, "1-0:52.7.0", value)) {
|
|
||||||
data.phase_voltage_v[1] = value;
|
|
||||||
v2_ok = true;
|
|
||||||
got_any = true;
|
|
||||||
}
|
|
||||||
if (parse_obis_ascii_value(line, "1-0:72.7.0", value)) {
|
|
||||||
data.phase_voltage_v[2] = value;
|
|
||||||
v3_ok = true;
|
|
||||||
got_any = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
line_len = 0;
|
line_len = 0;
|
||||||
continue;
|
continue;
|
||||||
@@ -324,7 +149,7 @@ static bool meter_read_ascii(MeterData &data) {
|
|||||||
delay(5);
|
delay(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok || v1_ok || v2_ok || v3_ok;
|
data.valid = energy_ok || total_p_ok || p1_ok || p2_ok || p3_ok;
|
||||||
return data.valid;
|
return data.valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,13 +159,7 @@ bool meter_read(MeterData &data) {
|
|||||||
data.phase_power_w[0] = NAN;
|
data.phase_power_w[0] = NAN;
|
||||||
data.phase_power_w[1] = NAN;
|
data.phase_power_w[1] = NAN;
|
||||||
data.phase_power_w[2] = NAN;
|
data.phase_power_w[2] = NAN;
|
||||||
data.phase_voltage_v[0] = NAN;
|
|
||||||
data.phase_voltage_v[1] = NAN;
|
|
||||||
data.phase_voltage_v[2] = NAN;
|
|
||||||
data.valid = false;
|
data.valid = false;
|
||||||
|
|
||||||
if (meter_read_ascii(data)) {
|
return meter_read_ascii(data);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return meter_read_sml(data);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,9 +126,6 @@ bool mqtt_publish_discovery(const char *device_id) {
|
|||||||
bool ok = true;
|
bool ok = true;
|
||||||
ok = ok && publish_discovery_sensor(device_id, "energy", "Energy", "kWh", "energy", state_topic.c_str(), "{{ value_json.e_kwh }}");
|
ok = ok && publish_discovery_sensor(device_id, "energy", "Energy", "kWh", "energy", state_topic.c_str(), "{{ value_json.e_kwh }}");
|
||||||
ok = ok && publish_discovery_sensor(device_id, "power", "Power", "W", "power", state_topic.c_str(), "{{ value_json.p_w }}");
|
ok = ok && publish_discovery_sensor(device_id, "power", "Power", "W", "power", state_topic.c_str(), "{{ value_json.p_w }}");
|
||||||
ok = ok && publish_discovery_sensor(device_id, "v1", "Voltage L1", "V", "voltage", state_topic.c_str(), "{{ value_json.v1_v }}");
|
|
||||||
ok = ok && publish_discovery_sensor(device_id, "v2", "Voltage L2", "V", "voltage", state_topic.c_str(), "{{ value_json.v2_v }}");
|
|
||||||
ok = ok && publish_discovery_sensor(device_id, "v3", "Voltage L3", "V", "voltage", state_topic.c_str(), "{{ value_json.v3_v }}");
|
|
||||||
ok = ok && publish_discovery_sensor(device_id, "p1", "Power L1", "W", "power", state_topic.c_str(), "{{ value_json.p1_w }}");
|
ok = ok && publish_discovery_sensor(device_id, "p1", "Power L1", "W", "power", state_topic.c_str(), "{{ value_json.p1_w }}");
|
||||||
ok = ok && publish_discovery_sensor(device_id, "p2", "Power L2", "W", "power", state_topic.c_str(), "{{ value_json.p2_w }}");
|
ok = ok && publish_discovery_sensor(device_id, "p2", "Power L2", "W", "power", state_topic.c_str(), "{{ value_json.p2_w }}");
|
||||||
ok = ok && publish_discovery_sensor(device_id, "p3", "Power L3", "W", "power", state_topic.c_str(), "{{ value_json.p3_w }}");
|
ok = ok && publish_discovery_sensor(device_id, "p3", "Power L3", "W", "power", state_topic.c_str(), "{{ value_json.p3_w }}");
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
#include "web_server.h"
|
#include "web_server.h"
|
||||||
#include <WebServer.h>
|
#include <WebServer.h>
|
||||||
#include "wifi_manager.h"
|
#include "wifi_manager.h"
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
static WebServer server(80);
|
static WebServer server(80);
|
||||||
static const SenderStatus *g_statuses = nullptr;
|
static const SenderStatus *g_statuses = nullptr;
|
||||||
static uint8_t g_status_count = 0;
|
static uint8_t g_status_count = 0;
|
||||||
static WifiMqttConfig g_config;
|
static WifiMqttConfig g_config;
|
||||||
static bool g_is_ap = false;
|
static bool g_is_ap = false;
|
||||||
|
static const FaultCounters *g_sender_faults = nullptr;
|
||||||
|
static const FaultType *g_sender_last_errors = nullptr;
|
||||||
|
static MeterData g_last_batch[NUM_SENDERS][METER_BATCH_MAX_SAMPLES];
|
||||||
|
static uint8_t g_last_batch_count[NUM_SENDERS] = {};
|
||||||
|
|
||||||
static String html_header(const String &title) {
|
static String html_header(const String &title) {
|
||||||
String h = "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>";
|
String h = "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>";
|
||||||
@@ -19,10 +24,40 @@ static String html_footer() {
|
|||||||
return "</body></html>";
|
return "</body></html>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String format_faults(uint8_t idx) {
|
||||||
|
if (!g_sender_faults || !g_sender_last_errors || idx >= g_status_count) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s;
|
||||||
|
s += " faults m:";
|
||||||
|
s += String(g_sender_faults[idx].meter_read_fail);
|
||||||
|
s += " d:";
|
||||||
|
s += String(g_sender_faults[idx].decode_fail);
|
||||||
|
s += " tx:";
|
||||||
|
s += String(g_sender_faults[idx].lora_tx_fail);
|
||||||
|
s += " last:";
|
||||||
|
s += String(static_cast<uint8_t>(g_sender_last_errors[idx]));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
static String render_sender_block(const SenderStatus &status) {
|
static String render_sender_block(const SenderStatus &status) {
|
||||||
String s;
|
String s;
|
||||||
s += "<div style='margin-bottom:10px;padding:6px;border:1px solid #ccc'>";
|
s += "<div style='margin-bottom:10px;padding:6px;border:1px solid #ccc'>";
|
||||||
s += "<strong>" + String(status.last_data.device_id) + "</strong><br>";
|
uint8_t idx = 0;
|
||||||
|
if (g_statuses) {
|
||||||
|
for (uint8_t i = 0; i < g_status_count; ++i) {
|
||||||
|
if (&g_statuses[i] == &status) {
|
||||||
|
idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s += "<strong>" + String(status.last_data.device_id) + "</strong>";
|
||||||
|
if (status.has_data && status.last_data.link_valid) {
|
||||||
|
s += " R:" + String(status.last_data.link_rssi_dbm) + " S:" + String(status.last_data.link_snr_db, 1);
|
||||||
|
}
|
||||||
|
s += format_faults(idx);
|
||||||
|
s += "<br>";
|
||||||
if (!status.has_data) {
|
if (!status.has_data) {
|
||||||
s += "No data";
|
s += "No data";
|
||||||
} else {
|
} else {
|
||||||
@@ -45,6 +80,7 @@ static void handle_root() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html += "<p><a href='/wifi'>Configure WiFi/MQTT/NTP</a></p>";
|
html += "<p><a href='/wifi'>Configure WiFi/MQTT/NTP</a></p>";
|
||||||
|
html += "<p><a href='/manual'>Manual</a></p>";
|
||||||
html += html_footer();
|
html += html_footer();
|
||||||
server.send(200, "text/html", html);
|
server.send(200, "text/html", html);
|
||||||
}
|
}
|
||||||
@@ -100,6 +136,31 @@ static void handle_sender() {
|
|||||||
if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) {
|
if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) {
|
||||||
String html = html_header("Sender " + device_id);
|
String html = html_header("Sender " + device_id);
|
||||||
html += render_sender_block(g_statuses[i]);
|
html += render_sender_block(g_statuses[i]);
|
||||||
|
if (g_last_batch_count[i] > 0) {
|
||||||
|
html += "<h3>Last batch (" + String(g_last_batch_count[i]) + " samples)</h3>";
|
||||||
|
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 += "<th>bat_v</th><th>bat_pct</th><th>rssi</th><th>snr</th><th>err_tx</th><th>err_last</th></tr>";
|
||||||
|
for (uint8_t r = 0; r < g_last_batch_count[i]; ++r) {
|
||||||
|
const MeterData &d = g_last_batch[i][r];
|
||||||
|
html += "<tr>";
|
||||||
|
html += "<td>" + String(r) + "</td>";
|
||||||
|
html += "<td>" + String(d.ts_utc) + "</td>";
|
||||||
|
html += "<td>" + String(d.energy_total_kwh, 3) + "</td>";
|
||||||
|
html += "<td>" + String(d.total_power_w, 1) + "</td>";
|
||||||
|
html += "<td>" + String(d.phase_power_w[0], 1) + "</td>";
|
||||||
|
html += "<td>" + String(d.phase_power_w[1], 1) + "</td>";
|
||||||
|
html += "<td>" + String(d.phase_power_w[2], 1) + "</td>";
|
||||||
|
html += "<td>" + String(d.battery_voltage_v, 2) + "</td>";
|
||||||
|
html += "<td>" + String(d.battery_percent) + "</td>";
|
||||||
|
html += "<td>" + String(d.link_rssi_dbm) + "</td>";
|
||||||
|
html += "<td>" + String(d.link_snr_db, 1) + "</td>";
|
||||||
|
html += "<td>" + String(d.err_lora_tx) + "</td>";
|
||||||
|
html += "<td>" + String(static_cast<uint8_t>(d.last_error)) + "</td>";
|
||||||
|
html += "</tr>";
|
||||||
|
}
|
||||||
|
html += "</table>";
|
||||||
|
}
|
||||||
html += html_footer();
|
html += html_footer();
|
||||||
server.send(200, "text/html", html);
|
server.send(200, "text/html", html);
|
||||||
return;
|
return;
|
||||||
@@ -108,16 +169,50 @@ static void handle_sender() {
|
|||||||
server.send(404, "text/plain", "Not found");
|
server.send(404, "text/plain", "Not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void handle_manual() {
|
||||||
|
String html = html_header("DD3 Manual");
|
||||||
|
html += "<ul>";
|
||||||
|
html += "<li>Energy: total kWh since meter start.</li>";
|
||||||
|
html += "<li>Power: total active power in W.</li>";
|
||||||
|
html += "<li>P1/P2/P3: phase power in W.</li>";
|
||||||
|
html += "<li>bat_v: battery voltage (V), bat_pct: estimated percent.</li>";
|
||||||
|
html += "<li>RSSI/SNR: LoRa link quality from last packet.</li>";
|
||||||
|
html += "<li>err_tx: LoRa TX error count; err_last: last error code.</li>";
|
||||||
|
html += "<li>faults m/d/tx: meter read/decode/tx counters.</li>";
|
||||||
|
html += "</ul>";
|
||||||
|
html += html_footer();
|
||||||
|
server.send(200, "text/html", html);
|
||||||
|
}
|
||||||
|
|
||||||
void web_server_set_config(const WifiMqttConfig &config) {
|
void web_server_set_config(const WifiMqttConfig &config) {
|
||||||
g_config = config;
|
g_config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors) {
|
||||||
|
g_sender_faults = faults;
|
||||||
|
g_sender_last_errors = last_errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
void web_server_set_last_batch(uint8_t sender_index, const MeterData *samples, size_t count) {
|
||||||
|
if (!samples || sender_index >= NUM_SENDERS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (count > METER_BATCH_MAX_SAMPLES) {
|
||||||
|
count = METER_BATCH_MAX_SAMPLES;
|
||||||
|
}
|
||||||
|
g_last_batch_count[sender_index] = static_cast<uint8_t>(count);
|
||||||
|
for (size_t i = 0; i < count; ++i) {
|
||||||
|
g_last_batch[sender_index][i] = samples[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void web_server_begin_ap(const SenderStatus *statuses, uint8_t count) {
|
void web_server_begin_ap(const SenderStatus *statuses, uint8_t count) {
|
||||||
g_statuses = statuses;
|
g_statuses = statuses;
|
||||||
g_status_count = count;
|
g_status_count = count;
|
||||||
g_is_ap = true;
|
g_is_ap = true;
|
||||||
|
|
||||||
server.on("/", handle_root);
|
server.on("/", handle_root);
|
||||||
|
server.on("/manual", handle_manual);
|
||||||
server.on("/wifi", HTTP_GET, handle_wifi_get);
|
server.on("/wifi", HTTP_GET, handle_wifi_get);
|
||||||
server.on("/wifi", HTTP_POST, handle_wifi_post);
|
server.on("/wifi", HTTP_POST, handle_wifi_post);
|
||||||
server.on("/sender/", handle_sender);
|
server.on("/sender/", handle_sender);
|
||||||
@@ -137,6 +232,7 @@ void web_server_begin_sta(const SenderStatus *statuses, uint8_t count) {
|
|||||||
g_is_ap = false;
|
g_is_ap = false;
|
||||||
|
|
||||||
server.on("/", handle_root);
|
server.on("/", handle_root);
|
||||||
|
server.on("/manual", handle_manual);
|
||||||
server.on("/sender/", handle_sender);
|
server.on("/sender/", handle_sender);
|
||||||
server.on("/wifi", HTTP_GET, handle_wifi_get);
|
server.on("/wifi", HTTP_GET, handle_wifi_get);
|
||||||
server.on("/wifi", HTTP_POST, handle_wifi_post);
|
server.on("/wifi", HTTP_POST, handle_wifi_post);
|
||||||
|
|||||||
Reference in New Issue
Block a user