From 6f308ad590035e219815c6385380aa9ee74f8b99 Mon Sep 17 00:00:00 2001 From: acidburns Date: Tue, 20 Jan 2026 01:39:06 +0100 Subject: [PATCH] Initial commit --- .gitignore | 5 + .vscode/extensions.json | 10 ++ README.md | 269 ++++++++++++++++++++++++++++++++ include/README | 37 +++++ include/compressor.h | 6 + include/config.h | 58 +++++++ include/data_model.h | 24 +++ include/display_ui.h | 19 +++ include/json_codec.h | 7 + include/lora_transport.h | 19 +++ include/meter_driver.h | 7 + include/mqtt_client.h | 13 ++ include/power_manager.h | 9 ++ include/test_mode.h | 9 ++ include/time_manager.h | 12 ++ include/web_server.h | 10 ++ include/wifi_manager.h | 25 +++ lib/README | 46 ++++++ platformio.ini | 33 ++++ src/compressor.cpp | 71 +++++++++ src/config.cpp | 6 + src/data_model.cpp | 9 ++ src/display_ui.cpp | 322 +++++++++++++++++++++++++++++++++++++++ src/json_codec.cpp | 55 +++++++ src/lora_transport.cpp | 102 +++++++++++++ src/main.cpp | 182 ++++++++++++++++++++++ src/meter_driver.cpp | 166 ++++++++++++++++++++ src/mqtt_client.cpp | 63 ++++++++ src/power_manager.cpp | 48 ++++++ src/test_mode.cpp | 97 ++++++++++++ src/time_manager.cpp | 103 +++++++++++++ src/web_server.cpp | 155 +++++++++++++++++++ src/wifi_manager.cpp | 60 ++++++++ test/README | 11 ++ 34 files changed, 2068 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 README.md create mode 100644 include/README create mode 100644 include/compressor.h create mode 100644 include/config.h create mode 100644 include/data_model.h create mode 100644 include/display_ui.h create mode 100644 include/json_codec.h create mode 100644 include/lora_transport.h create mode 100644 include/meter_driver.h create mode 100644 include/mqtt_client.h create mode 100644 include/power_manager.h create mode 100644 include/test_mode.h create mode 100644 include/time_manager.h create mode 100644 include/web_server.h create mode 100644 include/wifi_manager.h create mode 100644 lib/README create mode 100644 platformio.ini create mode 100644 src/compressor.cpp create mode 100644 src/config.cpp create mode 100644 src/data_model.cpp create mode 100644 src/display_ui.cpp create mode 100644 src/json_codec.cpp create mode 100644 src/lora_transport.cpp create mode 100644 src/main.cpp create mode 100644 src/meter_driver.cpp create mode 100644 src/mqtt_client.cpp create mode 100644 src/power_manager.cpp create mode 100644 src/test_mode.cpp create mode 100644 src/time_manager.cpp create mode 100644 src/web_server.cpp create mode 100644 src/wifi_manager.cpp create mode 100644 test/README diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -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" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..360dcbd --- /dev/null +++ b/README.md @@ -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-` +- AP password: `changeme123` +- Endpoints: + - `/`: status overview + - `/wifi`: WiFi/MQTT/NTP config (AP and STA) + - `/sender/`: per-sender details + +## MQTT +- Topic: `smartmeter//state` +- QoS 0 +- Test mode: `smartmeter//test` +- Client ID: `dd3-bridge-` (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 +``` + diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -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 diff --git a/include/compressor.h b/include/compressor.h new file mode 100644 index 0000000..6d08714 --- /dev/null +++ b/include/compressor.h @@ -0,0 +1,6 @@ +#pragma once + +#include + +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); diff --git a/include/config.h b/include/config.h new file mode 100644 index 0000000..02b530a --- /dev/null +++ b/include/config.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +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(); diff --git a/include/data_model.h b/include/data_model.h new file mode 100644 index 0000000..6b62321 --- /dev/null +++ b/include/data_model.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +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); diff --git a/include/display_ui.h b/include/display_ui.h new file mode 100644 index 0000000..ff7641d --- /dev/null +++ b/include/display_ui.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#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 diff --git a/include/json_codec.h b/include/json_codec.h new file mode 100644 index 0000000..7c79166 --- /dev/null +++ b/include/json_codec.h @@ -0,0 +1,7 @@ +#pragma once + +#include +#include "data_model.h" + +bool meterDataToJson(const MeterData &data, String &out_json); +bool jsonToMeterData(const String &json, MeterData &data); diff --git a/include/lora_transport.h b/include/lora_transport.h new file mode 100644 index 0000000..9d70e4d --- /dev/null +++ b/include/lora_transport.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#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); diff --git a/include/meter_driver.h b/include/meter_driver.h new file mode 100644 index 0000000..25d4761 --- /dev/null +++ b/include/meter_driver.h @@ -0,0 +1,7 @@ +#pragma once + +#include +#include "data_model.h" + +void meter_init(); +bool meter_read(MeterData &data); diff --git a/include/mqtt_client.h b/include/mqtt_client.h new file mode 100644 index 0000000..e0bee61 --- /dev/null +++ b/include/mqtt_client.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#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 diff --git a/include/power_manager.h b/include/power_manager.h new file mode 100644 index 0000000..89c387b --- /dev/null +++ b/include/power_manager.h @@ -0,0 +1,9 @@ +#pragma once + +#include +#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); diff --git a/include/test_mode.h b/include/test_mode.h new file mode 100644 index 0000000..e8c2ce8 --- /dev/null +++ b/include/test_mode.h @@ -0,0 +1,9 @@ +#pragma once + +#include +#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 diff --git a/include/time_manager.h b/include/time_manager.h new file mode 100644 index 0000000..3cefab9 --- /dev/null +++ b/include/time_manager.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#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); diff --git a/include/web_server.h b/include/web_server.h new file mode 100644 index 0000000..ee9a980 --- /dev/null +++ b/include/web_server.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#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(); diff --git a/include/wifi_manager.h b/include/wifi_manager.h new file mode 100644 index 0000000..e9012ca --- /dev/null +++ b/include/wifi_manager.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +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(); diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -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 +#include + +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 diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..d7f14d6 --- /dev/null +++ b/platformio.ini @@ -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 diff --git a/src/compressor.cpp b/src/compressor.cpp new file mode 100644 index 0000000..72b0f57 --- /dev/null +++ b/src/compressor.cpp @@ -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(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; +} diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..cf2fe9d --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,6 @@ +#include "config.h" + +DeviceRole detect_role() { + pinMode(PIN_ROLE, INPUT_PULLDOWN); + return digitalRead(PIN_ROLE) == HIGH ? DeviceRole::Receiver : DeviceRole::Sender; +} diff --git a/src/data_model.cpp b/src/data_model.cpp new file mode 100644 index 0000000..73dbb96 --- /dev/null +++ b/src/data_model.cpp @@ -0,0 +1,9 @@ +#include "data_model.h" +#include + +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(mac[4]) << 8) | mac[5]; + snprintf(device_id, device_id_len, "dd3-%04X", short_id); +} diff --git a/src/display_ui.cpp b/src/display_ui.cpp new file mode 100644 index 0000000..d88fe09 --- /dev/null +++ b/src/display_ui.cpp @@ -0,0 +1,322 @@ +#include "display_ui.h" +#include "config.h" +#include "time_manager.h" +#include +#include +#include +#include + +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(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(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; +} diff --git a/src/json_codec.cpp b/src/json_codec.cpp new file mode 100644 index 0000000..ce2ed7f --- /dev/null +++ b/src/json_codec.cpp @@ -0,0 +1,55 @@ +#include "json_codec.h" +#include +#include + +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(strtoul(suffix, nullptr, 16)); + } + + return true; +} diff --git a/src/lora_transport.cpp b/src/lora_transport.cpp new file mode 100644 index 0000000..201d393 --- /dev/null +++ b/src/lora_transport.cpp @@ -0,0 +1,102 @@ +#include "lora_transport.h" +#include +#include + +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(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(pkt.role); + buffer[idx++] = static_cast(pkt.device_id_short >> 8); + buffer[idx++] = static_cast(pkt.device_id_short & 0xFF); + buffer[idx++] = static_cast(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(crc >> 8); + buffer[idx++] = static_cast(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(buffer[len - 2] << 8) | buffer[len - 1]; + if (crc_calc != crc_rx) { + return false; + } + + pkt.protocol_version = buffer[0]; + pkt.role = static_cast(buffer[1]); + pkt.device_id_short = static_cast(buffer[2] << 8) | buffer[3]; + pkt.payload_type = static_cast(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); + } +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..0e40289 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,182 @@ +#include +#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(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(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(); + } +} diff --git a/src/meter_driver.cpp b/src/meter_driver.cpp new file mode 100644 index 0000000..78edd7b --- /dev/null +++ b/src/meter_driver.cpp @@ -0,0 +1,166 @@ +#include "meter_driver.h" +#include "config.h" +#include +#include + +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(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(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; +} diff --git a/src/mqtt_client.cpp b/src/mqtt_client.cpp new file mode 100644 index 0000000..1917393 --- /dev/null +++ b/src/mqtt_client.cpp @@ -0,0 +1,63 @@ +#include "mqtt_client.h" +#include +#include +#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 diff --git a/src/power_manager.cpp b/src/power_manager.cpp new file mode 100644 index 0000000..7dd204c --- /dev/null +++ b/src/power_manager.cpp @@ -0,0 +1,48 @@ +#include "power_manager.h" +#include "config.h" +#include +#include +#include +#include + +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(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(pct + 0.5f); +} + +void go_to_deep_sleep(uint32_t seconds) { + esp_sleep_enable_timer_wakeup(static_cast(seconds) * 1000000ULL); + esp_deep_sleep_start(); +} diff --git a/src/test_mode.cpp b/src/test_mode.cpp new file mode 100644 index 0000000..720dbdf --- /dev/null +++ b/src/test_mode.cpp @@ -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 + +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(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(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(decompressed))); +} +#endif diff --git a/src/time_manager.cpp b/src/time_manager.cpp new file mode 100644 index 0000000..a34f7d4 --- /dev/null +++ b/src/time_manager.cpp @@ -0,0 +1,103 @@ +#include "time_manager.h" +#include "compressor.h" +#include "config.h" +#include + +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(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(epoch)); + + uint8_t compressed[LORA_MAX_PAYLOAD]; + size_t compressed_len = 0; + if (!compressBuffer(reinterpret_cast(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(strtoul(reinterpret_cast(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); +} diff --git a/src/web_server.cpp b/src/web_server.cpp new file mode 100644 index 0000000..4a83854 --- /dev/null +++ b/src/web_server.cpp @@ -0,0 +1,155 @@ +#include "web_server.h" +#include +#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 = ""; + h += "" + title + ""; + h += "

" + title + "

"; + return h; +} + +static String html_footer() { + return ""; +} + +static String render_sender_block(const SenderStatus &status) { + String s; + s += "
"; + s += "" + String(status.last_data.device_id) + "
"; + if (!status.has_data) { + s += "No data"; + } else { + s += "Energy: " + String(status.last_data.energy_total_kwh, 3) + " kWh
"; + s += "Power: " + String(status.last_data.total_power_w, 1) + " W
"; + s += "Battery: " + String(status.last_data.battery_voltage_v, 2) + " V (" + String(status.last_data.battery_percent) + ")"; + } + s += "
"; + return s; +} + +static void handle_root() { + String html = html_header("DD3 Bridge Status"); + html += g_is_ap ? "

Mode: AP

" : "

Mode: STA

"; + + if (g_statuses) { + for (uint8_t i = 0; i < g_status_count; ++i) { + html += render_sender_block(g_statuses[i]); + } + } + + html += "

Configure WiFi/MQTT/NTP

"; + html += html_footer(); + server.send(200, "text/html", html); +} + +static void handle_wifi_get() { + String html = html_header("WiFi/MQTT Config"); + html += "
"; + html += "SSID:
"; + html += "Password:
"; + html += "MQTT Host:
"; + html += "MQTT Port:
"; + html += "MQTT User:
"; + html += "MQTT Pass:
"; + html += "NTP Server 1:
"; + html += "NTP Server 2:
"; + html += ""; + html += "
"; + 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(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", "Saved. Rebooting..."); + 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(); +} diff --git a/src/wifi_manager.cpp b/src/wifi_manager.cpp new file mode 100644 index 0000000..d6bd162 --- /dev/null +++ b/src/wifi_manager.cpp @@ -0,0 +1,60 @@ +#include "wifi_manager.h" +#include + +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(); +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -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