Initial commit

This commit is contained in:
2026-01-20 01:39:06 +01:00
commit 6f308ad590
34 changed files with 2068 additions and 0 deletions

5
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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