Initial commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.pio
|
||||||
|
.vscode/.browse.c_cpp.db*
|
||||||
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
10
.vscode/extensions.json
vendored
Normal file
10
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||||
|
// for the documentation about the extensions.json format
|
||||||
|
"recommendations": [
|
||||||
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"ms-vscode.cpptools-extension-pack"
|
||||||
|
]
|
||||||
|
}
|
||||||
269
README.md
Normal file
269
README.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# DD3 LoRa Bridge (Multi-Sender)
|
||||||
|
|
||||||
|
Unified firmware for LilyGO T3 v1.6.1 (ESP32 + SX1276 + SSD1306) that runs as **Sender** or **Receiver** based on a GPIO jumper. Senders read DD3 smart meter values, compress JSON, and transmit over LoRa. The receiver validates packets, publishes to MQTT, provides a web UI, and shows per-sender status on the OLED.
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
Board: **LilyGO T3 LoRa32 v1.6.1** (ESP32 + SX1276 433 MHz + SSD1306 128x64 + LiPo)
|
||||||
|
|
||||||
|
### Pin Mapping
|
||||||
|
- LoRa (SX1276)
|
||||||
|
- SCK: GPIO5
|
||||||
|
- MISO: GPIO19
|
||||||
|
- MOSI: GPIO27
|
||||||
|
- NSS/CS: GPIO18
|
||||||
|
- RST: GPIO23
|
||||||
|
- DIO0: GPIO26
|
||||||
|
- OLED (SSD1306)
|
||||||
|
- SDA: GPIO21
|
||||||
|
- SCL: GPIO22
|
||||||
|
- RST: **not used** (SSD1306 init uses `-1` reset pin)
|
||||||
|
- I2C address: 0x3C
|
||||||
|
- Battery ADC: GPIO35 (via on-board divider)
|
||||||
|
- **Role select**: GPIO13 (INPUT_PULLDOWN)
|
||||||
|
- LOW = Sender
|
||||||
|
- HIGH = Receiver
|
||||||
|
- **OLED control**: GPIO14 (INPUT_PULLDOWN)
|
||||||
|
- HIGH = force OLED on
|
||||||
|
- LOW = allow auto-off after timeout
|
||||||
|
- Smart meter UART RX: GPIO34 (input-only, always connected)
|
||||||
|
|
||||||
|
### Notes on GPIOs
|
||||||
|
- GPIO34/35/36/39 are input-only and have **no internal pullups/pulldowns**.
|
||||||
|
- Strap pins (GPIO0/2/4/5/12/15) can affect boot; avoid for role or control jumpers.
|
||||||
|
|
||||||
|
## Firmware Roles
|
||||||
|
### Sender (battery-powered)
|
||||||
|
- Reads DD3 smart meter via optical IR (UART 9600 7E1).
|
||||||
|
- Extracts OBIS values:
|
||||||
|
- Energy total: 1-0:1.8.0*255
|
||||||
|
- Total power: 1-0:16.7.0*255
|
||||||
|
- Phase power: 36.7 / 56.7 / 76.7
|
||||||
|
- Phase voltage: 32.7 / 52.7 / 72.7
|
||||||
|
- Reads battery voltage and estimates SoC.
|
||||||
|
- Builds JSON payload, compresses, wraps in LoRa packet, transmits.
|
||||||
|
- Deep sleeps between cycles.
|
||||||
|
- Listens for LoRa time sync packets to set UTC clock.
|
||||||
|
- OLED shows status + meter data pages.
|
||||||
|
|
||||||
|
**Sender flow (pseudo-code)**:
|
||||||
|
```cpp
|
||||||
|
void sender_cycle() {
|
||||||
|
meter_read(data); // SML/OBIS -> MeterData
|
||||||
|
read_battery(data); // VBAT + SoC
|
||||||
|
data.ts_utc = time_get_utc_or_uptime();
|
||||||
|
|
||||||
|
json = meterDataToJson(data);
|
||||||
|
compressed = compressBuffer(json);
|
||||||
|
lora_send(packet(MeterData, compressed));
|
||||||
|
|
||||||
|
display_set_last_meter(data);
|
||||||
|
display_set_last_read(ok);
|
||||||
|
display_set_last_tx(ok);
|
||||||
|
display_tick();
|
||||||
|
|
||||||
|
lora_receive_time_sync(); // optional
|
||||||
|
deep_sleep(SENDER_WAKE_INTERVAL_SEC);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key sender functions**:
|
||||||
|
```cpp
|
||||||
|
bool meter_read(MeterData &data); // parse SML frame, set OBIS fields
|
||||||
|
void read_battery(MeterData &data); // ADC -> volts + percent
|
||||||
|
bool meterDataToJson(const MeterData&, String&);
|
||||||
|
bool compressBuffer(const uint8_t*, size_t, uint8_t*, size_t, size_t&);
|
||||||
|
bool lora_send(const LoraPacket &pkt); // add header + CRC16 and transmit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Receiver (USB-powered)
|
||||||
|
- WiFi STA connect using stored config; if not available/fails, starts AP.
|
||||||
|
- NTP sync (UTC) and local display in Europe/Berlin.
|
||||||
|
- Receives LoRa packets, verifies CRC16, decompresses, parses JSON.
|
||||||
|
- Publishes meter JSON to MQTT.
|
||||||
|
- Web UI:
|
||||||
|
- AP mode: status + WiFi/MQTT config.
|
||||||
|
- STA mode: status + per-sender pages.
|
||||||
|
- OLED cycles through receiver status and per-sender pages.
|
||||||
|
|
||||||
|
**Receiver loop (pseudo-code)**:
|
||||||
|
```cpp
|
||||||
|
void receiver_loop() {
|
||||||
|
if (lora_receive(pkt) && pkt.type == MeterData) {
|
||||||
|
json = decompressBuffer(pkt.payload);
|
||||||
|
if (jsonToMeterData(json, data)) {
|
||||||
|
update_sender_status(data);
|
||||||
|
mqtt_publish_state(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time_to_send_timesync()) {
|
||||||
|
time_send_timesync(self_short_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
mqtt_loop();
|
||||||
|
web_server_loop();
|
||||||
|
display_set_receiver_status(...);
|
||||||
|
display_tick();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key receiver functions**:
|
||||||
|
```cpp
|
||||||
|
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms);
|
||||||
|
bool jsonToMeterData(const String &json, MeterData &data);
|
||||||
|
bool mqtt_publish_state(const MeterData &data);
|
||||||
|
void web_server_loop(); // AP or STA UI
|
||||||
|
void time_send_timesync(uint16_t self_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Mode (compile-time)
|
||||||
|
Enabled by `-DENABLE_TEST_MODE` (see `platformio.ini` test environment).
|
||||||
|
|
||||||
|
- Sender: sends 4-digit test code every ~30s in JSON.
|
||||||
|
- Receiver: shows last test code per sender and publishes to `/test` topic.
|
||||||
|
- Normal behavior is excluded from test builds.
|
||||||
|
|
||||||
|
**Test sender (pseudo-code)**:
|
||||||
|
```cpp
|
||||||
|
void test_sender_loop() {
|
||||||
|
code = random_4_digits();
|
||||||
|
json = {id, role:"sender", test_code: code, ts};
|
||||||
|
lora_send(packet(TestCode, compress(json)));
|
||||||
|
display_set_test_code(code);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test receiver (pseudo-code)**:
|
||||||
|
```cpp
|
||||||
|
void test_receiver_loop() {
|
||||||
|
if (pkt.type == TestCode) {
|
||||||
|
json = decompress(pkt.payload);
|
||||||
|
update_sender_test_code(json);
|
||||||
|
mqtt_publish_test(id, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## LoRa Protocol
|
||||||
|
Packet layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
[0] protocol_version (1)
|
||||||
|
[1] role (0=sender, 1=receiver)
|
||||||
|
[2..3] device_id_short (uint16)
|
||||||
|
[4] payload_type (0=meter, 1=test, 2=time_sync)
|
||||||
|
[5..N-3] compressed payload
|
||||||
|
[N-2..N-1] CRC16 (bytes 0..N-3)
|
||||||
|
```
|
||||||
|
|
||||||
|
LoRa radio settings:
|
||||||
|
- 433 MHz, SF12, BW 125 kHz, CR 4/5, CRC on, Sync Word 0x34
|
||||||
|
|
||||||
|
## Data Format
|
||||||
|
JSON payload (sender + MQTT):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "dd3-01",
|
||||||
|
"ts": 1737200000,
|
||||||
|
"energy_kwh": 1234.567,
|
||||||
|
"p_total_w": 950.0,
|
||||||
|
"p1_w": 500.0,
|
||||||
|
"p2_w": 450.0,
|
||||||
|
"p3_w": 0.0,
|
||||||
|
"v1_v": 230.1,
|
||||||
|
"v2_v": 229.8,
|
||||||
|
"v3_v": 231.0,
|
||||||
|
"bat_v": 3.92,
|
||||||
|
"bat_pct": 78
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Device IDs
|
||||||
|
- Derived from WiFi STA MAC.
|
||||||
|
- `short_id = (MAC[4] << 8) | MAC[5]`
|
||||||
|
- `device_id = dd3-%04X`
|
||||||
|
|
||||||
|
Receiver expects known senders in `include/config.h` via:
|
||||||
|
```cpp
|
||||||
|
constexpr uint8_t NUM_SENDERS = 1;
|
||||||
|
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C };
|
||||||
|
```
|
||||||
|
|
||||||
|
## OLED Behavior
|
||||||
|
- After reset, OLED stays **ON for 10 minutes** regardless of switch.
|
||||||
|
- After that:
|
||||||
|
- GPIO14 HIGH: OLED forced ON.
|
||||||
|
- GPIO14 LOW: start 10-minute auto-off timer.
|
||||||
|
- Pages rotate every 10s.
|
||||||
|
|
||||||
|
## Power & Battery
|
||||||
|
- Sender disables WiFi/BLE, reads VBAT via ADC, uses linear SoC map:
|
||||||
|
- 3.0 V = 0%
|
||||||
|
- 4.2 V = 100%
|
||||||
|
- Uses deep sleep between cycles (`SENDER_WAKE_INTERVAL_SEC`).
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
- AP SSID: `DD3-Bridge-<short_id>`
|
||||||
|
- AP password: `changeme123`
|
||||||
|
- Endpoints:
|
||||||
|
- `/`: status overview
|
||||||
|
- `/wifi`: WiFi/MQTT/NTP config (AP and STA)
|
||||||
|
- `/sender/<device_id>`: per-sender details
|
||||||
|
|
||||||
|
## MQTT
|
||||||
|
- Topic: `smartmeter/<deviceId>/state`
|
||||||
|
- QoS 0
|
||||||
|
- Test mode: `smartmeter/<deviceId>/test`
|
||||||
|
- Client ID: `dd3-bridge-<device_id>` (stable, derived from MAC)
|
||||||
|
|
||||||
|
## NTP
|
||||||
|
- NTP servers are configurable in the web UI (`/wifi`).
|
||||||
|
- Defaults: `pool.ntp.org` and `time.nist.gov`.
|
||||||
|
|
||||||
|
## Build Environments
|
||||||
|
- `lilygo-t3-v1-6-1`: production build
|
||||||
|
- `lilygo-t3-v1-6-1-test`: test build with `ENABLE_TEST_MODE`
|
||||||
|
|
||||||
|
## Limits & Known Constraints
|
||||||
|
- **Compression**: uses lightweight RLE (good for JSON but not optimal).
|
||||||
|
- **OBIS parsing**: heuristic SML parser; may need tuning for some DD3 meters.
|
||||||
|
- **Payload size**: JSON < 256 bytes (enforced by ArduinoJson static doc).
|
||||||
|
- **Battery ADC**: uses simple linear calibration constant in `power_manager.cpp`.
|
||||||
|
- **OLED**: no hardware reset line is used (matches working reference).
|
||||||
|
|
||||||
|
## Files & Modules
|
||||||
|
- `include/config.h`, `src/config.cpp`: pins, radio settings, sender IDs
|
||||||
|
- `include/data_model.h`, `src/data_model.cpp`: MeterData + ID init
|
||||||
|
- `include/json_codec.h`, `src/json_codec.cpp`: JSON encode/decode
|
||||||
|
- `include/compressor.h`, `src/compressor.cpp`: RLE compression
|
||||||
|
- `include/lora_transport.h`, `src/lora_transport.cpp`: LoRa packet + CRC
|
||||||
|
- `include/meter_driver.h`, `src/meter_driver.cpp`: SML/OBIS parse
|
||||||
|
- `include/power_manager.h`, `src/power_manager.cpp`: ADC + sleep
|
||||||
|
- `include/time_manager.h`, `src/time_manager.cpp`: NTP + time sync
|
||||||
|
- `include/wifi_manager.h`, `src/wifi_manager.cpp`: NVS config + WiFi
|
||||||
|
- `include/mqtt_client.h`, `src/mqtt_client.cpp`: MQTT publish
|
||||||
|
- `include/web_server.h`, `src/web_server.cpp`: AP/STA web pages
|
||||||
|
- `include/display_ui.h`, `src/display_ui.cpp`: OLED pages + control
|
||||||
|
- `include/test_mode.h`, `src/test_mode.cpp`: test sender/receiver
|
||||||
|
- `src/main.cpp`: role detection and main loop
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
1. Set role jumper on GPIO13:
|
||||||
|
- LOW: sender
|
||||||
|
- HIGH: receiver
|
||||||
|
2. OLED control on GPIO14:
|
||||||
|
- HIGH: always on
|
||||||
|
- LOW: auto-off after 10 minutes
|
||||||
|
3. Build and upload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pio run -e lilygo-t3-v1-6-1 -t upload --upload-port COMx
|
||||||
|
```
|
||||||
|
|
||||||
|
Test mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pio run -e lilygo-t3-v1-6-1-test -t upload --upload-port COMx
|
||||||
|
```
|
||||||
|
|
||||||
37
include/README
Normal file
37
include/README
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
This directory is intended for project header files.
|
||||||
|
|
||||||
|
A header file is a file containing C declarations and macro definitions
|
||||||
|
to be shared between several project source files. You request the use of a
|
||||||
|
header file in your project source file (C, C++, etc) located in `src` folder
|
||||||
|
by including it, with the C preprocessing directive `#include'.
|
||||||
|
|
||||||
|
```src/main.c
|
||||||
|
|
||||||
|
#include "header.h"
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Including a header file produces the same results as copying the header file
|
||||||
|
into each source file that needs it. Such copying would be time-consuming
|
||||||
|
and error-prone. With a header file, the related declarations appear
|
||||||
|
in only one place. If they need to be changed, they can be changed in one
|
||||||
|
place, and programs that include the header file will automatically use the
|
||||||
|
new version when next recompiled. The header file eliminates the labor of
|
||||||
|
finding and changing all the copies as well as the risk that a failure to
|
||||||
|
find one copy will result in inconsistencies within a program.
|
||||||
|
|
||||||
|
In C, the convention is to give header files names that end with `.h'.
|
||||||
|
|
||||||
|
Read more about using header files in official GCC documentation:
|
||||||
|
|
||||||
|
* Include Syntax
|
||||||
|
* Include Operation
|
||||||
|
* Once-Only Headers
|
||||||
|
* Computed Includes
|
||||||
|
|
||||||
|
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
||||||
6
include/compressor.h
Normal file
6
include/compressor.h
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
bool compressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len);
|
||||||
|
bool decompressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len);
|
||||||
58
include/config.h
Normal file
58
include/config.h
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
enum class DeviceRole : uint8_t {
|
||||||
|
Sender = 0,
|
||||||
|
Receiver = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class PayloadType : uint8_t {
|
||||||
|
MeterData = 0,
|
||||||
|
TestCode = 1,
|
||||||
|
TimeSync = 2
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr uint8_t PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
// Pin definitions
|
||||||
|
constexpr uint8_t PIN_LORA_SCK = 5;
|
||||||
|
constexpr uint8_t PIN_LORA_MISO = 19;
|
||||||
|
constexpr uint8_t PIN_LORA_MOSI = 27;
|
||||||
|
constexpr uint8_t PIN_LORA_NSS = 18;
|
||||||
|
constexpr uint8_t PIN_LORA_RST = 23;
|
||||||
|
constexpr uint8_t PIN_LORA_DIO0 = 26;
|
||||||
|
|
||||||
|
constexpr uint8_t PIN_OLED_SDA = 21;
|
||||||
|
constexpr uint8_t PIN_OLED_SCL = 22;
|
||||||
|
constexpr uint8_t PIN_OLED_RST = 16;
|
||||||
|
constexpr uint8_t OLED_I2C_ADDR = 0x3C;
|
||||||
|
constexpr uint8_t OLED_WIDTH = 128;
|
||||||
|
constexpr uint8_t OLED_HEIGHT = 64;
|
||||||
|
|
||||||
|
constexpr uint8_t PIN_BAT_ADC = 35;
|
||||||
|
|
||||||
|
constexpr uint8_t PIN_ROLE = 13;
|
||||||
|
constexpr uint8_t PIN_OLED_CTRL = 14;
|
||||||
|
|
||||||
|
constexpr uint8_t PIN_METER_RX = 34;
|
||||||
|
|
||||||
|
// LoRa settings
|
||||||
|
constexpr long LORA_FREQUENCY = 433E6;
|
||||||
|
constexpr uint8_t LORA_SPREADING_FACTOR = 12;
|
||||||
|
constexpr long LORA_BANDWIDTH = 125E3;
|
||||||
|
constexpr uint8_t LORA_CODING_RATE = 5;
|
||||||
|
constexpr uint8_t LORA_SYNC_WORD = 0x34;
|
||||||
|
|
||||||
|
// Timing
|
||||||
|
constexpr uint32_t SENDER_WAKE_INTERVAL_SEC = 30;
|
||||||
|
constexpr uint32_t TIME_SYNC_INTERVAL_SEC = 60;
|
||||||
|
constexpr uint32_t OLED_PAGE_INTERVAL_MS = 10000;
|
||||||
|
constexpr uint32_t OLED_AUTO_OFF_MS = 10UL * 60UL * 1000UL;
|
||||||
|
|
||||||
|
constexpr uint8_t NUM_SENDERS = 1;
|
||||||
|
inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = {
|
||||||
|
0xF19C
|
||||||
|
};
|
||||||
|
|
||||||
|
DeviceRole detect_role();
|
||||||
24
include/data_model.h
Normal file
24
include/data_model.h
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
struct MeterData {
|
||||||
|
uint32_t ts_utc;
|
||||||
|
uint16_t short_id;
|
||||||
|
char device_id[16];
|
||||||
|
float energy_total_kwh;
|
||||||
|
float phase_power_w[3];
|
||||||
|
float total_power_w;
|
||||||
|
float phase_voltage_v[3];
|
||||||
|
float battery_voltage_v;
|
||||||
|
uint8_t battery_percent;
|
||||||
|
bool valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SenderStatus {
|
||||||
|
MeterData last_data;
|
||||||
|
uint32_t last_update_ts_utc;
|
||||||
|
bool has_data;
|
||||||
|
};
|
||||||
|
|
||||||
|
void init_device_ids(uint16_t &short_id, char *device_id, size_t device_id_len);
|
||||||
19
include/display_ui.h
Normal file
19
include/display_ui.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "config.h"
|
||||||
|
#include "data_model.h"
|
||||||
|
|
||||||
|
void display_init();
|
||||||
|
void display_set_role(DeviceRole role);
|
||||||
|
void display_set_self_ids(uint16_t short_id, const char *device_id);
|
||||||
|
void display_set_sender_statuses(const SenderStatus *statuses, uint8_t count);
|
||||||
|
void display_set_last_meter(const MeterData &data);
|
||||||
|
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_receiver_status(bool ap_mode, const char *ssid, bool mqtt_ok);
|
||||||
|
void display_tick();
|
||||||
|
#ifdef ENABLE_TEST_MODE
|
||||||
|
void display_set_test_code(const char *code);
|
||||||
|
void display_set_test_code_for_sender(uint8_t index, const char *code);
|
||||||
|
#endif
|
||||||
7
include/json_codec.h
Normal file
7
include/json_codec.h
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "data_model.h"
|
||||||
|
|
||||||
|
bool meterDataToJson(const MeterData &data, String &out_json);
|
||||||
|
bool jsonToMeterData(const String &json, MeterData &data);
|
||||||
19
include/lora_transport.h
Normal file
19
include/lora_transport.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
constexpr size_t LORA_MAX_PAYLOAD = 200;
|
||||||
|
|
||||||
|
struct LoraPacket {
|
||||||
|
uint8_t protocol_version;
|
||||||
|
DeviceRole role;
|
||||||
|
uint16_t device_id_short;
|
||||||
|
PayloadType payload_type;
|
||||||
|
uint8_t payload[LORA_MAX_PAYLOAD];
|
||||||
|
size_t payload_len;
|
||||||
|
};
|
||||||
|
|
||||||
|
void lora_init();
|
||||||
|
bool lora_send(const LoraPacket &pkt);
|
||||||
|
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms);
|
||||||
7
include/meter_driver.h
Normal file
7
include/meter_driver.h
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "data_model.h"
|
||||||
|
|
||||||
|
void meter_init();
|
||||||
|
bool meter_read(MeterData &data);
|
||||||
13
include/mqtt_client.h
Normal file
13
include/mqtt_client.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "data_model.h"
|
||||||
|
#include "wifi_manager.h"
|
||||||
|
|
||||||
|
void mqtt_init(const WifiMqttConfig &config, const char *device_id);
|
||||||
|
void mqtt_loop();
|
||||||
|
bool mqtt_is_connected();
|
||||||
|
bool mqtt_publish_state(const MeterData &data);
|
||||||
|
#ifdef ENABLE_TEST_MODE
|
||||||
|
bool mqtt_publish_test(const char *device_id, const String &payload);
|
||||||
|
#endif
|
||||||
9
include/power_manager.h
Normal file
9
include/power_manager.h
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "data_model.h"
|
||||||
|
|
||||||
|
void power_sender_init();
|
||||||
|
void power_receiver_init();
|
||||||
|
void read_battery(MeterData &data);
|
||||||
|
void go_to_deep_sleep(uint32_t seconds);
|
||||||
9
include/test_mode.h
Normal file
9
include/test_mode.h
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "data_model.h"
|
||||||
|
|
||||||
|
#ifdef ENABLE_TEST_MODE
|
||||||
|
void test_sender_loop(uint16_t short_id, const char *device_id);
|
||||||
|
void test_receiver_loop(SenderStatus *statuses, uint8_t count);
|
||||||
|
#endif
|
||||||
12
include/time_manager.h
Normal file
12
include/time_manager.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "lora_transport.h"
|
||||||
|
|
||||||
|
void time_receiver_init(const char *ntp_server_1, const char *ntp_server_2);
|
||||||
|
uint32_t time_get_utc();
|
||||||
|
bool time_is_synced();
|
||||||
|
void time_set_utc(uint32_t epoch);
|
||||||
|
void time_send_timesync(uint16_t device_id_short);
|
||||||
|
bool time_handle_timesync_payload(const uint8_t *payload, size_t len);
|
||||||
|
void time_get_local_hhmm(char *out, size_t out_len);
|
||||||
10
include/web_server.h
Normal file
10
include/web_server.h
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "data_model.h"
|
||||||
|
#include "wifi_manager.h"
|
||||||
|
|
||||||
|
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_set_config(const WifiMqttConfig &config);
|
||||||
|
void web_server_loop();
|
||||||
25
include/wifi_manager.h
Normal file
25
include/wifi_manager.h
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <Preferences.h>
|
||||||
|
|
||||||
|
struct WifiMqttConfig {
|
||||||
|
String ssid;
|
||||||
|
String password;
|
||||||
|
String mqtt_host;
|
||||||
|
uint16_t mqtt_port;
|
||||||
|
String mqtt_user;
|
||||||
|
String mqtt_pass;
|
||||||
|
String ntp_server_1;
|
||||||
|
String ntp_server_2;
|
||||||
|
bool valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
void wifi_manager_init();
|
||||||
|
bool wifi_load_config(WifiMqttConfig &config);
|
||||||
|
bool wifi_save_config(const WifiMqttConfig &config);
|
||||||
|
|
||||||
|
bool wifi_connect_sta(const WifiMqttConfig &config, uint32_t timeout_ms = 10000);
|
||||||
|
void wifi_start_ap(const char *ap_ssid, const char *ap_pass);
|
||||||
|
bool wifi_is_connected();
|
||||||
|
String wifi_get_ssid();
|
||||||
46
lib/README
Normal file
46
lib/README
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
This directory is intended for project specific (private) libraries.
|
||||||
|
PlatformIO will compile them to static libraries and link into the executable file.
|
||||||
|
|
||||||
|
The source code of each library should be placed in a separate directory
|
||||||
|
("lib/your_library_name/[Code]").
|
||||||
|
|
||||||
|
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
||||||
|
|
||||||
|
|--lib
|
||||||
|
| |
|
||||||
|
| |--Bar
|
||||||
|
| | |--docs
|
||||||
|
| | |--examples
|
||||||
|
| | |--src
|
||||||
|
| | |- Bar.c
|
||||||
|
| | |- Bar.h
|
||||||
|
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
||||||
|
| |
|
||||||
|
| |--Foo
|
||||||
|
| | |- Foo.c
|
||||||
|
| | |- Foo.h
|
||||||
|
| |
|
||||||
|
| |- README --> THIS FILE
|
||||||
|
|
|
||||||
|
|- platformio.ini
|
||||||
|
|--src
|
||||||
|
|- main.c
|
||||||
|
|
||||||
|
Example contents of `src/main.c` using Foo and Bar:
|
||||||
|
```
|
||||||
|
#include <Foo.h>
|
||||||
|
#include <Bar.h>
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The PlatformIO Library Dependency Finder will find automatically dependent
|
||||||
|
libraries by scanning project source files.
|
||||||
|
|
||||||
|
More information about PlatformIO Library Dependency Finder
|
||||||
|
- https://docs.platformio.org/page/librarymanager/ldf.html
|
||||||
33
platformio.ini
Normal file
33
platformio.ini
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
; PlatformIO Project Configuration File
|
||||||
|
;
|
||||||
|
; Build options: build flags, source filter
|
||||||
|
; Upload options: custom upload port, speed and extra flags
|
||||||
|
; Library options: dependencies, extra library storages
|
||||||
|
; Advanced options: extra scripting
|
||||||
|
;
|
||||||
|
; Please visit documentation for the other options and examples
|
||||||
|
; https://docs.platformio.org/page/projectconf.html
|
||||||
|
|
||||||
|
[env:lilygo-t3-v1-6-1]
|
||||||
|
platform = espressif32
|
||||||
|
board = ttgo-lora32-v1
|
||||||
|
framework = arduino
|
||||||
|
lib_deps =
|
||||||
|
sandeepmistry/LoRa@^0.8.0
|
||||||
|
bblanchon/ArduinoJson@^6.21.5
|
||||||
|
adafruit/Adafruit SSD1306@^2.5.9
|
||||||
|
adafruit/Adafruit GFX Library@^1.11.9
|
||||||
|
knolleary/PubSubClient@^2.8
|
||||||
|
|
||||||
|
[env:lilygo-t3-v1-6-1-test]
|
||||||
|
platform = espressif32
|
||||||
|
board = ttgo-lora32-v1
|
||||||
|
framework = arduino
|
||||||
|
lib_deps =
|
||||||
|
sandeepmistry/LoRa@^0.8.0
|
||||||
|
bblanchon/ArduinoJson@^6.21.5
|
||||||
|
adafruit/Adafruit SSD1306@^2.5.9
|
||||||
|
adafruit/Adafruit GFX Library@^1.11.9
|
||||||
|
knolleary/PubSubClient@^2.8
|
||||||
|
build_flags =
|
||||||
|
-DENABLE_TEST_MODE
|
||||||
71
src/compressor.cpp
Normal file
71
src/compressor.cpp
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#include "compressor.h"
|
||||||
|
|
||||||
|
static constexpr uint8_t RLE_MARKER = 0xFF;
|
||||||
|
|
||||||
|
bool compressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len) {
|
||||||
|
out_len = 0;
|
||||||
|
if (!in || !out) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t i = 0;
|
||||||
|
while (i < in_len) {
|
||||||
|
uint8_t value = in[i];
|
||||||
|
size_t run = 1;
|
||||||
|
while (i + run < in_len && in[i + run] == value && run < 255) {
|
||||||
|
run++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == RLE_MARKER || run >= 4) {
|
||||||
|
if (out_len + 3 > out_max) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out[out_len++] = RLE_MARKER;
|
||||||
|
out[out_len++] = static_cast<uint8_t>(run);
|
||||||
|
out[out_len++] = value;
|
||||||
|
} else {
|
||||||
|
if (out_len + run > out_max) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (size_t j = 0; j < run; ++j) {
|
||||||
|
out[out_len++] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i += run;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool decompressBuffer(const uint8_t *in, size_t in_len, uint8_t *out, size_t out_max, size_t &out_len) {
|
||||||
|
out_len = 0;
|
||||||
|
if (!in || !out) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t i = 0;
|
||||||
|
while (i < in_len) {
|
||||||
|
uint8_t value = in[i++];
|
||||||
|
if (value == RLE_MARKER) {
|
||||||
|
if (i + 1 >= in_len) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint8_t run = in[i++];
|
||||||
|
uint8_t data = in[i++];
|
||||||
|
if (out_len + run > out_max) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (uint8_t j = 0; j < run; ++j) {
|
||||||
|
out[out_len++] = data;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (out_len + 1 > out_max) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out[out_len++] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
6
src/config.cpp
Normal file
6
src/config.cpp
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
DeviceRole detect_role() {
|
||||||
|
pinMode(PIN_ROLE, INPUT_PULLDOWN);
|
||||||
|
return digitalRead(PIN_ROLE) == HIGH ? DeviceRole::Receiver : DeviceRole::Sender;
|
||||||
|
}
|
||||||
9
src/data_model.cpp
Normal file
9
src/data_model.cpp
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#include "data_model.h"
|
||||||
|
#include <WiFi.h>
|
||||||
|
|
||||||
|
void init_device_ids(uint16_t &short_id, char *device_id, size_t device_id_len) {
|
||||||
|
uint8_t mac[6] = {0};
|
||||||
|
WiFi.macAddress(mac);
|
||||||
|
short_id = (static_cast<uint16_t>(mac[4]) << 8) | mac[5];
|
||||||
|
snprintf(device_id, device_id_len, "dd3-%04X", short_id);
|
||||||
|
}
|
||||||
322
src/display_ui.cpp
Normal file
322
src/display_ui.cpp
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
#include "display_ui.h"
|
||||||
|
#include "config.h"
|
||||||
|
#include "time_manager.h"
|
||||||
|
#include <Wire.h>
|
||||||
|
#include <Adafruit_GFX.h>
|
||||||
|
#include <Adafruit_SSD1306.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
static Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, -1);
|
||||||
|
|
||||||
|
static DeviceRole g_role = DeviceRole::Sender;
|
||||||
|
static uint16_t g_short_id = 0;
|
||||||
|
static char g_device_id[16] = "";
|
||||||
|
|
||||||
|
static MeterData g_last_meter = {};
|
||||||
|
static bool g_last_read_ok = false;
|
||||||
|
static bool g_last_tx_ok = false;
|
||||||
|
static uint32_t g_last_read_ts = 0;
|
||||||
|
static uint32_t g_last_tx_ts = 0;
|
||||||
|
static uint32_t g_last_read_ms = 0;
|
||||||
|
static uint32_t g_last_tx_ms = 0;
|
||||||
|
|
||||||
|
static const SenderStatus *g_statuses = nullptr;
|
||||||
|
static uint8_t g_status_count = 0;
|
||||||
|
|
||||||
|
static bool g_ap_mode = false;
|
||||||
|
static String g_wifi_ssid;
|
||||||
|
static bool g_mqtt_ok = false;
|
||||||
|
|
||||||
|
static bool g_oled_on = true;
|
||||||
|
static bool g_prev_ctrl_high = false;
|
||||||
|
static uint32_t g_oled_off_start = 0;
|
||||||
|
static uint32_t g_last_page_ms = 0;
|
||||||
|
static uint8_t g_page = 0;
|
||||||
|
static uint32_t g_boot_ms = 0;
|
||||||
|
static bool g_display_ready = false;
|
||||||
|
static uint32_t g_last_init_attempt_ms = 0;
|
||||||
|
|
||||||
|
#ifdef ENABLE_TEST_MODE
|
||||||
|
static char g_test_code[8] = "";
|
||||||
|
static char g_test_codes[NUM_SENDERS][8] = {};
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static void oled_set_power(bool on) {
|
||||||
|
if (on) {
|
||||||
|
display.ssd1306_command(SSD1306_DISPLAYON);
|
||||||
|
} else {
|
||||||
|
display.ssd1306_command(SSD1306_DISPLAYOFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void display_init() {
|
||||||
|
pinMode(PIN_OLED_CTRL, INPUT_PULLDOWN);
|
||||||
|
Wire.begin(PIN_OLED_SDA, PIN_OLED_SCL);
|
||||||
|
Wire.setClock(100000);
|
||||||
|
g_display_ready = display.begin(SSD1306_SWITCHCAPVCC, OLED_I2C_ADDR);
|
||||||
|
if (g_display_ready) {
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setTextSize(1);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
display.ssd1306_command(SSD1306_DISPLAYON);
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
g_last_init_attempt_ms = millis();
|
||||||
|
g_boot_ms = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void display_set_role(DeviceRole role) {
|
||||||
|
g_role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
void display_set_self_ids(uint16_t short_id, const char *device_id) {
|
||||||
|
g_short_id = short_id;
|
||||||
|
strncpy(g_device_id, device_id, sizeof(g_device_id));
|
||||||
|
g_device_id[sizeof(g_device_id) - 1] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
void display_set_sender_statuses(const SenderStatus *statuses, uint8_t count) {
|
||||||
|
g_statuses = statuses;
|
||||||
|
g_status_count = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
void display_set_last_meter(const MeterData &data) {
|
||||||
|
g_last_meter = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
void display_set_last_read(bool ok, uint32_t ts_utc) {
|
||||||
|
g_last_read_ok = ok;
|
||||||
|
g_last_read_ts = ts_utc;
|
||||||
|
g_last_read_ms = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void display_set_last_tx(bool ok, uint32_t ts_utc) {
|
||||||
|
g_last_tx_ok = ok;
|
||||||
|
g_last_tx_ts = ts_utc;
|
||||||
|
g_last_tx_ms = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void display_set_receiver_status(bool ap_mode, const char *ssid, bool mqtt_ok) {
|
||||||
|
g_ap_mode = ap_mode;
|
||||||
|
g_wifi_ssid = ssid ? ssid : "";
|
||||||
|
g_mqtt_ok = mqtt_ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef ENABLE_TEST_MODE
|
||||||
|
void display_set_test_code(const char *code) {
|
||||||
|
strncpy(g_test_code, code, sizeof(g_test_code));
|
||||||
|
g_test_code[sizeof(g_test_code) - 1] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
void display_set_test_code_for_sender(uint8_t index, const char *code) {
|
||||||
|
if (index >= NUM_SENDERS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
strncpy(g_test_codes[index], code, sizeof(g_test_codes[index]));
|
||||||
|
g_test_codes[index][sizeof(g_test_codes[index]) - 1] = '\0';
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static uint32_t age_seconds(uint32_t ts_utc, uint32_t ts_ms) {
|
||||||
|
if (time_is_synced() && ts_utc > 0) {
|
||||||
|
uint32_t now = time_get_utc();
|
||||||
|
return now > ts_utc ? now - ts_utc : 0;
|
||||||
|
}
|
||||||
|
return (millis() - ts_ms) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void render_sender_status() {
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setCursor(0, 0);
|
||||||
|
display.printf("SENDER %s", g_device_id);
|
||||||
|
|
||||||
|
char time_buf[8];
|
||||||
|
time_get_local_hhmm(time_buf, sizeof(time_buf));
|
||||||
|
display.setCursor(0, 12);
|
||||||
|
display.printf("%s %.2fV %u%%", time_buf, g_last_meter.battery_voltage_v, g_last_meter.battery_percent);
|
||||||
|
|
||||||
|
display.setCursor(0, 24);
|
||||||
|
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.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)));
|
||||||
|
|
||||||
|
#ifdef ENABLE_TEST_MODE
|
||||||
|
if (strlen(g_test_code) > 0) {
|
||||||
|
display.setCursor(0, 48);
|
||||||
|
display.printf("Test %s", g_test_code);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void render_sender_measurement() {
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setCursor(0, 0);
|
||||||
|
display.printf("E %.1f kWh", g_last_meter.energy_total_kwh);
|
||||||
|
display.setCursor(0, 12);
|
||||||
|
display.printf("P %.0fW", g_last_meter.total_power_w);
|
||||||
|
display.setCursor(0, 24);
|
||||||
|
display.printf("L1 %.0fV %.0fW", g_last_meter.phase_voltage_v[0], g_last_meter.phase_power_w[0]);
|
||||||
|
display.setCursor(0, 36);
|
||||||
|
display.printf("L2 %.0fV %.0fW", g_last_meter.phase_voltage_v[1], g_last_meter.phase_power_w[1]);
|
||||||
|
display.setCursor(0, 48);
|
||||||
|
display.printf("L3 %.0fV %.0fW", g_last_meter.phase_voltage_v[2], g_last_meter.phase_power_w[2]);
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void render_receiver_status() {
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setCursor(0, 0);
|
||||||
|
display.printf("RECEIVER %s", g_device_id);
|
||||||
|
|
||||||
|
display.setCursor(0, 12);
|
||||||
|
if (g_ap_mode) {
|
||||||
|
display.print("WiFi: AP");
|
||||||
|
} else {
|
||||||
|
display.printf("WiFi: %s", g_wifi_ssid.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
display.setCursor(0, 24);
|
||||||
|
display.printf("MQTT: %s", g_mqtt_ok ? "OK" : "RETRY");
|
||||||
|
|
||||||
|
uint32_t latest = 0;
|
||||||
|
if (g_statuses) {
|
||||||
|
for (uint8_t i = 0; i < g_status_count; ++i) {
|
||||||
|
if (g_statuses[i].has_data && g_statuses[i].last_update_ts_utc > latest) {
|
||||||
|
latest = g_statuses[i].last_update_ts_utc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
display.setCursor(0, 36);
|
||||||
|
if (latest == 0 || !time_is_synced()) {
|
||||||
|
display.print("Last upd: --:--");
|
||||||
|
} else {
|
||||||
|
time_t t = latest;
|
||||||
|
struct tm timeinfo;
|
||||||
|
localtime_r(&t, &timeinfo);
|
||||||
|
display.printf("Last upd: %02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
|
||||||
|
}
|
||||||
|
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void render_receiver_sender(uint8_t index) {
|
||||||
|
display.clearDisplay();
|
||||||
|
if (!g_statuses || index >= g_status_count) {
|
||||||
|
display.setCursor(0, 0);
|
||||||
|
display.print("No sender");
|
||||||
|
display.display();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SenderStatus &status = g_statuses[index];
|
||||||
|
display.setCursor(0, 0);
|
||||||
|
uint8_t bat = status.has_data ? status.last_data.battery_percent : 0;
|
||||||
|
if (status.has_data) {
|
||||||
|
display.printf("%s B%u", status.last_data.device_id, bat);
|
||||||
|
} else {
|
||||||
|
display.printf("%s B--", status.last_data.device_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status.has_data) {
|
||||||
|
display.setCursor(0, 12);
|
||||||
|
display.print("No data");
|
||||||
|
display.display();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef ENABLE_TEST_MODE
|
||||||
|
if (strlen(g_test_codes[index]) > 0) {
|
||||||
|
display.setCursor(0, 12);
|
||||||
|
display.printf("Test %s", g_test_codes[index]);
|
||||||
|
display.display();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
display.setCursor(0, 12);
|
||||||
|
display.printf("E %.1f kWh", status.last_data.energy_total_kwh);
|
||||||
|
display.setCursor(0, 24);
|
||||||
|
display.printf("P %.0fW", status.last_data.total_power_w);
|
||||||
|
display.setCursor(0, 36);
|
||||||
|
display.printf("L1 %.0fV %.0fW", status.last_data.phase_voltage_v[0], status.last_data.phase_power_w[0]);
|
||||||
|
display.setCursor(0, 48);
|
||||||
|
display.printf("L2 %.0fV %.0fW", status.last_data.phase_voltage_v[1], status.last_data.phase_power_w[1]);
|
||||||
|
display.setCursor(0, 56);
|
||||||
|
display.printf("L3 %.0fV %.0fW", status.last_data.phase_voltage_v[2], status.last_data.phase_power_w[2]);
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
|
||||||
|
void display_tick() {
|
||||||
|
if (!g_display_ready) {
|
||||||
|
if (millis() - g_last_init_attempt_ms > 1000) {
|
||||||
|
g_last_init_attempt_ms = millis();
|
||||||
|
g_display_ready = display.begin(SSD1306_SWITCHCAPVCC, OLED_I2C_ADDR);
|
||||||
|
if (g_display_ready) {
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setTextSize(1);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
display.ssd1306_command(SSD1306_DISPLAYON);
|
||||||
|
display.display();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bool ctrl_high = digitalRead(PIN_OLED_CTRL) == HIGH;
|
||||||
|
|
||||||
|
bool in_boot_window = (millis() - g_boot_ms) < OLED_AUTO_OFF_MS;
|
||||||
|
if (in_boot_window) {
|
||||||
|
g_oled_on = true;
|
||||||
|
oled_set_power(true);
|
||||||
|
} else {
|
||||||
|
if (ctrl_high) {
|
||||||
|
g_oled_on = true;
|
||||||
|
g_oled_off_start = 0;
|
||||||
|
} else if (g_prev_ctrl_high && !ctrl_high) {
|
||||||
|
g_oled_off_start = millis();
|
||||||
|
} else if (!g_prev_ctrl_high && !ctrl_high && g_oled_off_start == 0) {
|
||||||
|
g_oled_off_start = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctrl_high && g_oled_off_start > 0 && millis() - g_oled_off_start > OLED_AUTO_OFF_MS) {
|
||||||
|
g_oled_on = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_oled_on) {
|
||||||
|
oled_set_power(true);
|
||||||
|
} else {
|
||||||
|
oled_set_power(false);
|
||||||
|
g_prev_ctrl_high = ctrl_high;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t now = millis();
|
||||||
|
uint8_t page_count = g_role == DeviceRole::Sender ? 2 : (1 + g_status_count);
|
||||||
|
if (page_count == 0) {
|
||||||
|
page_count = 1;
|
||||||
|
}
|
||||||
|
if (now - g_last_page_ms > OLED_PAGE_INTERVAL_MS) {
|
||||||
|
g_last_page_ms = now;
|
||||||
|
g_page = (g_page + 1) % page_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_role == DeviceRole::Sender) {
|
||||||
|
if (g_page == 0) {
|
||||||
|
render_sender_status();
|
||||||
|
} else {
|
||||||
|
render_sender_measurement();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (g_page == 0) {
|
||||||
|
render_receiver_status();
|
||||||
|
} else {
|
||||||
|
render_receiver_sender(g_page - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g_prev_ctrl_high = ctrl_high;
|
||||||
|
}
|
||||||
55
src/json_codec.cpp
Normal file
55
src/json_codec.cpp
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#include "json_codec.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
bool meterDataToJson(const MeterData &data, String &out_json) {
|
||||||
|
StaticJsonDocument<256> doc;
|
||||||
|
doc["id"] = data.device_id;
|
||||||
|
doc["ts"] = data.ts_utc;
|
||||||
|
doc["energy_kwh"] = data.energy_total_kwh;
|
||||||
|
doc["p_total_w"] = data.total_power_w;
|
||||||
|
doc["p1_w"] = data.phase_power_w[0];
|
||||||
|
doc["p2_w"] = data.phase_power_w[1];
|
||||||
|
doc["p3_w"] = data.phase_power_w[2];
|
||||||
|
doc["v1_v"] = data.phase_voltage_v[0];
|
||||||
|
doc["v2_v"] = data.phase_voltage_v[1];
|
||||||
|
doc["v3_v"] = data.phase_voltage_v[2];
|
||||||
|
doc["bat_v"] = data.battery_voltage_v;
|
||||||
|
doc["bat_pct"] = data.battery_percent;
|
||||||
|
|
||||||
|
out_json = "";
|
||||||
|
size_t len = serializeJson(doc, out_json);
|
||||||
|
return len > 0 && len < 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool jsonToMeterData(const String &json, MeterData &data) {
|
||||||
|
StaticJsonDocument<256> doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, json);
|
||||||
|
if (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *id = doc["id"] | "";
|
||||||
|
strncpy(data.device_id, id, sizeof(data.device_id));
|
||||||
|
data.device_id[sizeof(data.device_id) - 1] = '\0';
|
||||||
|
|
||||||
|
data.ts_utc = doc["ts"] | 0;
|
||||||
|
data.energy_total_kwh = doc["energy_kwh"] | NAN;
|
||||||
|
data.total_power_w = doc["p_total_w"] | NAN;
|
||||||
|
data.phase_power_w[0] = doc["p1_w"] | NAN;
|
||||||
|
data.phase_power_w[1] = doc["p2_w"] | NAN;
|
||||||
|
data.phase_power_w[2] = doc["p3_w"] | NAN;
|
||||||
|
data.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_percent = doc["bat_pct"] | 0;
|
||||||
|
data.valid = true;
|
||||||
|
|
||||||
|
if (strlen(data.device_id) >= 8) {
|
||||||
|
const char *suffix = data.device_id + strlen(data.device_id) - 4;
|
||||||
|
data.short_id = static_cast<uint16_t>(strtoul(suffix, nullptr, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
102
src/lora_transport.cpp
Normal file
102
src/lora_transport.cpp
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#include "lora_transport.h"
|
||||||
|
#include <LoRa.h>
|
||||||
|
#include <SPI.h>
|
||||||
|
|
||||||
|
static uint16_t crc16_ccitt(const uint8_t *data, size_t len) {
|
||||||
|
uint16_t crc = 0xFFFF;
|
||||||
|
for (size_t i = 0; i < len; ++i) {
|
||||||
|
crc ^= static_cast<uint16_t>(data[i]) << 8;
|
||||||
|
for (uint8_t b = 0; b < 8; ++b) {
|
||||||
|
if (crc & 0x8000) {
|
||||||
|
crc = (crc << 1) ^ 0x1021;
|
||||||
|
} else {
|
||||||
|
crc <<= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
void lora_init() {
|
||||||
|
SPI.begin(PIN_LORA_SCK, PIN_LORA_MISO, PIN_LORA_MOSI, PIN_LORA_NSS);
|
||||||
|
LoRa.setPins(PIN_LORA_NSS, PIN_LORA_RST, PIN_LORA_DIO0);
|
||||||
|
LoRa.begin(LORA_FREQUENCY);
|
||||||
|
LoRa.setSpreadingFactor(LORA_SPREADING_FACTOR);
|
||||||
|
LoRa.setSignalBandwidth(LORA_BANDWIDTH);
|
||||||
|
LoRa.setCodingRate4(LORA_CODING_RATE);
|
||||||
|
LoRa.enableCrc();
|
||||||
|
LoRa.setSyncWord(LORA_SYNC_WORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool lora_send(const LoraPacket &pkt) {
|
||||||
|
uint8_t buffer[5 + LORA_MAX_PAYLOAD + 2];
|
||||||
|
size_t idx = 0;
|
||||||
|
buffer[idx++] = pkt.protocol_version;
|
||||||
|
buffer[idx++] = static_cast<uint8_t>(pkt.role);
|
||||||
|
buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short >> 8);
|
||||||
|
buffer[idx++] = static_cast<uint8_t>(pkt.device_id_short & 0xFF);
|
||||||
|
buffer[idx++] = static_cast<uint8_t>(pkt.payload_type);
|
||||||
|
|
||||||
|
if (pkt.payload_len > LORA_MAX_PAYLOAD) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(&buffer[idx], pkt.payload, pkt.payload_len);
|
||||||
|
idx += pkt.payload_len;
|
||||||
|
|
||||||
|
uint16_t crc = crc16_ccitt(buffer, idx);
|
||||||
|
buffer[idx++] = static_cast<uint8_t>(crc >> 8);
|
||||||
|
buffer[idx++] = static_cast<uint8_t>(crc & 0xFF);
|
||||||
|
|
||||||
|
LoRa.beginPacket();
|
||||||
|
LoRa.write(buffer, idx);
|
||||||
|
LoRa.endPacket();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool lora_receive(LoraPacket &pkt, uint32_t timeout_ms) {
|
||||||
|
uint32_t start = millis();
|
||||||
|
while (true) {
|
||||||
|
int packet_size = LoRa.parsePacket();
|
||||||
|
if (packet_size > 0) {
|
||||||
|
if (packet_size < 7) {
|
||||||
|
while (LoRa.available()) {
|
||||||
|
LoRa.read();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t buffer[5 + LORA_MAX_PAYLOAD + 2];
|
||||||
|
size_t len = 0;
|
||||||
|
while (LoRa.available() && len < sizeof(buffer)) {
|
||||||
|
buffer[len++] = LoRa.read();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len < 7) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t crc_calc = crc16_ccitt(buffer, len - 2);
|
||||||
|
uint16_t crc_rx = static_cast<uint16_t>(buffer[len - 2] << 8) | buffer[len - 1];
|
||||||
|
if (crc_calc != crc_rx) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt.protocol_version = buffer[0];
|
||||||
|
pkt.role = static_cast<DeviceRole>(buffer[1]);
|
||||||
|
pkt.device_id_short = static_cast<uint16_t>(buffer[2] << 8) | buffer[3];
|
||||||
|
pkt.payload_type = static_cast<PayloadType>(buffer[4]);
|
||||||
|
pkt.payload_len = len - 7;
|
||||||
|
if (pkt.payload_len > LORA_MAX_PAYLOAD) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
memcpy(pkt.payload, &buffer[5], pkt.payload_len);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeout_ms == 0 || millis() - start >= timeout_ms) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
delay(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/main.cpp
Normal file
182
src/main.cpp
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include "config.h"
|
||||||
|
#include "data_model.h"
|
||||||
|
#include "json_codec.h"
|
||||||
|
#include "compressor.h"
|
||||||
|
#include "lora_transport.h"
|
||||||
|
#include "meter_driver.h"
|
||||||
|
#include "power_manager.h"
|
||||||
|
#include "time_manager.h"
|
||||||
|
#include "wifi_manager.h"
|
||||||
|
#include "mqtt_client.h"
|
||||||
|
#include "web_server.h"
|
||||||
|
#include "display_ui.h"
|
||||||
|
#include "test_mode.h"
|
||||||
|
|
||||||
|
static DeviceRole g_role = DeviceRole::Sender;
|
||||||
|
static uint16_t g_short_id = 0;
|
||||||
|
static char g_device_id[16] = "";
|
||||||
|
|
||||||
|
static SenderStatus g_sender_statuses[NUM_SENDERS];
|
||||||
|
static bool g_ap_mode = false;
|
||||||
|
static WifiMqttConfig g_cfg;
|
||||||
|
static uint32_t g_last_timesync_ms = 0;
|
||||||
|
|
||||||
|
static void init_sender_statuses() {
|
||||||
|
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
|
||||||
|
g_sender_statuses[i] = {};
|
||||||
|
g_sender_statuses[i].has_data = false;
|
||||||
|
g_sender_statuses[i].last_update_ts_utc = 0;
|
||||||
|
g_sender_statuses[i].last_data.short_id = EXPECTED_SENDER_IDS[i];
|
||||||
|
snprintf(g_sender_statuses[i].last_data.device_id, sizeof(g_sender_statuses[i].last_data.device_id), "dd3-%04X", EXPECTED_SENDER_IDS[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(200);
|
||||||
|
|
||||||
|
g_role = detect_role();
|
||||||
|
init_device_ids(g_short_id, g_device_id, sizeof(g_device_id));
|
||||||
|
|
||||||
|
lora_init();
|
||||||
|
display_init();
|
||||||
|
display_set_role(g_role);
|
||||||
|
display_set_self_ids(g_short_id, g_device_id);
|
||||||
|
|
||||||
|
if (g_role == DeviceRole::Sender) {
|
||||||
|
power_sender_init();
|
||||||
|
meter_init();
|
||||||
|
} else {
|
||||||
|
power_receiver_init();
|
||||||
|
wifi_manager_init();
|
||||||
|
init_sender_statuses();
|
||||||
|
display_set_sender_statuses(g_sender_statuses, NUM_SENDERS);
|
||||||
|
|
||||||
|
bool has_cfg = wifi_load_config(g_cfg);
|
||||||
|
if (has_cfg && wifi_connect_sta(g_cfg)) {
|
||||||
|
g_ap_mode = false;
|
||||||
|
time_receiver_init(g_cfg.ntp_server_1.c_str(), g_cfg.ntp_server_2.c_str());
|
||||||
|
mqtt_init(g_cfg, g_device_id);
|
||||||
|
web_server_set_config(g_cfg);
|
||||||
|
web_server_begin_sta(g_sender_statuses, NUM_SENDERS);
|
||||||
|
} else {
|
||||||
|
g_ap_mode = true;
|
||||||
|
char ap_ssid[32];
|
||||||
|
snprintf(ap_ssid, sizeof(ap_ssid), "DD3-Bridge-%04X", g_short_id);
|
||||||
|
wifi_start_ap(ap_ssid, "changeme123");
|
||||||
|
if (g_cfg.ntp_server_1.isEmpty()) {
|
||||||
|
g_cfg.ntp_server_1 = "pool.ntp.org";
|
||||||
|
}
|
||||||
|
if (g_cfg.ntp_server_2.isEmpty()) {
|
||||||
|
g_cfg.ntp_server_2 = "time.nist.gov";
|
||||||
|
}
|
||||||
|
web_server_set_config(g_cfg);
|
||||||
|
web_server_begin_ap(g_sender_statuses, NUM_SENDERS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sender_cycle() {
|
||||||
|
MeterData data = {};
|
||||||
|
data.short_id = g_short_id;
|
||||||
|
strncpy(data.device_id, g_device_id, sizeof(data.device_id));
|
||||||
|
|
||||||
|
bool meter_ok = meter_read(data);
|
||||||
|
read_battery(data);
|
||||||
|
|
||||||
|
uint32_t now_utc = time_get_utc();
|
||||||
|
data.ts_utc = now_utc > 0 ? now_utc : millis() / 1000;
|
||||||
|
data.valid = meter_ok;
|
||||||
|
|
||||||
|
display_set_last_meter(data);
|
||||||
|
display_set_last_read(meter_ok, data.ts_utc);
|
||||||
|
|
||||||
|
String json;
|
||||||
|
bool json_ok = meterDataToJson(data, json);
|
||||||
|
|
||||||
|
bool tx_ok = false;
|
||||||
|
if (json_ok) {
|
||||||
|
uint8_t compressed[LORA_MAX_PAYLOAD];
|
||||||
|
size_t compressed_len = 0;
|
||||||
|
if (compressBuffer(reinterpret_cast<const uint8_t *>(json.c_str()), json.length(), compressed, sizeof(compressed), compressed_len)) {
|
||||||
|
LoraPacket pkt = {};
|
||||||
|
pkt.protocol_version = PROTOCOL_VERSION;
|
||||||
|
pkt.role = DeviceRole::Sender;
|
||||||
|
pkt.device_id_short = g_short_id;
|
||||||
|
pkt.payload_type = PayloadType::MeterData;
|
||||||
|
pkt.payload_len = compressed_len;
|
||||||
|
memcpy(pkt.payload, compressed, compressed_len);
|
||||||
|
tx_ok = lora_send(pkt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
display_set_last_tx(tx_ok, data.ts_utc);
|
||||||
|
display_tick();
|
||||||
|
|
||||||
|
LoraPacket rx = {};
|
||||||
|
if (lora_receive(rx, 200) && rx.protocol_version == PROTOCOL_VERSION && rx.payload_type == PayloadType::TimeSync) {
|
||||||
|
time_handle_timesync_payload(rx.payload, rx.payload_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(50);
|
||||||
|
go_to_deep_sleep(SENDER_WAKE_INTERVAL_SEC);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void receiver_loop() {
|
||||||
|
LoraPacket pkt = {};
|
||||||
|
if (lora_receive(pkt, 0) && pkt.protocol_version == PROTOCOL_VERSION && pkt.payload_type == PayloadType::MeterData) {
|
||||||
|
uint8_t decompressed[256];
|
||||||
|
size_t decompressed_len = 0;
|
||||||
|
if (decompressBuffer(pkt.payload, pkt.payload_len, decompressed, sizeof(decompressed), decompressed_len)) {
|
||||||
|
decompressed[decompressed_len] = '\0';
|
||||||
|
MeterData data = {};
|
||||||
|
if (jsonToMeterData(String(reinterpret_cast<const char *>(decompressed)), data)) {
|
||||||
|
for (uint8_t i = 0; i < NUM_SENDERS; ++i) {
|
||||||
|
if (pkt.device_id_short == EXPECTED_SENDER_IDS[i]) {
|
||||||
|
data.short_id = pkt.device_id_short;
|
||||||
|
g_sender_statuses[i].last_data = data;
|
||||||
|
g_sender_statuses[i].last_update_ts_utc = data.ts_utc;
|
||||||
|
g_sender_statuses[i].has_data = true;
|
||||||
|
mqtt_publish_state(data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!g_ap_mode && millis() - g_last_timesync_ms > TIME_SYNC_INTERVAL_SEC * 1000UL) {
|
||||||
|
g_last_timesync_ms = millis();
|
||||||
|
time_send_timesync(g_short_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
mqtt_loop();
|
||||||
|
web_server_loop();
|
||||||
|
display_set_receiver_status(g_ap_mode, wifi_is_connected() ? wifi_get_ssid().c_str() : "AP", mqtt_is_connected());
|
||||||
|
display_tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
#ifdef ENABLE_TEST_MODE
|
||||||
|
if (g_role == DeviceRole::Sender) {
|
||||||
|
test_sender_loop(g_short_id, g_device_id);
|
||||||
|
display_tick();
|
||||||
|
delay(50);
|
||||||
|
} else {
|
||||||
|
test_receiver_loop(g_sender_statuses, NUM_SENDERS);
|
||||||
|
mqtt_loop();
|
||||||
|
web_server_loop();
|
||||||
|
display_set_receiver_status(g_ap_mode, wifi_is_connected() ? wifi_get_ssid().c_str() : "AP", mqtt_is_connected());
|
||||||
|
display_tick();
|
||||||
|
delay(50);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (g_role == DeviceRole::Sender) {
|
||||||
|
sender_cycle();
|
||||||
|
} else {
|
||||||
|
receiver_loop();
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/meter_driver.cpp
Normal file
166
src/meter_driver.cpp
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
#include "meter_driver.h"
|
||||||
|
#include "config.h"
|
||||||
|
#include <math.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
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() {
|
||||||
|
Serial2.begin(9600, SERIAL_7E1, PIN_METER_RX, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool meter_read(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;
|
||||||
|
}
|
||||||
63
src/mqtt_client.cpp
Normal file
63
src/mqtt_client.cpp
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#include "mqtt_client.h"
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <PubSubClient.h>
|
||||||
|
#include "json_codec.h"
|
||||||
|
|
||||||
|
static WiFiClient wifi_client;
|
||||||
|
static PubSubClient mqtt_client(wifi_client);
|
||||||
|
static WifiMqttConfig g_cfg;
|
||||||
|
static String g_client_id;
|
||||||
|
|
||||||
|
void mqtt_init(const WifiMqttConfig &config, const char *device_id) {
|
||||||
|
g_cfg = config;
|
||||||
|
mqtt_client.setServer(config.mqtt_host.c_str(), config.mqtt_port);
|
||||||
|
if (device_id && device_id[0] != '\0') {
|
||||||
|
g_client_id = String("dd3-bridge-") + device_id;
|
||||||
|
} else {
|
||||||
|
g_client_id = String("dd3-bridge-") + String(random(0xffff), HEX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool mqtt_connect() {
|
||||||
|
if (mqtt_client.connected()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String client_id = g_client_id.length() > 0 ? g_client_id : String("dd3-bridge-") + String(random(0xffff), HEX);
|
||||||
|
if (g_cfg.mqtt_user.length() > 0) {
|
||||||
|
return mqtt_client.connect(client_id.c_str(), g_cfg.mqtt_user.c_str(), g_cfg.mqtt_pass.c_str());
|
||||||
|
}
|
||||||
|
return mqtt_client.connect(client_id.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void mqtt_loop() {
|
||||||
|
if (!mqtt_connect()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mqtt_client.loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool mqtt_is_connected() {
|
||||||
|
return mqtt_client.connected();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool mqtt_publish_state(const MeterData &data) {
|
||||||
|
if (!mqtt_connect()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String payload;
|
||||||
|
if (!meterDataToJson(data, payload)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String topic = String("smartmeter/") + data.device_id + "/state";
|
||||||
|
return mqtt_client.publish(topic.c_str(), payload.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef ENABLE_TEST_MODE
|
||||||
|
bool mqtt_publish_test(const char *device_id, const String &payload) {
|
||||||
|
if (!mqtt_connect()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String topic = String("smartmeter/") + device_id + "/test";
|
||||||
|
return mqtt_client.publish(topic.c_str(), payload.c_str());
|
||||||
|
}
|
||||||
|
#endif
|
||||||
48
src/power_manager.cpp
Normal file
48
src/power_manager.cpp
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#include "power_manager.h"
|
||||||
|
#include "config.h"
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <esp_wifi.h>
|
||||||
|
#include <esp_bt.h>
|
||||||
|
#include <esp_sleep.h>
|
||||||
|
|
||||||
|
static constexpr float BATTERY_DIVIDER = 2.0f;
|
||||||
|
static constexpr float BATTERY_CAL = 1.0f;
|
||||||
|
static constexpr float ADC_REF_V = 3.3f;
|
||||||
|
|
||||||
|
void power_sender_init() {
|
||||||
|
esp_wifi_stop();
|
||||||
|
btStop();
|
||||||
|
analogReadResolution(12);
|
||||||
|
pinMode(PIN_BAT_ADC, INPUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
void power_receiver_init() {
|
||||||
|
analogReadResolution(12);
|
||||||
|
pinMode(PIN_BAT_ADC, INPUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
void read_battery(MeterData &data) {
|
||||||
|
const int samples = 8;
|
||||||
|
uint32_t sum = 0;
|
||||||
|
for (int i = 0; i < samples; ++i) {
|
||||||
|
sum += analogRead(PIN_BAT_ADC);
|
||||||
|
delay(5);
|
||||||
|
}
|
||||||
|
float avg = static_cast<float>(sum) / samples;
|
||||||
|
float v = (avg / 4095.0f) * ADC_REF_V * BATTERY_DIVIDER * BATTERY_CAL;
|
||||||
|
|
||||||
|
data.battery_voltage_v = v;
|
||||||
|
float pct = (v - 3.0f) / (4.2f - 3.0f) * 100.0f;
|
||||||
|
if (pct < 0.0f) {
|
||||||
|
pct = 0.0f;
|
||||||
|
}
|
||||||
|
if (pct > 100.0f) {
|
||||||
|
pct = 100.0f;
|
||||||
|
}
|
||||||
|
data.battery_percent = static_cast<uint8_t>(pct + 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void go_to_deep_sleep(uint32_t seconds) {
|
||||||
|
esp_sleep_enable_timer_wakeup(static_cast<uint64_t>(seconds) * 1000000ULL);
|
||||||
|
esp_deep_sleep_start();
|
||||||
|
}
|
||||||
97
src/test_mode.cpp
Normal file
97
src/test_mode.cpp
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
#include "test_mode.h"
|
||||||
|
|
||||||
|
#ifdef ENABLE_TEST_MODE
|
||||||
|
#include "config.h"
|
||||||
|
#include "compressor.h"
|
||||||
|
#include "lora_transport.h"
|
||||||
|
#include "json_codec.h"
|
||||||
|
#include "time_manager.h"
|
||||||
|
#include "display_ui.h"
|
||||||
|
#include "mqtt_client.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
static uint32_t g_last_test_ms = 0;
|
||||||
|
static uint32_t g_last_timesync_ms = 0;
|
||||||
|
|
||||||
|
void test_sender_loop(uint16_t short_id, const char *device_id) {
|
||||||
|
LoraPacket rx = {};
|
||||||
|
if (lora_receive(rx, 0) && rx.payload_type == PayloadType::TimeSync) {
|
||||||
|
time_handle_timesync_payload(rx.payload, rx.payload_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (millis() - g_last_test_ms < 30000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_last_test_ms = millis();
|
||||||
|
|
||||||
|
char code[5];
|
||||||
|
uint16_t val = random(0, 9999);
|
||||||
|
snprintf(code, sizeof(code), "%04u", val);
|
||||||
|
display_set_test_code(code);
|
||||||
|
|
||||||
|
StaticJsonDocument<128> doc;
|
||||||
|
doc["id"] = device_id;
|
||||||
|
doc["role"] = "sender";
|
||||||
|
doc["test_code"] = code;
|
||||||
|
doc["ts"] = time_get_utc();
|
||||||
|
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
|
||||||
|
uint8_t compressed[LORA_MAX_PAYLOAD];
|
||||||
|
size_t compressed_len = 0;
|
||||||
|
if (!compressBuffer(reinterpret_cast<const uint8_t *>(json.c_str()), json.length(), compressed, sizeof(compressed), compressed_len)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoraPacket pkt = {};
|
||||||
|
pkt.protocol_version = PROTOCOL_VERSION;
|
||||||
|
pkt.role = DeviceRole::Sender;
|
||||||
|
pkt.device_id_short = short_id;
|
||||||
|
pkt.payload_type = PayloadType::TestCode;
|
||||||
|
pkt.payload_len = compressed_len;
|
||||||
|
memcpy(pkt.payload, compressed, compressed_len);
|
||||||
|
lora_send(pkt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_receiver_loop(SenderStatus *statuses, uint8_t count) {
|
||||||
|
if (millis() - g_last_timesync_ms > TIME_SYNC_INTERVAL_SEC * 1000UL) {
|
||||||
|
g_last_timesync_ms = millis();
|
||||||
|
time_send_timesync(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
LoraPacket pkt = {};
|
||||||
|
if (!lora_receive(pkt, 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pkt.payload_type != PayloadType::TestCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t decompressed[128];
|
||||||
|
size_t decompressed_len = 0;
|
||||||
|
if (!decompressBuffer(pkt.payload, pkt.payload_len, decompressed, sizeof(decompressed), decompressed_len)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
decompressed[decompressed_len] = '\0';
|
||||||
|
|
||||||
|
StaticJsonDocument<128> doc;
|
||||||
|
if (deserializeJson(doc, reinterpret_cast<const char *>(decompressed)) != DeserializationError::Ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *id = doc["id"] | "";
|
||||||
|
const char *code = doc["test_code"] | "";
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < count; ++i) {
|
||||||
|
if (strncmp(statuses[i].last_data.device_id, id, sizeof(statuses[i].last_data.device_id)) == 0) {
|
||||||
|
display_set_test_code_for_sender(i, code);
|
||||||
|
statuses[i].has_data = true;
|
||||||
|
statuses[i].last_update_ts_utc = time_get_utc();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mqtt_publish_test(id, String(reinterpret_cast<const char *>(decompressed)));
|
||||||
|
}
|
||||||
|
#endif
|
||||||
103
src/time_manager.cpp
Normal file
103
src/time_manager.cpp
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
#include "time_manager.h"
|
||||||
|
#include "compressor.h"
|
||||||
|
#include "config.h"
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
static bool g_time_synced = false;
|
||||||
|
static bool g_tz_set = false;
|
||||||
|
|
||||||
|
void time_receiver_init(const char *ntp_server_1, const char *ntp_server_2) {
|
||||||
|
const char *server1 = (ntp_server_1 && ntp_server_1[0] != '\0') ? ntp_server_1 : "pool.ntp.org";
|
||||||
|
const char *server2 = (ntp_server_2 && ntp_server_2[0] != '\0') ? ntp_server_2 : "time.nist.gov";
|
||||||
|
configTime(0, 0, server1, server2);
|
||||||
|
if (!g_tz_set) {
|
||||||
|
setenv("TZ", "CET-1CEST,M3.5.0/2,M10.5.0/3", 1);
|
||||||
|
tzset();
|
||||||
|
g_tz_set = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t time_get_utc() {
|
||||||
|
time_t now = time(nullptr);
|
||||||
|
if (now < 1672531200) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return static_cast<uint32_t>(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool time_is_synced() {
|
||||||
|
return g_time_synced || time_get_utc() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void time_set_utc(uint32_t epoch) {
|
||||||
|
if (!g_tz_set) {
|
||||||
|
setenv("TZ", "CET-1CEST,M3.5.0/2,M10.5.0/3", 1);
|
||||||
|
tzset();
|
||||||
|
g_tz_set = true;
|
||||||
|
}
|
||||||
|
struct timeval tv;
|
||||||
|
tv.tv_sec = epoch;
|
||||||
|
tv.tv_usec = 0;
|
||||||
|
settimeofday(&tv, nullptr);
|
||||||
|
g_time_synced = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void time_send_timesync(uint16_t device_id_short) {
|
||||||
|
uint32_t epoch = time_get_utc();
|
||||||
|
if (epoch == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char payload_str[32];
|
||||||
|
snprintf(payload_str, sizeof(payload_str), "T:%lu", static_cast<unsigned long>(epoch));
|
||||||
|
|
||||||
|
uint8_t compressed[LORA_MAX_PAYLOAD];
|
||||||
|
size_t compressed_len = 0;
|
||||||
|
if (!compressBuffer(reinterpret_cast<const uint8_t *>(payload_str), strlen(payload_str), compressed, sizeof(compressed), compressed_len)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoraPacket pkt = {};
|
||||||
|
pkt.protocol_version = PROTOCOL_VERSION;
|
||||||
|
pkt.role = DeviceRole::Receiver;
|
||||||
|
pkt.device_id_short = device_id_short;
|
||||||
|
pkt.payload_type = PayloadType::TimeSync;
|
||||||
|
pkt.payload_len = compressed_len;
|
||||||
|
memcpy(pkt.payload, compressed, compressed_len);
|
||||||
|
lora_send(pkt);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool time_handle_timesync_payload(const uint8_t *payload, size_t len) {
|
||||||
|
uint8_t decompressed[64];
|
||||||
|
size_t decompressed_len = 0;
|
||||||
|
if (!decompressBuffer(payload, len, decompressed, sizeof(decompressed), decompressed_len)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (decompressed_len >= sizeof(decompressed)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
decompressed[decompressed_len] = '\0';
|
||||||
|
|
||||||
|
if (decompressed_len < 3 || decompressed[0] != 'T' || decompressed[1] != ':') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t epoch = static_cast<uint32_t>(strtoul(reinterpret_cast<const char *>(decompressed + 2), nullptr, 10));
|
||||||
|
if (epoch == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
time_set_utc(epoch);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void time_get_local_hhmm(char *out, size_t out_len) {
|
||||||
|
if (!time_is_synced()) {
|
||||||
|
snprintf(out, out_len, "--:--");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
time_t now = time(nullptr);
|
||||||
|
struct tm timeinfo;
|
||||||
|
localtime_r(&now, &timeinfo);
|
||||||
|
snprintf(out, out_len, "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
|
||||||
|
}
|
||||||
155
src/web_server.cpp
Normal file
155
src/web_server.cpp
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
#include "web_server.h"
|
||||||
|
#include <WebServer.h>
|
||||||
|
#include "wifi_manager.h"
|
||||||
|
|
||||||
|
static WebServer server(80);
|
||||||
|
static const SenderStatus *g_statuses = nullptr;
|
||||||
|
static uint8_t g_status_count = 0;
|
||||||
|
static WifiMqttConfig g_config;
|
||||||
|
static bool g_is_ap = false;
|
||||||
|
|
||||||
|
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'>";
|
||||||
|
h += "<title>" + title + "</title></head><body>";
|
||||||
|
h += "<h2>" + title + "</h2>";
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String html_footer() {
|
||||||
|
return "</body></html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
static String render_sender_block(const SenderStatus &status) {
|
||||||
|
String s;
|
||||||
|
s += "<div style='margin-bottom:10px;padding:6px;border:1px solid #ccc'>";
|
||||||
|
s += "<strong>" + String(status.last_data.device_id) + "</strong><br>";
|
||||||
|
if (!status.has_data) {
|
||||||
|
s += "No data";
|
||||||
|
} else {
|
||||||
|
s += "Energy: " + String(status.last_data.energy_total_kwh, 3) + " kWh<br>";
|
||||||
|
s += "Power: " + String(status.last_data.total_power_w, 1) + " W<br>";
|
||||||
|
s += "Battery: " + String(status.last_data.battery_voltage_v, 2) + " V (" + String(status.last_data.battery_percent) + ")";
|
||||||
|
}
|
||||||
|
s += "</div>";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_root() {
|
||||||
|
String html = html_header("DD3 Bridge Status");
|
||||||
|
html += g_is_ap ? "<p>Mode: AP</p>" : "<p>Mode: STA</p>";
|
||||||
|
|
||||||
|
if (g_statuses) {
|
||||||
|
for (uint8_t i = 0; i < g_status_count; ++i) {
|
||||||
|
html += render_sender_block(g_statuses[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += "<p><a href='/wifi'>Configure WiFi/MQTT/NTP</a></p>";
|
||||||
|
html += html_footer();
|
||||||
|
server.send(200, "text/html", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_wifi_get() {
|
||||||
|
String html = html_header("WiFi/MQTT Config");
|
||||||
|
html += "<form method='POST' action='/wifi'>";
|
||||||
|
html += "SSID: <input name='ssid' value='" + g_config.ssid + "'><br>";
|
||||||
|
html += "Password: <input name='pass' type='password' value='" + g_config.password + "'><br>";
|
||||||
|
html += "MQTT Host: <input name='mqhost' value='" + g_config.mqtt_host + "'><br>";
|
||||||
|
html += "MQTT Port: <input name='mqport' value='" + String(g_config.mqtt_port) + "'><br>";
|
||||||
|
html += "MQTT User: <input name='mquser' value='" + g_config.mqtt_user + "'><br>";
|
||||||
|
html += "MQTT Pass: <input name='mqpass' type='password' value='" + g_config.mqtt_pass + "'><br>";
|
||||||
|
html += "NTP Server 1: <input name='ntp1' value='" + g_config.ntp_server_1 + "'><br>";
|
||||||
|
html += "NTP Server 2: <input name='ntp2' value='" + g_config.ntp_server_2 + "'><br>";
|
||||||
|
html += "<button type='submit'>Save</button>";
|
||||||
|
html += "</form>";
|
||||||
|
html += html_footer();
|
||||||
|
server.send(200, "text/html", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_wifi_post() {
|
||||||
|
WifiMqttConfig cfg;
|
||||||
|
cfg.ntp_server_1 = "pool.ntp.org";
|
||||||
|
cfg.ntp_server_2 = "time.nist.gov";
|
||||||
|
cfg.ssid = server.arg("ssid");
|
||||||
|
cfg.password = server.arg("pass");
|
||||||
|
cfg.mqtt_host = server.arg("mqhost");
|
||||||
|
cfg.mqtt_port = static_cast<uint16_t>(server.arg("mqport").toInt());
|
||||||
|
cfg.mqtt_user = server.arg("mquser");
|
||||||
|
cfg.mqtt_pass = server.arg("mqpass");
|
||||||
|
if (server.arg("ntp1").length() > 0) {
|
||||||
|
cfg.ntp_server_1 = server.arg("ntp1");
|
||||||
|
}
|
||||||
|
if (server.arg("ntp2").length() > 0) {
|
||||||
|
cfg.ntp_server_2 = server.arg("ntp2");
|
||||||
|
}
|
||||||
|
cfg.valid = true;
|
||||||
|
wifi_save_config(cfg);
|
||||||
|
server.send(200, "text/html", "<html><body>Saved. Rebooting...</body></html>");
|
||||||
|
delay(1000);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_sender() {
|
||||||
|
if (!g_statuses) {
|
||||||
|
server.send(404, "text/plain", "No senders");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String uri = server.uri();
|
||||||
|
String device_id = uri.substring(String("/sender/").length());
|
||||||
|
for (uint8_t i = 0; i < g_status_count; ++i) {
|
||||||
|
if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) {
|
||||||
|
String html = html_header("Sender " + device_id);
|
||||||
|
html += render_sender_block(g_statuses[i]);
|
||||||
|
html += html_footer();
|
||||||
|
server.send(200, "text/html", html);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.send(404, "text/plain", "Not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
void web_server_set_config(const WifiMqttConfig &config) {
|
||||||
|
g_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
void web_server_begin_ap(const SenderStatus *statuses, uint8_t count) {
|
||||||
|
g_statuses = statuses;
|
||||||
|
g_status_count = count;
|
||||||
|
g_is_ap = true;
|
||||||
|
|
||||||
|
server.on("/", handle_root);
|
||||||
|
server.on("/wifi", HTTP_GET, handle_wifi_get);
|
||||||
|
server.on("/wifi", HTTP_POST, handle_wifi_post);
|
||||||
|
server.on("/sender/", handle_sender);
|
||||||
|
server.onNotFound([]() {
|
||||||
|
if (server.uri().startsWith("/sender/")) {
|
||||||
|
handle_sender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.send(404, "text/plain", "Not found");
|
||||||
|
});
|
||||||
|
server.begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
void web_server_begin_sta(const SenderStatus *statuses, uint8_t count) {
|
||||||
|
g_statuses = statuses;
|
||||||
|
g_status_count = count;
|
||||||
|
g_is_ap = false;
|
||||||
|
|
||||||
|
server.on("/", handle_root);
|
||||||
|
server.on("/sender/", handle_sender);
|
||||||
|
server.on("/wifi", HTTP_GET, handle_wifi_get);
|
||||||
|
server.on("/wifi", HTTP_POST, handle_wifi_post);
|
||||||
|
server.onNotFound([]() {
|
||||||
|
if (server.uri().startsWith("/sender/")) {
|
||||||
|
handle_sender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.send(404, "text/plain", "Not found");
|
||||||
|
});
|
||||||
|
server.begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
void web_server_loop() {
|
||||||
|
server.handleClient();
|
||||||
|
}
|
||||||
60
src/wifi_manager.cpp
Normal file
60
src/wifi_manager.cpp
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#include "wifi_manager.h"
|
||||||
|
#include <WiFi.h>
|
||||||
|
|
||||||
|
static Preferences prefs;
|
||||||
|
|
||||||
|
void wifi_manager_init() {
|
||||||
|
prefs.begin("dd3cfg", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wifi_load_config(WifiMqttConfig &config) {
|
||||||
|
config.valid = prefs.getBool("valid", false);
|
||||||
|
if (!config.valid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
config.ssid = prefs.getString("ssid", "");
|
||||||
|
config.password = prefs.getString("pass", "");
|
||||||
|
config.mqtt_host = prefs.getString("mqhost", "");
|
||||||
|
config.mqtt_port = prefs.getUShort("mqport", 1883);
|
||||||
|
config.mqtt_user = prefs.getString("mquser", "");
|
||||||
|
config.mqtt_pass = prefs.getString("mqpass", "");
|
||||||
|
config.ntp_server_1 = prefs.getString("ntp1", "pool.ntp.org");
|
||||||
|
config.ntp_server_2 = prefs.getString("ntp2", "time.nist.gov");
|
||||||
|
return config.ssid.length() > 0 && config.mqtt_host.length() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wifi_save_config(const WifiMqttConfig &config) {
|
||||||
|
prefs.putBool("valid", true);
|
||||||
|
prefs.putString("ssid", config.ssid);
|
||||||
|
prefs.putString("pass", config.password);
|
||||||
|
prefs.putString("mqhost", config.mqtt_host);
|
||||||
|
prefs.putUShort("mqport", config.mqtt_port);
|
||||||
|
prefs.putString("mquser", config.mqtt_user);
|
||||||
|
prefs.putString("mqpass", config.mqtt_pass);
|
||||||
|
prefs.putString("ntp1", config.ntp_server_1);
|
||||||
|
prefs.putString("ntp2", config.ntp_server_2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wifi_connect_sta(const WifiMqttConfig &config, uint32_t timeout_ms) {
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
WiFi.begin(config.ssid.c_str(), config.password.c_str());
|
||||||
|
uint32_t start = millis();
|
||||||
|
while (WiFi.status() != WL_CONNECTED && millis() - start < timeout_ms) {
|
||||||
|
delay(200);
|
||||||
|
}
|
||||||
|
return WiFi.status() == WL_CONNECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
void wifi_start_ap(const char *ap_ssid, const char *ap_pass) {
|
||||||
|
WiFi.mode(WIFI_AP);
|
||||||
|
WiFi.softAP(ap_ssid, ap_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wifi_is_connected() {
|
||||||
|
return WiFi.status() == WL_CONNECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
String wifi_get_ssid() {
|
||||||
|
return WiFi.SSID();
|
||||||
|
}
|
||||||
11
test/README
Normal file
11
test/README
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
This directory is intended for PlatformIO Test Runner and project tests.
|
||||||
|
|
||||||
|
Unit Testing is a software testing method by which individual units of
|
||||||
|
source code, sets of one or more MCU program modules together with associated
|
||||||
|
control data, usage procedures, and operating procedures, are tested to
|
||||||
|
determine whether they are fit for use. Unit testing finds problems early
|
||||||
|
in the development cycle.
|
||||||
|
|
||||||
|
More information about PlatformIO Unit Testing:
|
||||||
|
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||||
Reference in New Issue
Block a user