Compare commits
5 Commits
v1.0.0-202
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9ae94a0ff | ||
|
|
8d4dd4965d | ||
|
|
339d87bf86 | ||
|
|
40c9633238 | ||
|
|
545c15b3f2 |
21
.gitignore
vendored
21
.gitignore
vendored
@@ -1,19 +1,2 @@
|
||||
.direnv
|
||||
.pio
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
out/
|
||||
.direnv/
|
||||
.DS_Store
|
||||
.vscode/settings.json
|
||||
.vscode/extensions.json
|
||||
.idea
|
||||
cmake-*
|
||||
.cache
|
||||
.ccls
|
||||
compile_commands.json
|
||||
.venv/
|
||||
venv/
|
||||
platformio.local.ini
|
||||
*.uf2
|
||||
.pio/
|
||||
|
||||
70
README.md
70
README.md
@@ -1,6 +1,12 @@
|
||||
## MeshCore Simple Sensor — Heltec T114 + BME280
|
||||
## MeshCore Firmware for Heltec T114
|
||||
|
||||
Прошивка **Simple Sensor** на базе [MeshCore](https://github.com/meshcore-dev/MeshCore) для платы **Heltec T114** (без экрана) с датчиком **BME280** (температура, влажность, атмосферное давление).
|
||||
Прошивки на базе [MeshCore](https://github.com/meshcore-dev/MeshCore) для платы **Heltec T114**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Simple Sensor — Heltec T114 + BME280
|
||||
|
||||
Прошивка **Simple Sensor** для платы **Heltec T114** (без экрана) с датчиком **BME280** (температура, влажность, атмосферное давление).
|
||||
|
||||
### 🎯 Назначение
|
||||
|
||||
@@ -64,6 +70,66 @@ pio run -e Heltec_t114_without_display_simple_sensor -t create_uf2
|
||||
|
||||
Другие устройства mesh-сети могут подключаться к сенсору и запрашивать телеметрию по протоколу MeshCore.
|
||||
|
||||
---
|
||||
|
||||
## 2. Beacon Sensor (Auto-Announce) — Heltec T114 + BMP280
|
||||
|
||||
Прошивка **Beacon Sensor** — устройство автоматически отправляет flood-объявление (advert) в mesh-сеть **каждые 15 минут**. Подключён **BMP280** по I2C, телеметрия доступна по запросу. Подключается к Companion-клиентам через **BLE**.
|
||||
|
||||
### 📻 Параметры радиоканала
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Частота | 868.731018 МГц |
|
||||
| Полоса (BW) | 62.5 кГц |
|
||||
| Spreading Factor | 7 |
|
||||
| Coding Rate | 7 |
|
||||
| Хэш пути | 1 байт |
|
||||
| Мощность | 22 dBm |
|
||||
|
||||
### 🧩 Поддерживаемые датчики (I2C на Wire1, SDA=7, SCL=8)
|
||||
|
||||
- **BMP280** — температура, давление
|
||||
- Все датчики из списка Simple Sensor
|
||||
|
||||
### 🔧 Сборка
|
||||
|
||||
```bash
|
||||
# Без дисплея, через BLE (рекомендуемый вариант)
|
||||
pio run -e Heltec_t114_without_display_beacon_sensor_ble -t create_uf2
|
||||
|
||||
# С дисплеем, через BLE
|
||||
pio run -e Heltec_t114_beacon_sensor_ble -t create_uf2
|
||||
|
||||
# Без дисплея, через USB (Serial)
|
||||
pio run -e Heltec_t114_without_display_beacon_sensor_usb -t create_uf2
|
||||
|
||||
# С дисплеем, через USB (Serial)
|
||||
pio run -e Heltec_t114_beacon_sensor_usb -t create_uf2
|
||||
```
|
||||
|
||||
Файл прошивки: `.pio/build/<env>/firmware.uf2`
|
||||
|
||||
### 🚀 Прошивка (UF2)
|
||||
|
||||
1. Зажми кнопку **BOOT/PRG** на T114
|
||||
2. Подключи USB к компьютеру
|
||||
3. Отпусти кнопку — появится диск `T114`
|
||||
4. Перетащи `firmware.uf2` на этот диск
|
||||
5. Устройство перезагрузится и начнёт работу
|
||||
|
||||
Или через nrfutil:
|
||||
```bash
|
||||
nrfutil dfu usb-serial -pkg .pio/build/<env>/firmware.zip -p /dev/ttyACM0
|
||||
```
|
||||
|
||||
### 📡 Использование
|
||||
|
||||
- Устройство автоматически отправляет объявление (advert) в mesh-сеть каждые 15 минут
|
||||
- Объявление распространяется flood'ом (ретранслируется другими узлами)
|
||||
- Подключись через **BLE** (пин: `123456`) к мобильному приложению MeshCore
|
||||
- BMP280 телеметрия доступна по запросу через протокол MeshCore
|
||||
|
||||
### 🔗 Совместимость
|
||||
|
||||
- Работает с Companion Radio (BLE/USB/WiFi)
|
||||
|
||||
46
examples/beacon_sensor/AbstractUITask.h
Normal file
46
examples/beacon_sensor/AbstractUITask.h
Normal file
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <MeshCore.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/BaseSerialInterface.h>
|
||||
#include <Arduino.h>
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
#include <helpers/ui/buzzer.h>
|
||||
#endif
|
||||
|
||||
#include "NodePrefs.h"
|
||||
|
||||
enum class UIEventType {
|
||||
none,
|
||||
contactMessage,
|
||||
channelMessage,
|
||||
roomMessage,
|
||||
newContactMessage,
|
||||
ack
|
||||
};
|
||||
|
||||
class AbstractUITask {
|
||||
protected:
|
||||
mesh::MainBoard* _board;
|
||||
BaseSerialInterface* _serial;
|
||||
bool _connected;
|
||||
|
||||
AbstractUITask(mesh::MainBoard* board, BaseSerialInterface* serial) : _board(board), _serial(serial) {
|
||||
_connected = false;
|
||||
}
|
||||
|
||||
public:
|
||||
void setHasConnection(bool connected) { _connected = connected; }
|
||||
bool hasConnection() const { return _connected; }
|
||||
uint16_t getBattMilliVolts() const { return _board->getBattMilliVolts(); }
|
||||
bool isSerialEnabled() const { return _serial->isEnabled(); }
|
||||
void enableSerial() { _serial->enable(); }
|
||||
void disableSerial() { _serial->disable(); }
|
||||
virtual void msgRead(int msgcount) = 0;
|
||||
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0;
|
||||
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||
virtual void loop() = 0;
|
||||
};
|
||||
622
examples/beacon_sensor/DataStore.cpp
Normal file
622
examples/beacon_sensor/DataStore.cpp
Normal file
@@ -0,0 +1,622 @@
|
||||
#include <Arduino.h>
|
||||
#include "DataStore.h"
|
||||
|
||||
#if defined(EXTRAFS) || defined(QSPIFLASH)
|
||||
#define MAX_BLOBRECS 100
|
||||
#else
|
||||
#define MAX_BLOBRECS 20
|
||||
#endif
|
||||
|
||||
DataStore::DataStore(FILESYSTEM& fs, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(nullptr), _clock(&clock),
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
identity_store(fs, "")
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
identity_store(fs, "/identity")
|
||||
#else
|
||||
identity_store(fs, "/identity")
|
||||
#endif
|
||||
{
|
||||
}
|
||||
|
||||
#if defined(EXTRAFS) || defined(QSPIFLASH)
|
||||
DataStore::DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock) : _fs(&fs), _fsExtra(&fsExtra), _clock(&clock),
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
identity_store(fs, "")
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
identity_store(fs, "/identity")
|
||||
#else
|
||||
identity_store(fs, "/identity")
|
||||
#endif
|
||||
{
|
||||
}
|
||||
#endif
|
||||
|
||||
static File openWrite(FILESYSTEM* fs, const char* filename) {
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
fs->remove(filename);
|
||||
return fs->open(filename, FILE_O_WRITE);
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
return fs->open(filename, "w");
|
||||
#else
|
||||
return fs->open(filename, "w", true);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
static uint32_t _ContactsChannelsTotalBlocks = 0;
|
||||
#endif
|
||||
|
||||
void DataStore::begin() {
|
||||
#if defined(RP2040_PLATFORM)
|
||||
identity_store.begin();
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
_ContactsChannelsTotalBlocks = _getContactsChannelsFS()->_getFS()->cfg->block_count;
|
||||
checkAdvBlobFile();
|
||||
#if defined(EXTRAFS) || defined(QSPIFLASH)
|
||||
migrateToSecondaryFS();
|
||||
#endif
|
||||
#else
|
||||
// init 'blob store' support
|
||||
_fs->mkdir("/bl");
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(ESP32)
|
||||
#include <SPIFFS.h>
|
||||
#include <nvs_flash.h>
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
#include <LittleFS.h>
|
||||
#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
#if defined(QSPIFLASH)
|
||||
#include <CustomLFS_QSPIFlash.h>
|
||||
#elif defined(EXTRAFS)
|
||||
#include <CustomLFS.h>
|
||||
#else
|
||||
#include <InternalFileSystem.h>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
int _countLfsBlock(void *p, lfs_block_t block){
|
||||
if (block > _ContactsChannelsTotalBlocks) {
|
||||
MESH_DEBUG_PRINTLN("ERROR: Block %d exceeds filesystem bounds - CORRUPTION DETECTED!", block);
|
||||
return LFS_ERR_CORRUPT; // return error to abort lfs_traverse() gracefully
|
||||
}
|
||||
lfs_size_t *size = (lfs_size_t*) p;
|
||||
*size += 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
lfs_ssize_t _getLfsUsedBlockCount(FILESYSTEM* fs) {
|
||||
lfs_size_t size = 0;
|
||||
int err = lfs_traverse(fs->_getFS(), _countLfsBlock, &size);
|
||||
if (err) {
|
||||
MESH_DEBUG_PRINTLN("ERROR: lfs_traverse() error: %d", err);
|
||||
return 0;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
|
||||
uint32_t DataStore::getStorageUsedKb() const {
|
||||
#if defined(ESP32)
|
||||
return SPIFFS.usedBytes() / 1024;
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
FSInfo info;
|
||||
info.usedBytes = 0;
|
||||
_fs->info(info);
|
||||
return info.usedBytes / 1024;
|
||||
#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
const lfs_config* config = _getContactsChannelsFS()->_getFS()->cfg;
|
||||
int usedBlockCount = _getLfsUsedBlockCount(_getContactsChannelsFS());
|
||||
int usedBytes = config->block_size * usedBlockCount;
|
||||
return usedBytes / 1024;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint32_t DataStore::getStorageTotalKb() const {
|
||||
#if defined(ESP32)
|
||||
return SPIFFS.totalBytes() / 1024;
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
FSInfo info;
|
||||
info.totalBytes = 0;
|
||||
_fs->info(info);
|
||||
return info.totalBytes / 1024;
|
||||
#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
const lfs_config* config = _getContactsChannelsFS()->_getFS()->cfg;
|
||||
int totalBytes = config->block_size * config->block_count;
|
||||
return totalBytes / 1024;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
File DataStore::openRead(const char* filename) {
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
return _fs->open(filename, FILE_O_READ);
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
return _fs->open(filename, "r");
|
||||
#else
|
||||
return _fs->open(filename, "r", false);
|
||||
#endif
|
||||
}
|
||||
|
||||
File DataStore::openRead(FILESYSTEM* fs, const char* filename) {
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
return fs->open(filename, FILE_O_READ);
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
return fs->open(filename, "r");
|
||||
#else
|
||||
return fs->open(filename, "r", false);
|
||||
#endif
|
||||
}
|
||||
|
||||
bool DataStore::removeFile(const char* filename) {
|
||||
return _fs->remove(filename);
|
||||
}
|
||||
|
||||
bool DataStore::removeFile(FILESYSTEM* fs, const char* filename) {
|
||||
return fs->remove(filename);
|
||||
}
|
||||
|
||||
bool DataStore::formatFileSystem() {
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
if (_fsExtra == nullptr) {
|
||||
return _fs->format();
|
||||
} else {
|
||||
return _fs->format() && _fsExtra->format();
|
||||
}
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
return LittleFS.format();
|
||||
#elif defined(ESP32)
|
||||
bool fs_success = ((fs::SPIFFSFS *)_fs)->format();
|
||||
esp_err_t nvs_err = nvs_flash_erase(); // no need to reinit, will be done by reboot
|
||||
return fs_success && (nvs_err == ESP_OK);
|
||||
#else
|
||||
#error "need to implement format()"
|
||||
#endif
|
||||
}
|
||||
|
||||
bool DataStore::loadMainIdentity(mesh::LocalIdentity &identity) {
|
||||
return identity_store.load("_main", identity);
|
||||
}
|
||||
|
||||
bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) {
|
||||
return identity_store.save("_main", identity);
|
||||
}
|
||||
|
||||
void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) {
|
||||
if (_fs->exists("/new_prefs")) {
|
||||
loadPrefsInt("/new_prefs", prefs, node_lat, node_lon); // new filename
|
||||
} else if (_fs->exists("/node_prefs")) {
|
||||
loadPrefsInt("/node_prefs", prefs, node_lat, node_lon);
|
||||
savePrefs(prefs, node_lat, node_lon); // save to new filename
|
||||
_fs->remove("/node_prefs"); // remove old
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& node_lat, double& node_lon) {
|
||||
File file = openRead(_fs, filename);
|
||||
if (file) {
|
||||
uint8_t pad[8];
|
||||
|
||||
file.read((uint8_t *)&_prefs.airtime_factor, sizeof(float)); // 0
|
||||
file.read((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); // 4
|
||||
file.read(pad, 4); // 36
|
||||
file.read((uint8_t *)&node_lat, sizeof(node_lat)); // 40
|
||||
file.read((uint8_t *)&node_lon, sizeof(node_lon)); // 48
|
||||
file.read((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); // 56
|
||||
file.read((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); // 60
|
||||
file.read((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); // 61
|
||||
file.read((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); // 62
|
||||
file.read((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); // 63
|
||||
file.read((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); // 64
|
||||
file.read((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); // 68
|
||||
file.read((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); // 69
|
||||
file.read((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); // 70
|
||||
file.read((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); // 71
|
||||
file.read((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); // 72
|
||||
file.read((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); // 76
|
||||
file.read((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); // 77
|
||||
file.read((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 78
|
||||
file.read(pad, 1); // 79
|
||||
file.read((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); // 80
|
||||
file.read((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); // 84
|
||||
file.read((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85
|
||||
file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
|
||||
file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
|
||||
file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 88
|
||||
file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89
|
||||
file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90
|
||||
file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121
|
||||
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {
|
||||
File file = openWrite(_fs, "/new_prefs");
|
||||
if (file) {
|
||||
uint8_t pad[8];
|
||||
memset(pad, 0, sizeof(pad));
|
||||
|
||||
file.write((uint8_t *)&_prefs.airtime_factor, sizeof(float)); // 0
|
||||
file.write((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); // 4
|
||||
file.write(pad, 4); // 36
|
||||
file.write((uint8_t *)&node_lat, sizeof(node_lat)); // 40
|
||||
file.write((uint8_t *)&node_lon, sizeof(node_lon)); // 48
|
||||
file.write((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); // 56
|
||||
file.write((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); // 60
|
||||
file.write((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); // 61
|
||||
file.write((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); // 62
|
||||
file.write((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); // 63
|
||||
file.write((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); // 64
|
||||
file.write((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); // 68
|
||||
file.write((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); // 69
|
||||
file.write((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); // 70
|
||||
file.write((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); // 71
|
||||
file.write((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); // 72
|
||||
file.write((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); // 76
|
||||
file.write((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); // 77
|
||||
file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 78
|
||||
file.write(pad, 1); // 79
|
||||
file.write((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); // 80
|
||||
file.write((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); // 84
|
||||
file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85
|
||||
file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
|
||||
file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
|
||||
file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 88
|
||||
file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89
|
||||
file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90
|
||||
file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121
|
||||
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::loadContacts(DataStoreHost* host) {
|
||||
File file = openRead(_getContactsChannelsFS(), "/contacts3");
|
||||
if (file) {
|
||||
bool full = false;
|
||||
while (!full) {
|
||||
ContactInfo c;
|
||||
uint8_t pub_key[32];
|
||||
uint8_t unused;
|
||||
|
||||
bool success = (file.read(pub_key, 32) == 32);
|
||||
success = success && (file.read((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (file.read(&c.type, 1) == 1);
|
||||
success = success && (file.read(&c.flags, 1) == 1);
|
||||
success = success && (file.read(&unused, 1) == 1);
|
||||
success = success && (file.read((uint8_t *)&c.sync_since, 4) == 4); // was 'reserved'
|
||||
success = success && (file.read((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.read((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (file.read(c.out_path, 64) == 64);
|
||||
success = success && (file.read((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (file.read((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (file.read((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
|
||||
if (!success) break; // EOF
|
||||
|
||||
c.id = mesh::Identity(pub_key);
|
||||
if (!host->onContactLoaded(c)) full = true;
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::saveContacts(DataStoreHost* host) {
|
||||
File file = openWrite(_getContactsChannelsFS(), "/contacts3");
|
||||
if (file) {
|
||||
uint32_t idx = 0;
|
||||
ContactInfo c;
|
||||
uint8_t unused = 0;
|
||||
|
||||
while (host->getContactForSave(idx, c)) {
|
||||
bool success = (file.write(c.id.pub_key, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)&c.name, 32) == 32);
|
||||
success = success && (file.write(&c.type, 1) == 1);
|
||||
success = success && (file.write(&c.flags, 1) == 1);
|
||||
success = success && (file.write(&unused, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.sync_since, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.out_path_len, 1) == 1);
|
||||
success = success && (file.write((uint8_t *)&c.last_advert_timestamp, 4) == 4);
|
||||
success = success && (file.write(c.out_path, 64) == 64);
|
||||
success = success && (file.write((uint8_t *)&c.lastmod, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lat, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)&c.gps_lon, 4) == 4);
|
||||
|
||||
if (!success) break; // write failed
|
||||
|
||||
idx++; // advance to next contact
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::loadChannels(DataStoreHost* host) {
|
||||
File file = openRead(_getContactsChannelsFS(), "/channels2");
|
||||
if (file) {
|
||||
bool full = false;
|
||||
uint8_t channel_idx = 0;
|
||||
while (!full) {
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
|
||||
bool success = (file.read(unused, 4) == 4);
|
||||
success = success && (file.read((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.read((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
|
||||
if (!success) break; // EOF
|
||||
|
||||
if (host->onChannelLoaded(channel_idx, ch)) {
|
||||
channel_idx++;
|
||||
} else {
|
||||
full = true;
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::saveChannels(DataStoreHost* host) {
|
||||
File file = openWrite(_getContactsChannelsFS(), "/channels2");
|
||||
if (file) {
|
||||
uint8_t channel_idx = 0;
|
||||
ChannelDetails ch;
|
||||
uint8_t unused[4];
|
||||
memset(unused, 0, 4);
|
||||
|
||||
while (host->getChannelForSave(channel_idx, ch)) {
|
||||
bool success = (file.write(unused, 4) == 4);
|
||||
success = success && (file.write((uint8_t *)ch.name, 32) == 32);
|
||||
success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32);
|
||||
|
||||
if (!success) break; // write failed
|
||||
channel_idx++;
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
|
||||
#define MAX_ADVERT_PKT_LEN (2 + 32 + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE)
|
||||
|
||||
struct BlobRec {
|
||||
uint32_t timestamp;
|
||||
uint8_t key[7];
|
||||
uint8_t len;
|
||||
uint8_t data[MAX_ADVERT_PKT_LEN];
|
||||
};
|
||||
|
||||
void DataStore::checkAdvBlobFile() {
|
||||
if (!_getContactsChannelsFS()->exists("/adv_blobs")) {
|
||||
File file = openWrite(_getContactsChannelsFS(), "/adv_blobs");
|
||||
if (file) {
|
||||
BlobRec zeroes;
|
||||
memset(&zeroes, 0, sizeof(zeroes));
|
||||
for (int i = 0; i < MAX_BLOBRECS; i++) { // pre-allocate to fixed size
|
||||
file.write((uint8_t *) &zeroes, sizeof(zeroes));
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DataStore::migrateToSecondaryFS() {
|
||||
// migrate old adv_blobs, contacts3 and channels2 files to secondary FS if they don't already exist
|
||||
if (!_fsExtra->exists("/adv_blobs")) {
|
||||
if (_fs->exists("/adv_blobs")) {
|
||||
File oldAdvBlobs = openRead(_fs, "/adv_blobs");
|
||||
File newAdvBlobs = openWrite(_fsExtra, "/adv_blobs");
|
||||
|
||||
if (oldAdvBlobs && newAdvBlobs) {
|
||||
BlobRec rec;
|
||||
size_t count = 0;
|
||||
|
||||
// Copy 20 BlobRecs from old to new
|
||||
while (count < 20 && oldAdvBlobs.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) {
|
||||
newAdvBlobs.seek(count * sizeof(BlobRec));
|
||||
newAdvBlobs.write((uint8_t *)&rec, sizeof(rec));
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (oldAdvBlobs) oldAdvBlobs.close();
|
||||
if (newAdvBlobs) newAdvBlobs.close();
|
||||
_fs->remove("/adv_blobs");
|
||||
}
|
||||
}
|
||||
if (!_fsExtra->exists("/contacts3")) {
|
||||
if (_fs->exists("/contacts3")) {
|
||||
File oldFile = openRead(_fs, "/contacts3");
|
||||
File newFile = openWrite(_fsExtra, "/contacts3");
|
||||
|
||||
if (oldFile && newFile) {
|
||||
uint8_t buf[64];
|
||||
int n;
|
||||
while ((n = oldFile.read(buf, sizeof(buf))) > 0) {
|
||||
newFile.write(buf, n);
|
||||
}
|
||||
}
|
||||
if (oldFile) oldFile.close();
|
||||
if (newFile) newFile.close();
|
||||
_fs->remove("/contacts3");
|
||||
}
|
||||
}
|
||||
if (!_fsExtra->exists("/channels2")) {
|
||||
if (_fs->exists("/channels2")) {
|
||||
File oldFile = openRead(_fs, "/channels2");
|
||||
File newFile = openWrite(_fsExtra, "/channels2");
|
||||
|
||||
if (oldFile && newFile) {
|
||||
uint8_t buf[64];
|
||||
int n;
|
||||
while ((n = oldFile.read(buf, sizeof(buf))) > 0) {
|
||||
newFile.write(buf, n);
|
||||
}
|
||||
}
|
||||
if (oldFile) oldFile.close();
|
||||
if (newFile) newFile.close();
|
||||
_fs->remove("/channels2");
|
||||
}
|
||||
}
|
||||
// cleanup nodes which have been testing the extra fs, copy _main.id and new_prefs back to primary
|
||||
if (_fsExtra->exists("/_main.id")) {
|
||||
if (_fs->exists("/_main.id")) {_fs->remove("/_main.id");}
|
||||
File oldFile = openRead(_fsExtra, "/_main.id");
|
||||
File newFile = openWrite(_fs, "/_main.id");
|
||||
|
||||
if (oldFile && newFile) {
|
||||
uint8_t buf[64];
|
||||
int n;
|
||||
while ((n = oldFile.read(buf, sizeof(buf))) > 0) {
|
||||
newFile.write(buf, n);
|
||||
}
|
||||
}
|
||||
if (oldFile) oldFile.close();
|
||||
if (newFile) newFile.close();
|
||||
_fsExtra->remove("/_main.id");
|
||||
}
|
||||
if (_fsExtra->exists("/new_prefs")) {
|
||||
if (_fs->exists("/new_prefs")) {_fs->remove("/new_prefs");}
|
||||
File oldFile = openRead(_fsExtra, "/new_prefs");
|
||||
File newFile = openWrite(_fs, "/new_prefs");
|
||||
|
||||
if (oldFile && newFile) {
|
||||
uint8_t buf[64];
|
||||
int n;
|
||||
while ((n = oldFile.read(buf, sizeof(buf))) > 0) {
|
||||
newFile.write(buf, n);
|
||||
}
|
||||
}
|
||||
if (oldFile) oldFile.close();
|
||||
if (newFile) newFile.close();
|
||||
_fsExtra->remove("/new_prefs");
|
||||
}
|
||||
// remove files from where they should not be anymore
|
||||
if (_fs->exists("/adv_blobs")) {
|
||||
_fs->remove("/adv_blobs");
|
||||
}
|
||||
if (_fs->exists("/contacts3")) {
|
||||
_fs->remove("/contacts3");
|
||||
}
|
||||
if (_fs->exists("/channels2")) {
|
||||
_fs->remove("/channels2");
|
||||
}
|
||||
if (_fsExtra->exists("/_main.id")) {
|
||||
_fsExtra->remove("/_main.id");
|
||||
}
|
||||
if (_fsExtra->exists("/new_prefs")) {
|
||||
_fsExtra->remove("/new_prefs");
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) {
|
||||
File file = openRead(_getContactsChannelsFS(), "/adv_blobs");
|
||||
uint8_t len = 0; // 0 = not found
|
||||
if (file) {
|
||||
BlobRec tmp;
|
||||
while (file.read((uint8_t *) &tmp, sizeof(tmp)) == sizeof(tmp)) {
|
||||
if (memcmp(key, tmp.key, sizeof(tmp.key)) == 0) { // only match by 7 byte prefix
|
||||
len = tmp.len;
|
||||
memcpy(dest_buf, tmp.data, len);
|
||||
break;
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) {
|
||||
if (len < PUB_KEY_SIZE+4+SIGNATURE_SIZE || len > MAX_ADVERT_PKT_LEN) return false;
|
||||
checkAdvBlobFile();
|
||||
File file = _getContactsChannelsFS()->open("/adv_blobs", FILE_O_WRITE);
|
||||
if (file) {
|
||||
uint32_t pos = 0, found_pos = 0;
|
||||
uint32_t min_timestamp = 0xFFFFFFFF;
|
||||
|
||||
// search for matching key OR evict by oldest timestmap
|
||||
BlobRec tmp;
|
||||
file.seek(0);
|
||||
while (file.read((uint8_t *) &tmp, sizeof(tmp)) == sizeof(tmp)) {
|
||||
if (memcmp(key, tmp.key, sizeof(tmp.key)) == 0) { // only match by 7 byte prefix
|
||||
found_pos = pos;
|
||||
break;
|
||||
}
|
||||
if (tmp.timestamp < min_timestamp) {
|
||||
min_timestamp = tmp.timestamp;
|
||||
found_pos = pos;
|
||||
}
|
||||
|
||||
pos += sizeof(tmp);
|
||||
}
|
||||
|
||||
memcpy(tmp.key, key, sizeof(tmp.key)); // just record 7 byte prefix of key
|
||||
memcpy(tmp.data, src_buf, len);
|
||||
tmp.len = len;
|
||||
tmp.timestamp = _clock->getCurrentTime();
|
||||
|
||||
file.seek(found_pos);
|
||||
file.write((uint8_t *) &tmp, sizeof(tmp));
|
||||
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
return false; // error
|
||||
}
|
||||
bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) {
|
||||
return true; // this is just a stub on NRF52/STM32 platforms
|
||||
}
|
||||
#else
|
||||
inline void makeBlobPath(const uint8_t key[], int key_len, char* path, size_t path_size) {
|
||||
char fname[18];
|
||||
if (key_len > 8) key_len = 8; // just use first 8 bytes (prefix)
|
||||
mesh::Utils::toHex(fname, key, key_len);
|
||||
sprintf(path, "/bl/%s", fname);
|
||||
}
|
||||
|
||||
uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) {
|
||||
char path[64];
|
||||
makeBlobPath(key, key_len, path, sizeof(path));
|
||||
|
||||
if (_fs->exists(path)) {
|
||||
File f = openRead(_fs, path);
|
||||
if (f) {
|
||||
int len = f.read(dest_buf, 255); // currently MAX 255 byte blob len supported!!
|
||||
f.close();
|
||||
return len;
|
||||
}
|
||||
}
|
||||
return 0; // not found
|
||||
}
|
||||
|
||||
bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) {
|
||||
char path[64];
|
||||
makeBlobPath(key, key_len, path, sizeof(path));
|
||||
|
||||
File f = openWrite(_fs, path);
|
||||
if (f) {
|
||||
int n = f.write(src_buf, len);
|
||||
f.close();
|
||||
if (n == len) return true; // success!
|
||||
|
||||
_fs->remove(path); // blob was only partially written!
|
||||
}
|
||||
return false; // error
|
||||
}
|
||||
|
||||
bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) {
|
||||
char path[64];
|
||||
makeBlobPath(key, key_len, path, sizeof(path));
|
||||
|
||||
_fs->remove(path);
|
||||
|
||||
return true; // return true even if file did not exist
|
||||
}
|
||||
#endif
|
||||
55
examples/beacon_sensor/DataStore.h
Normal file
55
examples/beacon_sensor/DataStore.h
Normal file
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/IdentityStore.h>
|
||||
#include <helpers/ContactInfo.h>
|
||||
#include <helpers/ChannelDetails.h>
|
||||
#include "NodePrefs.h"
|
||||
|
||||
class DataStoreHost {
|
||||
public:
|
||||
virtual bool onContactLoaded(const ContactInfo& contact) =0;
|
||||
virtual bool getContactForSave(uint32_t idx, ContactInfo& contact) =0;
|
||||
virtual bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) =0;
|
||||
virtual bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) =0;
|
||||
};
|
||||
|
||||
class DataStore {
|
||||
FILESYSTEM* _fs;
|
||||
FILESYSTEM* _fsExtra;
|
||||
mesh::RTCClock* _clock;
|
||||
IdentityStore identity_store;
|
||||
|
||||
void loadPrefsInt(const char *filename, NodePrefs& prefs, double& node_lat, double& node_lon);
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
void checkAdvBlobFile();
|
||||
#endif
|
||||
|
||||
public:
|
||||
DataStore(FILESYSTEM& fs, mesh::RTCClock& clock);
|
||||
DataStore(FILESYSTEM& fs, FILESYSTEM& fsExtra, mesh::RTCClock& clock);
|
||||
void begin();
|
||||
bool formatFileSystem();
|
||||
FILESYSTEM* getPrimaryFS() const { return _fs; }
|
||||
FILESYSTEM* getSecondaryFS() const { return _fsExtra; }
|
||||
bool loadMainIdentity(mesh::LocalIdentity &identity);
|
||||
bool saveMainIdentity(const mesh::LocalIdentity &identity);
|
||||
void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon);
|
||||
void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
|
||||
void loadContacts(DataStoreHost* host);
|
||||
void saveContacts(DataStoreHost* host);
|
||||
void loadChannels(DataStoreHost* host);
|
||||
void saveChannels(DataStoreHost* host);
|
||||
void migrateToSecondaryFS();
|
||||
uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]);
|
||||
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len);
|
||||
bool deleteBlobByKey(const uint8_t key[], int key_len);
|
||||
File openRead(const char* filename);
|
||||
File openRead(FILESYSTEM* fs, const char* filename);
|
||||
bool removeFile(const char* filename);
|
||||
bool removeFile(FILESYSTEM* fs, const char* filename);
|
||||
uint32_t getStorageUsedKb() const;
|
||||
uint32_t getStorageTotalKb() const;
|
||||
|
||||
private:
|
||||
FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;};
|
||||
};
|
||||
2218
examples/beacon_sensor/MyMesh.cpp
Normal file
2218
examples/beacon_sensor/MyMesh.cpp
Normal file
File diff suppressed because it is too large
Load Diff
259
examples/beacon_sensor/MyMesh.h
Normal file
259
examples/beacon_sensor/MyMesh.h
Normal file
@@ -0,0 +1,259 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Mesh.h>
|
||||
#include "AbstractUITask.h"
|
||||
|
||||
/*------------ Frame Protocol --------------*/
|
||||
#define FIRMWARE_VER_CODE 12
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "05 Jun 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "v1.0.0-beacon"
|
||||
#endif
|
||||
|
||||
#ifndef BEACON_INTERVAL_MS
|
||||
#define BEACON_INTERVAL_MS 900000
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
#include <InternalFileSystem.h>
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
#include <LittleFS.h>
|
||||
#elif defined(ESP32)
|
||||
#include <SPIFFS.h>
|
||||
#endif
|
||||
|
||||
#include "DataStore.h"
|
||||
#include "NodePrefs.h"
|
||||
|
||||
#include <RTClib.h>
|
||||
#include <helpers/ArduinoHelpers.h>
|
||||
#include <helpers/BaseSerialInterface.h>
|
||||
#include <helpers/IdentityStore.h>
|
||||
#include <helpers/SimpleMeshTables.h>
|
||||
#include <helpers/StaticPoolPacketManager.h>
|
||||
#include <target.h>
|
||||
|
||||
/* ---------------------------------- CONFIGURATION ------------------------------------- */
|
||||
|
||||
#ifndef LORA_FREQ
|
||||
#define LORA_FREQ 915.0
|
||||
#endif
|
||||
#ifndef LORA_BW
|
||||
#define LORA_BW 250
|
||||
#endif
|
||||
#ifndef LORA_SF
|
||||
#define LORA_SF 10
|
||||
#endif
|
||||
#ifndef LORA_CR
|
||||
#define LORA_CR 5
|
||||
#endif
|
||||
#ifndef LORA_TX_POWER
|
||||
#define LORA_TX_POWER 20
|
||||
#endif
|
||||
#ifndef MAX_LORA_TX_POWER
|
||||
#define MAX_LORA_TX_POWER LORA_TX_POWER
|
||||
#endif
|
||||
|
||||
#ifndef MAX_CONTACTS
|
||||
#define MAX_CONTACTS 100
|
||||
#endif
|
||||
|
||||
#ifndef OFFLINE_QUEUE_SIZE
|
||||
#define OFFLINE_QUEUE_SIZE 16
|
||||
#endif
|
||||
|
||||
#ifndef BLE_NAME_PREFIX
|
||||
#define BLE_NAME_PREFIX "MeshCore-"
|
||||
#endif
|
||||
|
||||
#include <helpers/BaseChatMesh.h>
|
||||
#include <helpers/TransportKeyStore.h>
|
||||
|
||||
/* -------------------------------------------------------------------------------------- */
|
||||
|
||||
#define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS
|
||||
#define REQ_TYPE_KEEP_ALIVE 0x02
|
||||
#define REQ_TYPE_GET_TELEMETRY_DATA 0x03
|
||||
|
||||
struct AdvertPath {
|
||||
uint8_t pubkey_prefix[7];
|
||||
uint8_t path_len;
|
||||
char name[32];
|
||||
uint32_t recv_timestamp;
|
||||
uint8_t path[MAX_PATH_SIZE];
|
||||
};
|
||||
|
||||
class MyMesh : public BaseChatMesh, public DataStoreHost {
|
||||
public:
|
||||
MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL);
|
||||
|
||||
void begin(bool has_display);
|
||||
void startInterface(BaseSerialInterface &serial);
|
||||
|
||||
const char *getNodeName();
|
||||
NodePrefs *getNodePrefs();
|
||||
uint32_t getBLEPin();
|
||||
|
||||
void loop();
|
||||
void handleCmdFrame(size_t len);
|
||||
bool advert();
|
||||
void sendBeacon();
|
||||
void enterCLIRescue();
|
||||
|
||||
int getRecentlyHeard(AdvertPath dest[], int max_num);
|
||||
|
||||
protected:
|
||||
float getAirtimeBudgetFactor() const override;
|
||||
int getInterferenceThreshold() const override;
|
||||
int calcRxDelay(float score, uint32_t air_time) const override;
|
||||
uint32_t getRetransmitDelay(const mesh::Packet *packet) override;
|
||||
uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override;
|
||||
uint8_t getExtraAckTransmitCount() const override;
|
||||
bool filterRecvFloodPacket(mesh::Packet* packet) override;
|
||||
bool allowPacketForward(const mesh::Packet* packet) override;
|
||||
|
||||
void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
|
||||
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
|
||||
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
|
||||
bool isAutoAddEnabled() const override;
|
||||
bool shouldAutoAddContactType(uint8_t type) const override;
|
||||
bool shouldOverwriteWhenFull() const override;
|
||||
uint8_t getAutoAddMaxHops() const override;
|
||||
void onContactsFull() override;
|
||||
void onContactOverwrite(const uint8_t* pub_key) override;
|
||||
bool onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override;
|
||||
void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) override;
|
||||
void onContactPathUpdated(const ContactInfo &contact) override;
|
||||
ContactInfo* processAck(const uint8_t *data) override;
|
||||
void queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
const uint8_t *extra, int extra_len, const char *text);
|
||||
|
||||
void onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
const char *text) override;
|
||||
void onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
const char *text) override;
|
||||
void onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
const uint8_t *sender_prefix, const char *text) override;
|
||||
void onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
|
||||
const char *text) override;
|
||||
void onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type,
|
||||
const uint8_t *data, size_t data_len) override;
|
||||
|
||||
uint8_t onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
|
||||
uint8_t len, uint8_t *reply) override;
|
||||
void onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) override;
|
||||
void onControlDataRecv(mesh::Packet *packet) override;
|
||||
void onRawDataRecv(mesh::Packet *packet) override;
|
||||
void onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags,
|
||||
const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) override;
|
||||
|
||||
uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const override;
|
||||
uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const override;
|
||||
void onSendTimeout() override;
|
||||
|
||||
// DataStoreHost methods
|
||||
bool onContactLoaded(const ContactInfo& contact) override { return addContact(contact); }
|
||||
bool getContactForSave(uint32_t idx, ContactInfo& contact) override { return getContactByIdx(idx, contact); }
|
||||
bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) override { return setChannel(channel_idx, ch); }
|
||||
bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); }
|
||||
|
||||
void clearPendingReqs() {
|
||||
pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0;
|
||||
}
|
||||
|
||||
public:
|
||||
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
|
||||
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
void applyGpsPrefs() {
|
||||
sensors.setSettingValue("gps", _prefs.gps_enabled ? "1" : "0");
|
||||
if (_prefs.gps_interval > 0) {
|
||||
char interval_str[12]; // Max: 24 hours = 86400 seconds (5 digits + null)
|
||||
sprintf(interval_str, "%u", _prefs.gps_interval);
|
||||
sensors.setSettingValue("gps_interval", interval_str);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private:
|
||||
void writeOKFrame();
|
||||
void writeErrFrame(uint8_t err_code);
|
||||
void writeDisabledFrame();
|
||||
void writeContactRespFrame(uint8_t code, const ContactInfo &contact);
|
||||
void updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len);
|
||||
void addToOfflineQueue(const uint8_t frame[], int len);
|
||||
int getFromOfflineQueue(uint8_t frame[]);
|
||||
int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override {
|
||||
return _store->getBlobByKey(key, key_len, dest_buf);
|
||||
}
|
||||
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) override {
|
||||
return _store->putBlobByKey(key, key_len, src_buf, len);
|
||||
}
|
||||
|
||||
void checkCLIRescueCmd();
|
||||
void checkSerialInterface();
|
||||
bool isValidClientRepeatFreq(uint32_t f) const;
|
||||
|
||||
// helpers, short-cuts
|
||||
void saveChannels() { _store->saveChannels(this); }
|
||||
void saveContacts() { _store->saveContacts(this); }
|
||||
|
||||
DataStore* _store;
|
||||
NodePrefs _prefs;
|
||||
uint32_t pending_login;
|
||||
uint32_t pending_status;
|
||||
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
|
||||
uint32_t pending_req; // pending _BINARY_REQ
|
||||
BaseSerialInterface *_serial;
|
||||
AbstractUITask* _ui;
|
||||
|
||||
ContactsIterator _iter;
|
||||
uint32_t _iter_filter_since;
|
||||
uint32_t _most_recent_lastmod;
|
||||
uint32_t _active_ble_pin;
|
||||
bool _iter_started;
|
||||
bool _cli_rescue;
|
||||
char cli_command[80];
|
||||
uint8_t app_target_ver;
|
||||
uint8_t *sign_data;
|
||||
uint32_t sign_data_len;
|
||||
unsigned long dirty_contacts_expiry;
|
||||
|
||||
TransportKey send_scope;
|
||||
|
||||
unsigned long _last_beacon_ms;
|
||||
|
||||
uint8_t cmd_frame[MAX_FRAME_SIZE + 1];
|
||||
uint8_t out_frame[MAX_FRAME_SIZE + 1];
|
||||
CayenneLPP telemetry;
|
||||
|
||||
struct Frame {
|
||||
uint8_t len;
|
||||
uint8_t buf[MAX_FRAME_SIZE];
|
||||
|
||||
bool isChannelMsg() const;
|
||||
};
|
||||
int offline_queue_len;
|
||||
Frame offline_queue[OFFLINE_QUEUE_SIZE];
|
||||
|
||||
struct AckTableEntry {
|
||||
unsigned long msg_sent;
|
||||
uint32_t ack;
|
||||
ContactInfo* contact;
|
||||
};
|
||||
#define EXPECTED_ACK_TABLE_SIZE 8
|
||||
AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table
|
||||
int next_ack_idx;
|
||||
|
||||
#define ADVERT_PATH_TABLE_SIZE 16
|
||||
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
|
||||
};
|
||||
|
||||
extern MyMesh the_mesh;
|
||||
37
examples/beacon_sensor/NodePrefs.h
Normal file
37
examples/beacon_sensor/NodePrefs.h
Normal file
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
#include <cstdint> // For uint8_t, uint32_t
|
||||
|
||||
#define TELEM_MODE_DENY 0
|
||||
#define TELEM_MODE_ALLOW_FLAGS 1 // use contact.flags
|
||||
#define TELEM_MODE_ALLOW_ALL 2
|
||||
|
||||
#define ADVERT_LOC_NONE 0
|
||||
#define ADVERT_LOC_SHARE 1
|
||||
|
||||
struct NodePrefs { // persisted to file
|
||||
float airtime_factor;
|
||||
char node_name[32];
|
||||
float freq;
|
||||
uint8_t sf;
|
||||
uint8_t cr;
|
||||
uint8_t multi_acks;
|
||||
uint8_t manual_add_contacts;
|
||||
float bw;
|
||||
int8_t tx_power_dbm;
|
||||
uint8_t telemetry_mode_base;
|
||||
uint8_t telemetry_mode_loc;
|
||||
uint8_t telemetry_mode_env;
|
||||
float rx_delay_base;
|
||||
uint32_t ble_pin;
|
||||
uint8_t advert_loc_policy;
|
||||
uint8_t buzzer_quiet;
|
||||
uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled)
|
||||
uint32_t gps_interval; // GPS read interval in seconds
|
||||
uint8_t autoadd_config; // bitmask for auto-add contacts config
|
||||
uint8_t rx_boosted_gain; // SX126x RX boosted gain mode (0=power saving, 1=boosted)
|
||||
uint8_t client_repeat;
|
||||
uint8_t path_hash_mode; // which path mode to use when sending
|
||||
uint8_t autoadd_max_hops; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64)
|
||||
char default_scope_name[31];
|
||||
uint8_t default_scope_key[16];
|
||||
};
|
||||
232
examples/beacon_sensor/main.cpp
Normal file
232
examples/beacon_sensor/main.cpp
Normal file
@@ -0,0 +1,232 @@
|
||||
#include <Arduino.h> // needed for PlatformIO
|
||||
#include <Mesh.h>
|
||||
#include "MyMesh.h"
|
||||
|
||||
// Believe it or not, this std C function is busted on some platforms!
|
||||
static uint32_t _atoi(const char* sp) {
|
||||
uint32_t n = 0;
|
||||
while (*sp && *sp >= '0' && *sp <= '9') {
|
||||
n *= 10;
|
||||
n += (*sp++ - '0');
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
#include <InternalFileSystem.h>
|
||||
#if defined(QSPIFLASH)
|
||||
#include <CustomLFS_QSPIFlash.h>
|
||||
DataStore store(InternalFS, QSPIFlash, rtc_clock);
|
||||
#else
|
||||
#if defined(EXTRAFS)
|
||||
#include <CustomLFS.h>
|
||||
CustomLFS ExtraFS(0xD4000, 0x19000, 128);
|
||||
DataStore store(InternalFS, ExtraFS, rtc_clock);
|
||||
#else
|
||||
DataStore store(InternalFS, rtc_clock);
|
||||
#endif
|
||||
#endif
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
#include <LittleFS.h>
|
||||
DataStore store(LittleFS, rtc_clock);
|
||||
#elif defined(ESP32)
|
||||
#include <SPIFFS.h>
|
||||
DataStore store(SPIFFS, rtc_clock);
|
||||
#endif
|
||||
|
||||
#ifdef ESP32
|
||||
#ifdef WIFI_SSID
|
||||
#include <helpers/esp32/SerialWifiInterface.h>
|
||||
SerialWifiInterface serial_interface;
|
||||
#ifndef TCP_PORT
|
||||
#define TCP_PORT 5000
|
||||
#endif
|
||||
#elif defined(BLE_PIN_CODE)
|
||||
#include <helpers/esp32/SerialBLEInterface.h>
|
||||
SerialBLEInterface serial_interface;
|
||||
#elif defined(SERIAL_RX)
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
HardwareSerial companion_serial(1);
|
||||
#else
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
#endif
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
//#ifdef WIFI_SSID
|
||||
// #include <helpers/rp2040/SerialWifiInterface.h>
|
||||
// SerialWifiInterface serial_interface;
|
||||
// #ifndef TCP_PORT
|
||||
// #define TCP_PORT 5000
|
||||
// #endif
|
||||
// #elif defined(BLE_PIN_CODE)
|
||||
// #include <helpers/rp2040/SerialBLEInterface.h>
|
||||
// SerialBLEInterface serial_interface;
|
||||
#if defined(SERIAL_RX)
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
HardwareSerial companion_serial(1);
|
||||
#else
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
#endif
|
||||
#elif defined(NRF52_PLATFORM)
|
||||
#ifdef BLE_PIN_CODE
|
||||
#include <helpers/nrf52/SerialBLEInterface.h>
|
||||
SerialBLEInterface serial_interface;
|
||||
#else
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
#endif
|
||||
#elif defined(STM32_PLATFORM)
|
||||
#include <helpers/ArduinoSerialInterface.h>
|
||||
ArduinoSerialInterface serial_interface;
|
||||
#else
|
||||
#error "need to define a serial interface"
|
||||
#endif
|
||||
|
||||
/* GLOBAL OBJECTS */
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include "UITask.h"
|
||||
UITask ui_task(&board, &serial_interface);
|
||||
#endif
|
||||
|
||||
StdRNG fast_rng;
|
||||
SimpleMeshTables tables;
|
||||
MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
|
||||
#ifdef DISPLAY_CLASS
|
||||
, &ui_task
|
||||
#endif
|
||||
);
|
||||
|
||||
/* END GLOBAL OBJECTS */
|
||||
|
||||
void halt() {
|
||||
while (1) ;
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
board.begin();
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
DisplayDriver* disp = NULL;
|
||||
if (display.begin()) {
|
||||
disp = &display;
|
||||
disp->startFrame();
|
||||
#ifdef ST7789
|
||||
disp->setTextSize(2);
|
||||
#endif
|
||||
disp->drawTextCentered(disp->width() / 2, 28, "Loading...");
|
||||
disp->endFrame();
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!radio_init()) { halt(); }
|
||||
|
||||
fast_rng.begin(radio_get_rng_seed());
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
InternalFS.begin();
|
||||
#if defined(QSPIFLASH)
|
||||
if (!QSPIFlash.begin()) {
|
||||
// debug output might not be available at this point, might be too early. maybe should fall back to InternalFS here?
|
||||
MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: failed to initialize");
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: initialized successfully");
|
||||
}
|
||||
#else
|
||||
#if defined(EXTRAFS)
|
||||
ExtraFS.begin();
|
||||
#endif
|
||||
#endif
|
||||
store.begin();
|
||||
the_mesh.begin(
|
||||
#ifdef DISPLAY_CLASS
|
||||
disp != NULL
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
);
|
||||
|
||||
#ifdef BLE_PIN_CODE
|
||||
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
||||
#else
|
||||
serial_interface.begin(Serial);
|
||||
#endif
|
||||
the_mesh.startInterface(serial_interface);
|
||||
#elif defined(RP2040_PLATFORM)
|
||||
LittleFS.begin();
|
||||
store.begin();
|
||||
the_mesh.begin(
|
||||
#ifdef DISPLAY_CLASS
|
||||
disp != NULL
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
);
|
||||
|
||||
//#ifdef WIFI_SSID
|
||||
// WiFi.begin(WIFI_SSID, WIFI_PWD);
|
||||
// serial_interface.begin(TCP_PORT);
|
||||
// #elif defined(BLE_PIN_CODE)
|
||||
// char dev_name[32+16];
|
||||
// sprintf(dev_name, "%s%s", BLE_NAME_PREFIX, the_mesh.getNodeName());
|
||||
// serial_interface.begin(dev_name, the_mesh.getBLEPin());
|
||||
#if defined(SERIAL_RX)
|
||||
companion_serial.setPins(SERIAL_RX, SERIAL_TX);
|
||||
companion_serial.begin(115200);
|
||||
serial_interface.begin(companion_serial);
|
||||
#else
|
||||
serial_interface.begin(Serial);
|
||||
#endif
|
||||
the_mesh.startInterface(serial_interface);
|
||||
#elif defined(ESP32)
|
||||
SPIFFS.begin(true);
|
||||
store.begin();
|
||||
the_mesh.begin(
|
||||
#ifdef DISPLAY_CLASS
|
||||
disp != NULL
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
);
|
||||
|
||||
#ifdef WIFI_SSID
|
||||
board.setInhibitSleep(true); // prevent sleep when WiFi is active
|
||||
WiFi.begin(WIFI_SSID, WIFI_PWD);
|
||||
serial_interface.begin(TCP_PORT);
|
||||
#elif defined(BLE_PIN_CODE)
|
||||
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
|
||||
#elif defined(SERIAL_RX)
|
||||
companion_serial.setPins(SERIAL_RX, SERIAL_TX);
|
||||
companion_serial.begin(115200);
|
||||
serial_interface.begin(companion_serial);
|
||||
#else
|
||||
serial_interface.begin(Serial);
|
||||
#endif
|
||||
the_mesh.startInterface(serial_interface);
|
||||
#else
|
||||
#error "need to define filesystem"
|
||||
#endif
|
||||
|
||||
sensors.begin();
|
||||
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
the_mesh.applyGpsPrefs();
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved
|
||||
#endif
|
||||
}
|
||||
|
||||
void loop() {
|
||||
the_mesh.loop();
|
||||
sensors.loop();
|
||||
#ifdef DISPLAY_CLASS
|
||||
ui_task.loop();
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
}
|
||||
923
examples/beacon_sensor/ui-new/UITask.cpp
Normal file
923
examples/beacon_sensor/ui-new/UITask.cpp
Normal file
@@ -0,0 +1,923 @@
|
||||
#include "UITask.h"
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include "../MyMesh.h"
|
||||
#include "target.h"
|
||||
#ifdef WIFI_SSID
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
|
||||
#ifndef AUTO_OFF_MILLIS
|
||||
#define AUTO_OFF_MILLIS 15000 // 15 seconds
|
||||
#endif
|
||||
#define BOOT_SCREEN_MILLIS 3000 // 3 seconds
|
||||
|
||||
#ifdef PIN_STATUS_LED
|
||||
#define LED_ON_MILLIS 20
|
||||
#define LED_ON_MSG_MILLIS 200
|
||||
#define LED_CYCLE_MILLIS 4000
|
||||
#endif
|
||||
|
||||
#define LONG_PRESS_MILLIS 1200
|
||||
|
||||
#ifndef UI_RECENT_LIST_SIZE
|
||||
#define UI_RECENT_LIST_SIZE 4
|
||||
#endif
|
||||
|
||||
#if UI_HAS_JOYSTICK
|
||||
#define PRESS_LABEL "press Enter"
|
||||
#else
|
||||
#define PRESS_LABEL "long press"
|
||||
#endif
|
||||
|
||||
#include "icons.h"
|
||||
|
||||
class SplashScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
unsigned long dismiss_after;
|
||||
char _version_info[12];
|
||||
|
||||
public:
|
||||
SplashScreen(UITask* task) : _task(task) {
|
||||
// strip off dash and commit hash by changing dash to null terminator
|
||||
// e.g: v1.2.3-abcdef -> v1.2.3
|
||||
const char *ver = FIRMWARE_VERSION;
|
||||
const char *dash = strchr(ver, '-');
|
||||
|
||||
int len = dash ? dash - ver : strlen(ver);
|
||||
if (len >= sizeof(_version_info)) len = sizeof(_version_info) - 1;
|
||||
memcpy(_version_info, ver, len);
|
||||
_version_info[len] = 0;
|
||||
|
||||
dismiss_after = millis() + BOOT_SCREEN_MILLIS;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
// meshcore logo
|
||||
display.setColor(DisplayDriver::BLUE);
|
||||
int logoWidth = 128;
|
||||
display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
||||
|
||||
// version info
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(2);
|
||||
display.drawTextCentered(display.width()/2, 22, _version_info);
|
||||
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE);
|
||||
|
||||
return 1000;
|
||||
}
|
||||
|
||||
void poll() override {
|
||||
if (millis() >= dismiss_after) {
|
||||
_task->gotoHomeScreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class HomeScreen : public UIScreen {
|
||||
enum HomePage {
|
||||
FIRST,
|
||||
RECENT,
|
||||
RADIO,
|
||||
BLUETOOTH,
|
||||
ADVERT,
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
GPS,
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
SENSORS,
|
||||
#endif
|
||||
SHUTDOWN,
|
||||
Count // keep as last
|
||||
};
|
||||
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
SensorManager* _sensors;
|
||||
NodePrefs* _node_prefs;
|
||||
uint8_t _page;
|
||||
bool _shutdown_init;
|
||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||
|
||||
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
|
||||
// Convert millivolts to percentage
|
||||
#ifndef BATT_MIN_MILLIVOLTS
|
||||
#define BATT_MIN_MILLIVOLTS 3000
|
||||
#endif
|
||||
#ifndef BATT_MAX_MILLIVOLTS
|
||||
#define BATT_MAX_MILLIVOLTS 4200
|
||||
#endif
|
||||
const int minMilliVolts = BATT_MIN_MILLIVOLTS;
|
||||
const int maxMilliVolts = BATT_MAX_MILLIVOLTS;
|
||||
int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
|
||||
if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
|
||||
if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
|
||||
|
||||
// battery icon
|
||||
int iconWidth = 24;
|
||||
int iconHeight = 10;
|
||||
int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner
|
||||
int iconY = 0;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
|
||||
// battery "cap"
|
||||
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2);
|
||||
|
||||
// fill the battery based on the percentage
|
||||
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
||||
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
||||
|
||||
// show muted icon if buzzer is muted
|
||||
#ifdef PIN_BUZZER
|
||||
if (_task->isBuzzerQuiet()) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.drawXbm(iconX - 9, iconY + 1, muted_icon, 8, 8);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
CayenneLPP sensors_lpp;
|
||||
int sensors_nb = 0;
|
||||
bool sensors_scroll = false;
|
||||
int sensors_scroll_offset = 0;
|
||||
int next_sensors_refresh = 0;
|
||||
|
||||
void refresh_sensors() {
|
||||
if (millis() > next_sensors_refresh) {
|
||||
sensors_lpp.reset();
|
||||
sensors_nb = 0;
|
||||
sensors_lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
|
||||
sensors.querySensors(0xFF, sensors_lpp);
|
||||
LPPReader reader (sensors_lpp.getBuffer(), sensors_lpp.getSize());
|
||||
uint8_t channel, type;
|
||||
while(reader.readHeader(channel, type)) {
|
||||
reader.skipData(type);
|
||||
sensors_nb ++;
|
||||
}
|
||||
sensors_scroll = sensors_nb > UI_RECENT_LIST_SIZE;
|
||||
#if AUTO_OFF_MILLIS > 0
|
||||
next_sensors_refresh = millis() + 5000; // refresh sensor values every 5 sec
|
||||
#else
|
||||
next_sensors_refresh = millis() + 60000; // refresh sensor values every 1 min
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
|
||||
_shutdown_init(false), sensors_lpp(200) { }
|
||||
|
||||
void poll() override {
|
||||
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
|
||||
_task->shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[80];
|
||||
// node name
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
char filtered_name[sizeof(_node_prefs->node_name)];
|
||||
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
||||
display.setCursor(0, 0);
|
||||
display.print(filtered_name);
|
||||
|
||||
// battery voltage
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts());
|
||||
|
||||
// curr page indicator
|
||||
int y = 14;
|
||||
int x = display.width() / 2 - 5 * (HomePage::Count-1);
|
||||
for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) {
|
||||
if (i == _page) {
|
||||
display.fillRect(x-1, y-1, 3, 3);
|
||||
} else {
|
||||
display.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (_page == HomePage::FIRST) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "MSG: %d", _task->getMsgCount());
|
||||
display.drawTextCentered(display.width() / 2, 20, tmp);
|
||||
|
||||
#ifdef WIFI_SSID
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 54, tmp);
|
||||
#endif
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 43, "< Connected >");
|
||||
|
||||
} else if (the_mesh.getBLEPin() != 0) { // BT pin
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
display.drawTextCentered(display.width() / 2, 43, tmp);
|
||||
}
|
||||
} else if (_page == HomePage::RECENT) {
|
||||
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
int y = 20;
|
||||
for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) {
|
||||
auto a = &recent[i];
|
||||
if (a->name[0] == 0) continue; // empty slot
|
||||
int secs = _rtc->getCurrentTime() - a->recv_timestamp;
|
||||
if (secs < 60) {
|
||||
sprintf(tmp, "%ds", secs);
|
||||
} else if (secs < 60*60) {
|
||||
sprintf(tmp, "%dm", secs / 60);
|
||||
} else {
|
||||
sprintf(tmp, "%dh", secs / (60*60));
|
||||
}
|
||||
|
||||
int timestamp_width = display.getTextWidth(tmp);
|
||||
int max_name_width = display.width() - timestamp_width - 1;
|
||||
|
||||
char filtered_recent_name[sizeof(a->name)];
|
||||
display.translateUTF8ToBlocks(filtered_recent_name, a->name, sizeof(filtered_recent_name));
|
||||
display.drawTextEllipsized(0, y, max_name_width, filtered_recent_name);
|
||||
display.setCursor(display.width() - timestamp_width - 1, y);
|
||||
display.print(tmp);
|
||||
}
|
||||
} else if (_page == HomePage::RADIO) {
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(1);
|
||||
// freq / sf
|
||||
display.setCursor(0, 20);
|
||||
sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf);
|
||||
display.print(tmp);
|
||||
|
||||
display.setCursor(0, 31);
|
||||
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
||||
display.print(tmp);
|
||||
|
||||
// tx power, noise floor
|
||||
display.setCursor(0, 42);
|
||||
sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm);
|
||||
display.print(tmp);
|
||||
display.setCursor(0, 53);
|
||||
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
|
||||
display.print(tmp);
|
||||
} else if (_page == HomePage::BLUETOOTH) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawXbm((display.width() - 32) / 2, 18,
|
||||
_task->isSerialEnabled() ? bluetooth_on : bluetooth_off,
|
||||
32, 32);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
} else if (_page == HomePage::GPS) {
|
||||
LocationProvider* nmea = sensors.getLocationProvider();
|
||||
char buf[50];
|
||||
int y = 18;
|
||||
bool gps_state = _task->getGPSState();
|
||||
#ifdef PIN_GPS_SWITCH
|
||||
bool hw_gps_state = digitalRead(PIN_GPS_SWITCH);
|
||||
if (gps_state != hw_gps_state) {
|
||||
strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)");
|
||||
} else {
|
||||
strcpy(buf, gps_state ? "gps on" : "gps off");
|
||||
}
|
||||
#else
|
||||
strcpy(buf, gps_state ? "gps on" : "gps off");
|
||||
#endif
|
||||
display.drawTextLeftAlign(0, y, buf);
|
||||
if (nmea == NULL) {
|
||||
y = y + 12;
|
||||
display.drawTextLeftAlign(0, y, "Can't access GPS");
|
||||
} else {
|
||||
strcpy(buf, nmea->isValid()?"fix":"no fix");
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
display.drawTextLeftAlign(0, y, "sat");
|
||||
sprintf(buf, "%d", nmea->satellitesCount());
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
display.drawTextLeftAlign(0, y, "pos");
|
||||
sprintf(buf, "%.4f %.4f",
|
||||
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
display.drawTextLeftAlign(0, y, "alt");
|
||||
sprintf(buf, "%.2f", nmea->getAltitude()/1000.);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
}
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
} else if (_page == HomePage::SENSORS) {
|
||||
int y = 18;
|
||||
refresh_sensors();
|
||||
char buf[30];
|
||||
char name[30];
|
||||
LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize());
|
||||
|
||||
for (int i = 0; i < sensors_scroll_offset; i++) {
|
||||
uint8_t channel, type;
|
||||
r.readHeader(channel, type);
|
||||
r.skipData(type);
|
||||
}
|
||||
|
||||
for (int i = 0; i < (sensors_scroll?UI_RECENT_LIST_SIZE:sensors_nb); i++) {
|
||||
uint8_t channel, type;
|
||||
if (!r.readHeader(channel, type)) { // reached end, reset
|
||||
r.reset();
|
||||
r.readHeader(channel, type);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
float v;
|
||||
switch (type) {
|
||||
case LPP_GPS: // GPS
|
||||
float lat, lon, alt;
|
||||
r.readGPS(lat, lon, alt);
|
||||
strcpy(name, "gps"); sprintf(buf, "%.4f %.4f", lat, lon);
|
||||
break;
|
||||
case LPP_VOLTAGE:
|
||||
r.readVoltage(v);
|
||||
strcpy(name, "voltage"); sprintf(buf, "%6.2f", v);
|
||||
break;
|
||||
case LPP_CURRENT:
|
||||
r.readCurrent(v);
|
||||
strcpy(name, "current"); sprintf(buf, "%.3f", v);
|
||||
break;
|
||||
case LPP_TEMPERATURE:
|
||||
r.readTemperature(v);
|
||||
strcpy(name, "temperature"); sprintf(buf, "%.2f", v);
|
||||
break;
|
||||
case LPP_RELATIVE_HUMIDITY:
|
||||
r.readRelativeHumidity(v);
|
||||
strcpy(name, "humidity"); sprintf(buf, "%.2f", v);
|
||||
break;
|
||||
case LPP_BAROMETRIC_PRESSURE:
|
||||
r.readPressure(v);
|
||||
strcpy(name, "pressure"); sprintf(buf, "%.2f", v);
|
||||
break;
|
||||
case LPP_ALTITUDE:
|
||||
r.readAltitude(v);
|
||||
strcpy(name, "altitude"); sprintf(buf, "%.0f", v);
|
||||
break;
|
||||
case LPP_POWER:
|
||||
r.readPower(v);
|
||||
strcpy(name, "power"); sprintf(buf, "%6.2f", v);
|
||||
break;
|
||||
default:
|
||||
r.skipData(type);
|
||||
strcpy(name, "unk"); sprintf(buf, "");
|
||||
}
|
||||
display.setCursor(0, y);
|
||||
display.print(name);
|
||||
display.setCursor(
|
||||
display.width()-display.getTextWidth(buf)-1, y
|
||||
);
|
||||
display.print(buf);
|
||||
y = y + 12;
|
||||
}
|
||||
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
|
||||
else sensors_scroll_offset = 0;
|
||||
#endif
|
||||
} else if (_page == HomePage::SHUTDOWN) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
if (_shutdown_init) {
|
||||
display.drawTextCentered(display.width() / 2, 34, "hibernating...");
|
||||
} else {
|
||||
display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL);
|
||||
}
|
||||
}
|
||||
return 5000; // next render after 5000 ms
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_LEFT || c == KEY_PREV) {
|
||||
_page = (_page + HomePage::Count - 1) % HomePage::Count;
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
||||
_page = (_page + 1) % HomePage::Count;
|
||||
if (_page == HomePage::RECENT) {
|
||||
_task->showAlert("Recent adverts", 800);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
|
||||
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
|
||||
_task->disableSerial();
|
||||
} else {
|
||||
_task->enableSerial();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
||||
_task->notify(UIEventType::ack);
|
||||
if (the_mesh.advert()) {
|
||||
_task->showAlert("Advert sent!", 1000);
|
||||
} else {
|
||||
_task->showAlert("Advert failed..", 1000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
if (c == KEY_ENTER && _page == HomePage::GPS) {
|
||||
_task->toggleGPS();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
if (c == KEY_ENTER && _page == HomePage::SENSORS) {
|
||||
_task->toggleGPS();
|
||||
next_sensors_refresh=0;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
|
||||
_shutdown_init = true; // need to wait for button to be released
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
class MsgPreviewScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
|
||||
struct MsgEntry {
|
||||
uint32_t timestamp;
|
||||
char origin[62];
|
||||
char msg[78];
|
||||
};
|
||||
#define MAX_UNREAD_MSGS 32
|
||||
int num_unread;
|
||||
int head = MAX_UNREAD_MSGS - 1; // index of latest unread message
|
||||
MsgEntry unread[MAX_UNREAD_MSGS];
|
||||
|
||||
public:
|
||||
MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc) { num_unread = 0; }
|
||||
|
||||
void addPreview(uint8_t path_len, const char* from_name, const char* msg) {
|
||||
head = (head + 1) % MAX_UNREAD_MSGS;
|
||||
if (num_unread < MAX_UNREAD_MSGS) num_unread++;
|
||||
|
||||
auto p = &unread[head];
|
||||
p->timestamp = _rtc->getCurrentTime();
|
||||
if (path_len == 0xFF) {
|
||||
sprintf(p->origin, "(D) %s:", from_name);
|
||||
} else {
|
||||
sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name);
|
||||
}
|
||||
StrHelper::strncpy(p->msg, msg, sizeof(p->msg));
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[16];
|
||||
display.setCursor(0, 0);
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
sprintf(tmp, "Unread: %d", num_unread);
|
||||
display.print(tmp);
|
||||
|
||||
auto p = &unread[head];
|
||||
|
||||
int secs = _rtc->getCurrentTime() - p->timestamp;
|
||||
if (secs < 60) {
|
||||
sprintf(tmp, "%ds", secs);
|
||||
} else if (secs < 60*60) {
|
||||
sprintf(tmp, "%dm", secs / 60);
|
||||
} else {
|
||||
sprintf(tmp, "%dh", secs / (60*60));
|
||||
}
|
||||
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
||||
display.print(tmp);
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1); // horiz line
|
||||
|
||||
display.setCursor(0, 14);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
char filtered_origin[sizeof(p->origin)];
|
||||
display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin));
|
||||
display.print(filtered_origin);
|
||||
|
||||
display.setCursor(0, 25);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
char filtered_msg[sizeof(p->msg)];
|
||||
display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg));
|
||||
display.printWordWrap(filtered_msg, display.width());
|
||||
|
||||
#if AUTO_OFF_MILLIS==0 // probably e-ink
|
||||
return 10000; // 10 s
|
||||
#else
|
||||
return 1000; // next render after 1000 ms
|
||||
#endif
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
||||
head = (head + MAX_UNREAD_MSGS - 1) % MAX_UNREAD_MSGS;
|
||||
num_unread--;
|
||||
if (num_unread == 0) {
|
||||
_task->gotoHomeScreen();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER) {
|
||||
num_unread = 0; // clear unread queue
|
||||
_task->gotoHomeScreen();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) {
|
||||
_display = display;
|
||||
_sensors = sensors;
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
|
||||
#if defined(PIN_USER_BTN)
|
||||
user_btn.begin();
|
||||
#endif
|
||||
#if defined(PIN_USER_BTN_ANA)
|
||||
analog_btn.begin();
|
||||
#endif
|
||||
|
||||
_node_prefs = node_prefs;
|
||||
|
||||
if (_display != NULL) {
|
||||
_display->turnOn();
|
||||
}
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
buzzer.begin();
|
||||
buzzer.quiet(_node_prefs->buzzer_quiet);
|
||||
#endif
|
||||
|
||||
#ifdef PIN_VIBRATION
|
||||
vibration.begin();
|
||||
#endif
|
||||
|
||||
ui_started_at = millis();
|
||||
_alert_expiry = 0;
|
||||
|
||||
splash = new SplashScreen(this);
|
||||
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
|
||||
msg_preview = new MsgPreviewScreen(this, &rtc_clock);
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
void UITask::showAlert(const char* text, int duration_millis) {
|
||||
strcpy(_alert, text);
|
||||
_alert_expiry = millis() + duration_millis;
|
||||
}
|
||||
|
||||
void UITask::notify(UIEventType t) {
|
||||
#if defined(PIN_BUZZER)
|
||||
switch(t){
|
||||
case UIEventType::contactMessage:
|
||||
// gemini's pick
|
||||
buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
|
||||
break;
|
||||
case UIEventType::channelMessage:
|
||||
buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#");
|
||||
break;
|
||||
case UIEventType::ack:
|
||||
buzzer.play("ack:d=32,o=8,b=120:c");
|
||||
break;
|
||||
case UIEventType::roomMessage:
|
||||
case UIEventType::newContactMessage:
|
||||
case UIEventType::none:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef PIN_VIBRATION
|
||||
// Trigger vibration for all UI events except none
|
||||
if (t != UIEventType::none) {
|
||||
vibration.trigger();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
void UITask::msgRead(int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
if (msgcount == 0) {
|
||||
gotoHomeScreen();
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
|
||||
((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text);
|
||||
setCurrScreen(msg_preview);
|
||||
|
||||
if (_display != NULL) {
|
||||
if (!_display->isOn() && !hasConnection()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
if (_display->isOn()) {
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer
|
||||
_next_refresh = 100; // trigger refresh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::userLedHandler() {
|
||||
#ifdef PIN_STATUS_LED
|
||||
int cur_time = millis();
|
||||
if (cur_time > next_led_change) {
|
||||
if (led_state == 0) {
|
||||
led_state = 1;
|
||||
if (_msgcount > 0) {
|
||||
last_led_increment = LED_ON_MSG_MILLIS;
|
||||
} else {
|
||||
last_led_increment = LED_ON_MILLIS;
|
||||
}
|
||||
next_led_change = cur_time + last_led_increment;
|
||||
} else {
|
||||
led_state = 0;
|
||||
next_led_change = cur_time + LED_CYCLE_MILLIS - last_led_increment;
|
||||
}
|
||||
digitalWrite(PIN_STATUS_LED, led_state == LED_STATE_ON);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::setCurrScreen(UIScreen* c) {
|
||||
curr = c;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
/*
|
||||
hardware-agnostic pre-shutdown activity should be done here
|
||||
*/
|
||||
void UITask::shutdown(bool restart){
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
/* note: we have a choice here -
|
||||
we can do a blocking buzzer.loop() with non-deterministic consequences
|
||||
or we can set a flag and delay the shutdown for a couple of seconds
|
||||
while a non-blocking buzzer.loop() plays out in UITask::loop()
|
||||
*/
|
||||
buzzer.shutdown();
|
||||
uint32_t buzzer_timer = millis(); // fail-safe shutdown
|
||||
while (buzzer.isPlaying() && (millis() - 2500) < buzzer_timer)
|
||||
buzzer.loop();
|
||||
|
||||
#endif // PIN_BUZZER
|
||||
|
||||
if (restart) {
|
||||
_board->reboot();
|
||||
} else {
|
||||
_display->turnOff();
|
||||
radio_driver.powerOff();
|
||||
_board->powerOff();
|
||||
}
|
||||
}
|
||||
|
||||
bool UITask::isButtonPressed() const {
|
||||
#ifdef PIN_USER_BTN
|
||||
return user_btn.isPressed();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::loop() {
|
||||
char c = 0;
|
||||
#if UI_HAS_JOYSTICK
|
||||
int ev = user_btn.check();
|
||||
if (ev == BUTTON_EVENT_CLICK) {
|
||||
c = checkDisplayOn(KEY_ENTER);
|
||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||||
c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code
|
||||
}
|
||||
ev = joystick_left.check();
|
||||
if (ev == BUTTON_EVENT_CLICK) {
|
||||
c = checkDisplayOn(KEY_LEFT);
|
||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||||
c = handleLongPress(KEY_LEFT);
|
||||
}
|
||||
ev = joystick_right.check();
|
||||
if (ev == BUTTON_EVENT_CLICK) {
|
||||
c = checkDisplayOn(KEY_RIGHT);
|
||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||||
c = handleLongPress(KEY_RIGHT);
|
||||
}
|
||||
ev = back_btn.check();
|
||||
if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
||||
c = handleTripleClick(KEY_SELECT);
|
||||
}
|
||||
#elif defined(PIN_USER_BTN)
|
||||
int ev = user_btn.check();
|
||||
if (ev == BUTTON_EVENT_CLICK) {
|
||||
c = checkDisplayOn(KEY_NEXT);
|
||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||||
c = handleLongPress(KEY_ENTER);
|
||||
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
||||
c = handleDoubleClick(KEY_PREV);
|
||||
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
||||
c = handleTripleClick(KEY_SELECT);
|
||||
}
|
||||
#endif
|
||||
#if defined(PIN_USER_BTN_ANA)
|
||||
if (abs(millis() - _analogue_pin_read_millis) > 10) {
|
||||
ev = analog_btn.check();
|
||||
if (ev == BUTTON_EVENT_CLICK) {
|
||||
c = checkDisplayOn(KEY_NEXT);
|
||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||||
c = handleLongPress(KEY_ENTER);
|
||||
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
||||
c = handleDoubleClick(KEY_PREV);
|
||||
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
||||
c = handleTripleClick(KEY_SELECT);
|
||||
}
|
||||
_analogue_pin_read_millis = millis();
|
||||
}
|
||||
#endif
|
||||
#if defined(BACKLIGHT_BTN)
|
||||
if (millis() > next_backlight_btn_check) {
|
||||
bool touch_state = digitalRead(PIN_BUTTON2);
|
||||
#if defined(DISP_BACKLIGHT)
|
||||
digitalWrite(DISP_BACKLIGHT, !touch_state);
|
||||
#elif defined(EXP_PIN_BACKLIGHT)
|
||||
expander.digitalWrite(EXP_PIN_BACKLIGHT, !touch_state);
|
||||
#endif
|
||||
next_backlight_btn_check = millis() + 300;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (c != 0 && curr) {
|
||||
curr->handleInput(c);
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
_next_refresh = 100; // trigger refresh
|
||||
}
|
||||
|
||||
userLedHandler();
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
if (buzzer.isPlaying()) buzzer.loop();
|
||||
#endif
|
||||
|
||||
if (curr) curr->poll();
|
||||
|
||||
if (_display != NULL && _display->isOn()) {
|
||||
if (millis() >= _next_refresh && curr) {
|
||||
_display->startFrame();
|
||||
int delay_millis = curr->render(*_display);
|
||||
if (millis() < _alert_expiry) { // render alert popup
|
||||
_display->setTextSize(1);
|
||||
int y = _display->height() / 3;
|
||||
int p = _display->height() / 32;
|
||||
_display->setColor(DisplayDriver::DARK);
|
||||
_display->fillRect(p, y, _display->width() - p*2, y);
|
||||
_display->setColor(DisplayDriver::LIGHT); // draw box border
|
||||
_display->drawRect(p, y, _display->width() - p*2, y);
|
||||
_display->drawTextCentered(_display->width() / 2, y + p*3, _alert);
|
||||
_next_refresh = _alert_expiry; // will need refresh when alert is dismissed
|
||||
} else {
|
||||
_next_refresh = millis() + delay_millis;
|
||||
}
|
||||
_display->endFrame();
|
||||
}
|
||||
#if AUTO_OFF_MILLIS > 0
|
||||
if (millis() > _auto_off) {
|
||||
_display->turnOff();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef PIN_VIBRATION
|
||||
vibration.loop();
|
||||
#endif
|
||||
|
||||
#ifdef AUTO_SHUTDOWN_MILLIVOLTS
|
||||
if (millis() > next_batt_chck) {
|
||||
uint16_t milliVolts = getBattMilliVolts();
|
||||
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||||
|
||||
// show low battery shutdown alert
|
||||
// we should only do this for eink displays, which will persist after power loss
|
||||
#if defined(THINKNODE_M1) || defined(LILYGO_TECHO)
|
||||
if (_display != NULL) {
|
||||
_display->startFrame();
|
||||
_display->setTextSize(2);
|
||||
_display->setColor(DisplayDriver::RED);
|
||||
_display->drawTextCentered(_display->width() / 2, 20, "Low Battery.");
|
||||
_display->drawTextCentered(_display->width() / 2, 40, "Shutting Down!");
|
||||
_display->endFrame();
|
||||
}
|
||||
#endif
|
||||
|
||||
shutdown();
|
||||
|
||||
}
|
||||
next_batt_chck = millis() + 8000;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
char UITask::checkDisplayOn(char c) {
|
||||
if (_display != NULL) {
|
||||
if (!_display->isOn()) {
|
||||
_display->turnOn(); // turn display on and consume event
|
||||
c = 0;
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
_next_refresh = 0; // trigger refresh
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
char UITask::handleLongPress(char c) {
|
||||
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
|
||||
the_mesh.enterCLIRescue();
|
||||
c = 0; // consume event
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
char UITask::handleDoubleClick(char c) {
|
||||
MESH_DEBUG_PRINTLN("UITask: double click triggered");
|
||||
checkDisplayOn(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
char UITask::handleTripleClick(char c) {
|
||||
MESH_DEBUG_PRINTLN("UITask: triple click triggered");
|
||||
checkDisplayOn(c);
|
||||
toggleBuzzer();
|
||||
c = 0;
|
||||
return c;
|
||||
}
|
||||
|
||||
bool UITask::getGPSState() {
|
||||
if (_sensors != NULL) {
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
||||
return !strcmp(_sensors->getSettingValue(i), "1");
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void UITask::toggleGPS() {
|
||||
if (_sensors != NULL) {
|
||||
// toggle GPS on/off
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
||||
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
notify(UIEventType::ack);
|
||||
}
|
||||
the_mesh.savePrefs();
|
||||
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
|
||||
_next_refresh = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::toggleBuzzer() {
|
||||
// Toggle buzzer quiet mode
|
||||
#ifdef PIN_BUZZER
|
||||
if (buzzer.isQuiet()) {
|
||||
buzzer.quiet(false);
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
buzzer.quiet(true);
|
||||
}
|
||||
_node_prefs->buzzer_quiet = buzzer.isQuiet();
|
||||
the_mesh.savePrefs();
|
||||
showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800);
|
||||
_next_refresh = 0; // trigger refresh
|
||||
#endif
|
||||
}
|
||||
101
examples/beacon_sensor/ui-new/UITask.h
Normal file
101
examples/beacon_sensor/ui-new/UITask.h
Normal file
@@ -0,0 +1,101 @@
|
||||
#pragma once
|
||||
|
||||
#include <MeshCore.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/BaseSerialInterface.h>
|
||||
#include <Arduino.h>
|
||||
#include <helpers/sensors/LPPDataHelpers.h>
|
||||
|
||||
#ifndef LED_STATE_ON
|
||||
#define LED_STATE_ON 1
|
||||
#endif
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
#include <helpers/ui/buzzer.h>
|
||||
#endif
|
||||
#ifdef PIN_VIBRATION
|
||||
#include <helpers/ui/GenericVibration.h>
|
||||
#endif
|
||||
|
||||
#include "../AbstractUITask.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
class UITask : public AbstractUITask {
|
||||
DisplayDriver* _display;
|
||||
SensorManager* _sensors;
|
||||
#ifdef PIN_BUZZER
|
||||
genericBuzzer buzzer;
|
||||
#endif
|
||||
#ifdef PIN_VIBRATION
|
||||
GenericVibration vibration;
|
||||
#endif
|
||||
unsigned long _next_refresh, _auto_off;
|
||||
NodePrefs* _node_prefs;
|
||||
char _alert[80];
|
||||
unsigned long _alert_expiry;
|
||||
int _msgcount;
|
||||
unsigned long ui_started_at, next_batt_chck;
|
||||
int next_backlight_btn_check = 0;
|
||||
#ifdef PIN_STATUS_LED
|
||||
int led_state = 0;
|
||||
int next_led_change = 0;
|
||||
int last_led_increment = 0;
|
||||
#endif
|
||||
|
||||
#ifdef PIN_USER_BTN_ANA
|
||||
unsigned long _analogue_pin_read_millis = millis();
|
||||
#endif
|
||||
|
||||
UIScreen* splash;
|
||||
UIScreen* home;
|
||||
UIScreen* msg_preview;
|
||||
UIScreen* curr;
|
||||
|
||||
void userLedHandler();
|
||||
|
||||
// Button action handlers
|
||||
char checkDisplayOn(char c);
|
||||
char handleLongPress(char c);
|
||||
char handleDoubleClick(char c);
|
||||
char handleTripleClick(char c);
|
||||
|
||||
void setCurrScreen(UIScreen* c);
|
||||
|
||||
public:
|
||||
|
||||
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
|
||||
next_batt_chck = _next_refresh = 0;
|
||||
ui_started_at = 0;
|
||||
curr = NULL;
|
||||
}
|
||||
void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
|
||||
|
||||
void gotoHomeScreen() { setCurrScreen(home); }
|
||||
void showAlert(const char* text, int duration_millis);
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
bool hasDisplay() const { return _display != NULL; }
|
||||
bool isButtonPressed() const;
|
||||
|
||||
bool isBuzzerQuiet() {
|
||||
#ifdef PIN_BUZZER
|
||||
return buzzer.isQuiet();
|
||||
#else
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
void toggleBuzzer();
|
||||
bool getGPSState();
|
||||
void toggleGPS();
|
||||
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void loop() override;
|
||||
|
||||
void shutdown(bool restart = false);
|
||||
};
|
||||
122
examples/beacon_sensor/ui-new/icons.h
Normal file
122
examples/beacon_sensor/ui-new/icons.h
Normal file
@@ -0,0 +1,122 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// 'meshcore', 128x13px
|
||||
static const uint8_t meshcore_logo [] = {
|
||||
0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe,
|
||||
0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe,
|
||||
0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc,
|
||||
0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00,
|
||||
0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00,
|
||||
0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8,
|
||||
0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8,
|
||||
0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0,
|
||||
0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00,
|
||||
0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00,
|
||||
0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8,
|
||||
0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8,
|
||||
0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8,
|
||||
};
|
||||
|
||||
static const uint8_t bluetooth_on[] = {
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x30, 0x00, 0x00,
|
||||
0x00, 0x3C, 0x00, 0x00,
|
||||
0x00, 0x3E, 0x00, 0x00,
|
||||
0x00, 0x3F, 0x80, 0x00,
|
||||
0x00, 0x3F, 0xC0, 0x00,
|
||||
0x00, 0x3B, 0xE0, 0x00,
|
||||
0x30, 0x38, 0xF8, 0x00,
|
||||
0x3C, 0x38, 0x7C, 0x00,
|
||||
0x3E, 0x38, 0x7C, 0x00,
|
||||
0x1F, 0xB8, 0xF8, 0x70,
|
||||
0x07, 0xF9, 0xF0, 0x78,
|
||||
0x03, 0xFF, 0xC0, 0x78,
|
||||
0x00, 0xFF, 0x80, 0x3C,
|
||||
0x00, 0x7F, 0x07, 0x1C,
|
||||
0x00, 0x7E, 0x07, 0x1C,
|
||||
0x03, 0xFF, 0x82, 0x1C,
|
||||
0x03, 0xFF, 0xC0, 0x78,
|
||||
0x07, 0xFB, 0xE0, 0x78,
|
||||
0x0F, 0xB8, 0xF8, 0x70,
|
||||
0x3E, 0x38, 0x7C, 0x00,
|
||||
0x3C, 0x38, 0x7C, 0x00,
|
||||
0x38, 0x38, 0xF8, 0x00,
|
||||
0x00, 0x39, 0xF0, 0x00,
|
||||
0x00, 0x3F, 0xC0, 0x00,
|
||||
0x00, 0x3F, 0x80, 0x00,
|
||||
0x00, 0x3E, 0x00, 0x00,
|
||||
0x00, 0x3C, 0x00, 0x00,
|
||||
0x00, 0x38, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
|
||||
static const uint8_t bluetooth_off[] = {
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x03, 0x80, 0x00,
|
||||
0x00, 0x03, 0xC0, 0x00,
|
||||
0x00, 0x03, 0xE0, 0x00,
|
||||
0x38, 0x03, 0xF8, 0x00,
|
||||
0x3C, 0x03, 0xFC, 0x00,
|
||||
0x3E, 0x03, 0xBF, 0x00,
|
||||
0x0F, 0x83, 0x8F, 0x80,
|
||||
0x07, 0xC3, 0x87, 0xC0,
|
||||
0x03, 0xF0, 0x03, 0xC0,
|
||||
0x00, 0xF8, 0x0F, 0x80,
|
||||
0x00, 0x7C, 0x0F, 0x00,
|
||||
0x00, 0x1F, 0x0E, 0x00,
|
||||
0x00, 0x0F, 0x80, 0x00,
|
||||
0x00, 0x07, 0xE0, 0x00,
|
||||
0x00, 0x07, 0xF0, 0x00,
|
||||
0x00, 0x0F, 0xF8, 0x00,
|
||||
0x00, 0x3F, 0xBE, 0x00,
|
||||
0x00, 0x7F, 0x9F, 0x00,
|
||||
0x00, 0xFB, 0x8F, 0xC0,
|
||||
0x03, 0xE3, 0x83, 0xE0,
|
||||
0x03, 0xC3, 0x87, 0xF0,
|
||||
0x03, 0x83, 0x8F, 0xFC,
|
||||
0x00, 0x03, 0xBF, 0x3C,
|
||||
0x00, 0x03, 0xFC, 0x1C,
|
||||
0x00, 0x03, 0xF8, 0x00,
|
||||
0x00, 0x03, 0xE0, 0x00,
|
||||
0x00, 0x03, 0xC0, 0x00,
|
||||
0x00, 0x03, 0x80, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
|
||||
static const uint8_t power_icon[] = {
|
||||
0x00, 0x01, 0x80, 0x00, 0x00, 0x03, 0xC0, 0x00, 0x00, 0x03, 0xC0, 0x00,
|
||||
0x00, 0x33, 0xCC, 0x00, 0x00, 0xF3, 0xCF, 0x00, 0x01, 0xF3, 0xCF, 0x80,
|
||||
0x03, 0xF3, 0xCF, 0xC0, 0x07, 0xF3, 0xCF, 0xE0, 0x0F, 0xE3, 0xC7, 0xF0,
|
||||
0x1F, 0xC3, 0xC3, 0xF8, 0x1F, 0x83, 0xC1, 0xF8, 0x3F, 0x03, 0xC0, 0xFC,
|
||||
0x3E, 0x03, 0xC0, 0x7C, 0x3E, 0x03, 0xC0, 0x7C, 0x7E, 0x01, 0x80, 0x7E,
|
||||
0x7C, 0x00, 0x00, 0x3E, 0x7C, 0x00, 0x00, 0x3E, 0x7C, 0x00, 0x00, 0x3E,
|
||||
0x7C, 0x00, 0x00, 0x3E, 0x7C, 0x00, 0x00, 0x3E, 0x3E, 0x00, 0x00, 0x7C,
|
||||
0x3E, 0x00, 0x00, 0x7C, 0x3F, 0x00, 0x00, 0xFC, 0x1F, 0x80, 0x01, 0xF8,
|
||||
0x1F, 0xC0, 0x03, 0xF8, 0x0F, 0xE0, 0x07, 0xF0, 0x0F, 0xF8, 0x1F, 0xF0,
|
||||
0x07, 0xFF, 0xFF, 0xE0, 0x03, 0xFF, 0xFF, 0xC0, 0x00, 0xFF, 0xFF, 0x00,
|
||||
0x00, 0x3F, 0xFC, 0x00, 0x00, 0x0F, 0xF0, 0x00,
|
||||
};
|
||||
|
||||
static const uint8_t advert_icon[] = {
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x30,
|
||||
0x1C, 0x00, 0x00, 0x38, 0x18, 0x00, 0x00, 0x18, 0x30, 0x00, 0x00, 0x0C,
|
||||
0x30, 0x60, 0x06, 0x0C, 0x60, 0xE0, 0x07, 0x06, 0x61, 0xC0, 0x03, 0x86,
|
||||
0xE1, 0x81, 0x81, 0x87, 0xC3, 0x07, 0xE0, 0xC3, 0xC3, 0x0F, 0xF0, 0xC3,
|
||||
0xC3, 0x0F, 0xF0, 0xC3, 0xC3, 0x0F, 0xF0, 0xC3, 0xC3, 0x0F, 0xF0, 0xC3,
|
||||
0xC3, 0x07, 0xE0, 0xC3, 0xC1, 0x83, 0xC1, 0x83, 0x61, 0x80, 0x01, 0x86,
|
||||
0x60, 0xC0, 0x03, 0x06, 0x70, 0xE0, 0x07, 0x0E, 0x30, 0x40, 0x02, 0x0C,
|
||||
0x38, 0x00, 0x00, 0x1C, 0x18, 0x00, 0x00, 0x18, 0x0C, 0x00, 0x00, 0x30,
|
||||
0x04, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
|
||||
static const uint8_t muted_icon[] = {
|
||||
0x20, 0x6a, 0xea, 0xe4, 0xe4, 0xea, 0x6a, 0x20
|
||||
};
|
||||
131
examples/beacon_sensor/ui-orig/Button.cpp
Normal file
131
examples/beacon_sensor/ui-orig/Button.cpp
Normal file
@@ -0,0 +1,131 @@
|
||||
#include "Button.h"
|
||||
|
||||
Button::Button(uint8_t pin, bool activeState)
|
||||
: _pin(pin), _activeState(activeState), _isAnalog(false), _analogThreshold(20) {
|
||||
_currentState = false; // Initialize as not pressed
|
||||
_lastState = _currentState;
|
||||
}
|
||||
|
||||
Button::Button(uint8_t pin, bool activeState, bool isAnalog, uint16_t analogThreshold)
|
||||
: _pin(pin), _activeState(activeState), _isAnalog(isAnalog), _analogThreshold(analogThreshold) {
|
||||
_currentState = false; // Initialize as not pressed
|
||||
_lastState = _currentState;
|
||||
}
|
||||
|
||||
void Button::begin() {
|
||||
_currentState = readButton();
|
||||
_lastState = _currentState;
|
||||
}
|
||||
|
||||
void Button::update() {
|
||||
uint32_t now = millis();
|
||||
|
||||
// Read button at specified interval
|
||||
if (now - _lastReadTime < BUTTON_READ_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
_lastReadTime = now;
|
||||
|
||||
bool newState = readButton();
|
||||
|
||||
// Check if state has changed
|
||||
if (newState != _lastState) {
|
||||
_stateChangeTime = now;
|
||||
}
|
||||
|
||||
// Debounce check
|
||||
if ((now - _stateChangeTime) > BUTTON_DEBOUNCE_TIME_MS) {
|
||||
if (newState != _currentState) {
|
||||
_currentState = newState;
|
||||
handleStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
_lastState = newState;
|
||||
|
||||
// Handle multi-click timeout
|
||||
if (_state == WAITING_FOR_MULTI_CLICK && (now - _releaseTime) > BUTTON_CLICK_TIMEOUT_MS) {
|
||||
// Timeout reached, process the clicks
|
||||
if (_clickCount == 1) {
|
||||
triggerEvent(SHORT_PRESS);
|
||||
} else if (_clickCount == 2) {
|
||||
triggerEvent(DOUBLE_PRESS);
|
||||
} else if (_clickCount == 3) {
|
||||
triggerEvent(TRIPLE_PRESS);
|
||||
} else if (_clickCount >= 4) {
|
||||
triggerEvent(QUADRUPLE_PRESS);
|
||||
}
|
||||
|
||||
_clickCount = 0;
|
||||
_state = IDLE;
|
||||
}
|
||||
|
||||
// Handle long press while button is held
|
||||
if (_state == PRESSED && (now - _pressTime) > BUTTON_LONG_PRESS_TIME_MS) {
|
||||
triggerEvent(LONG_PRESS);
|
||||
_state = IDLE; // Prevent multiple press events
|
||||
_clickCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool Button::readButton() {
|
||||
if (_isAnalog) {
|
||||
return (analogRead(_pin) < _analogThreshold);
|
||||
} else {
|
||||
return (digitalRead(_pin) == _activeState);
|
||||
}
|
||||
}
|
||||
|
||||
void Button::handleStateChange() {
|
||||
uint32_t now = millis();
|
||||
|
||||
if (_currentState) {
|
||||
// Button pressed
|
||||
_pressTime = now;
|
||||
_state = PRESSED;
|
||||
triggerEvent(ANY_PRESS);
|
||||
} else {
|
||||
// Button released
|
||||
if (_state == PRESSED) {
|
||||
uint32_t pressDuration = now - _pressTime;
|
||||
|
||||
if (pressDuration < BUTTON_LONG_PRESS_TIME_MS) {
|
||||
// Short press detected
|
||||
_clickCount++;
|
||||
_releaseTime = now;
|
||||
_state = WAITING_FOR_MULTI_CLICK;
|
||||
} else {
|
||||
// Long press already handled in update()
|
||||
_state = IDLE;
|
||||
_clickCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Button::triggerEvent(EventType event) {
|
||||
_lastEvent = event;
|
||||
|
||||
switch (event) {
|
||||
case ANY_PRESS:
|
||||
if (_onAnyPress) _onAnyPress();
|
||||
break;
|
||||
case SHORT_PRESS:
|
||||
if (_onShortPress) _onShortPress();
|
||||
break;
|
||||
case DOUBLE_PRESS:
|
||||
if (_onDoublePress) _onDoublePress();
|
||||
break;
|
||||
case TRIPLE_PRESS:
|
||||
if (_onTriplePress) _onTriplePress();
|
||||
break;
|
||||
case QUADRUPLE_PRESS:
|
||||
if (_onQuadruplePress) _onQuadruplePress();
|
||||
break;
|
||||
case LONG_PRESS:
|
||||
if (_onLongPress) _onLongPress();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
80
examples/beacon_sensor/ui-orig/Button.h
Normal file
80
examples/beacon_sensor/ui-orig/Button.h
Normal file
@@ -0,0 +1,80 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <functional>
|
||||
|
||||
// Button timing configuration
|
||||
#define BUTTON_DEBOUNCE_TIME_MS 50 // Debounce time in ms
|
||||
#define BUTTON_CLICK_TIMEOUT_MS 500 // Max time between clicks for multi-click
|
||||
#define BUTTON_LONG_PRESS_TIME_MS 3000 // Time to trigger long press (3 seconds)
|
||||
#define BUTTON_READ_INTERVAL_MS 10 // How often to read the button
|
||||
|
||||
class Button {
|
||||
public:
|
||||
enum EventType {
|
||||
NONE,
|
||||
SHORT_PRESS,
|
||||
DOUBLE_PRESS,
|
||||
TRIPLE_PRESS,
|
||||
QUADRUPLE_PRESS,
|
||||
LONG_PRESS,
|
||||
ANY_PRESS
|
||||
};
|
||||
|
||||
using EventCallback = std::function<void()>;
|
||||
|
||||
Button(uint8_t pin, bool activeState = LOW);
|
||||
Button(uint8_t pin, bool activeState, bool isAnalog, uint16_t analogThreshold = 20);
|
||||
|
||||
void begin();
|
||||
void update();
|
||||
|
||||
// Set callbacks for different events
|
||||
void onShortPress(EventCallback callback) { _onShortPress = callback; }
|
||||
void onDoublePress(EventCallback callback) { _onDoublePress = callback; }
|
||||
void onTriplePress(EventCallback callback) { _onTriplePress = callback; }
|
||||
void onQuadruplePress(EventCallback callback) { _onQuadruplePress = callback; }
|
||||
void onLongPress(EventCallback callback) { _onLongPress = callback; }
|
||||
void onAnyPress(EventCallback callback) { _onAnyPress = callback; }
|
||||
|
||||
// State getters
|
||||
bool isPressed() const { return _currentState; }
|
||||
EventType getLastEvent() const { return _lastEvent; }
|
||||
|
||||
private:
|
||||
enum State {
|
||||
IDLE,
|
||||
PRESSED,
|
||||
RELEASED,
|
||||
WAITING_FOR_MULTI_CLICK
|
||||
};
|
||||
|
||||
uint8_t _pin;
|
||||
bool _activeState;
|
||||
bool _isAnalog;
|
||||
uint16_t _analogThreshold;
|
||||
|
||||
State _state = IDLE;
|
||||
bool _currentState;
|
||||
bool _lastState;
|
||||
|
||||
uint32_t _stateChangeTime = 0;
|
||||
uint32_t _pressTime = 0;
|
||||
uint32_t _releaseTime = 0;
|
||||
uint32_t _lastReadTime = 0;
|
||||
|
||||
uint8_t _clickCount = 0;
|
||||
EventType _lastEvent = NONE;
|
||||
|
||||
// Callbacks
|
||||
EventCallback _onShortPress = nullptr;
|
||||
EventCallback _onDoublePress = nullptr;
|
||||
EventCallback _onTriplePress = nullptr;
|
||||
EventCallback _onQuadruplePress = nullptr;
|
||||
EventCallback _onLongPress = nullptr;
|
||||
EventCallback _onAnyPress = nullptr;
|
||||
|
||||
bool readButton();
|
||||
void handleStateChange();
|
||||
void triggerEvent(EventType event);
|
||||
};
|
||||
446
examples/beacon_sensor/ui-orig/UITask.cpp
Normal file
446
examples/beacon_sensor/ui-orig/UITask.cpp
Normal file
@@ -0,0 +1,446 @@
|
||||
#include "UITask.h"
|
||||
#include <Arduino.h>
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include "../MyMesh.h"
|
||||
|
||||
#define AUTO_OFF_MILLIS 15000 // 15 seconds
|
||||
#define BOOT_SCREEN_MILLIS 3000 // 3 seconds
|
||||
|
||||
#ifdef PIN_STATUS_LED
|
||||
#define LED_ON_MILLIS 20
|
||||
#define LED_ON_MSG_MILLIS 200
|
||||
#define LED_CYCLE_MILLIS 4000
|
||||
#endif
|
||||
|
||||
#ifndef USER_BTN_PRESSED
|
||||
#define USER_BTN_PRESSED LOW
|
||||
#endif
|
||||
|
||||
// 'meshcore', 128x13px
|
||||
static const uint8_t meshcore_logo [] PROGMEM = {
|
||||
0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe,
|
||||
0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe,
|
||||
0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc,
|
||||
0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00,
|
||||
0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00,
|
||||
0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8,
|
||||
0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8,
|
||||
0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0,
|
||||
0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00,
|
||||
0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00,
|
||||
0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8,
|
||||
0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8,
|
||||
0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8,
|
||||
};
|
||||
|
||||
void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) {
|
||||
_display = display;
|
||||
_sensors = sensors;
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
clearMsgPreview();
|
||||
_node_prefs = node_prefs;
|
||||
if (_display != NULL) {
|
||||
_display->turnOn();
|
||||
}
|
||||
|
||||
// strip off dash and commit hash by changing dash to null terminator
|
||||
// e.g: v1.2.3-abcdef -> v1.2.3
|
||||
char *version = strdup(FIRMWARE_VERSION);
|
||||
char *dash = strchr(version, '-');
|
||||
if (dash) {
|
||||
*dash = 0;
|
||||
}
|
||||
|
||||
// v1.2.3 (1 Jan 2025)
|
||||
sprintf(_version_info, "%s (%s)", version, FIRMWARE_BUILD_DATE);
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
buzzer.begin();
|
||||
buzzer.quiet(_node_prefs->buzzer_quiet);
|
||||
#endif
|
||||
|
||||
// Initialize digital button if available
|
||||
#ifdef PIN_USER_BTN
|
||||
_userButton = new Button(PIN_USER_BTN, USER_BTN_PRESSED);
|
||||
_userButton->begin();
|
||||
|
||||
// Set up digital button callbacks
|
||||
_userButton->onShortPress([this]() { handleButtonShortPress(); });
|
||||
_userButton->onDoublePress([this]() { handleButtonDoublePress(); });
|
||||
_userButton->onTriplePress([this]() { handleButtonTriplePress(); });
|
||||
_userButton->onQuadruplePress([this]() { handleButtonQuadruplePress(); });
|
||||
_userButton->onLongPress([this]() { handleButtonLongPress(); });
|
||||
_userButton->onAnyPress([this]() { handleButtonAnyPress(); });
|
||||
#endif
|
||||
|
||||
// Initialize analog button if available
|
||||
#ifdef PIN_USER_BTN_ANA
|
||||
_userButtonAnalog = new Button(PIN_USER_BTN_ANA, USER_BTN_PRESSED, true, 20);
|
||||
_userButtonAnalog->begin();
|
||||
|
||||
// Set up analog button callbacks
|
||||
_userButtonAnalog->onShortPress([this]() { handleButtonShortPress(); });
|
||||
_userButtonAnalog->onDoublePress([this]() { handleButtonDoublePress(); });
|
||||
_userButtonAnalog->onTriplePress([this]() { handleButtonTriplePress(); });
|
||||
_userButtonAnalog->onQuadruplePress([this]() { handleButtonQuadruplePress(); });
|
||||
_userButtonAnalog->onLongPress([this]() { handleButtonLongPress(); });
|
||||
_userButtonAnalog->onAnyPress([this]() { handleButtonAnyPress(); });
|
||||
#endif
|
||||
ui_started_at = millis();
|
||||
}
|
||||
|
||||
void UITask::notify(UIEventType t) {
|
||||
#if defined(PIN_BUZZER)
|
||||
switch(t){
|
||||
case UIEventType::contactMessage:
|
||||
// gemini's pick
|
||||
buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7");
|
||||
break;
|
||||
case UIEventType::channelMessage:
|
||||
buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#");
|
||||
break;
|
||||
case UIEventType::ack:
|
||||
buzzer.play("ack:d=32,o=8,b=120:c");
|
||||
break;
|
||||
case UIEventType::roomMessage:
|
||||
case UIEventType::newContactMessage:
|
||||
case UIEventType::none:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
// Serial.print("DBG: Alert user -> ");
|
||||
// Serial.println((int) t);
|
||||
}
|
||||
|
||||
void UITask::msgRead(int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
if (msgcount == 0) {
|
||||
clearMsgPreview();
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::clearMsgPreview() {
|
||||
_origin[0] = 0;
|
||||
_msg[0] = 0;
|
||||
_need_refresh = true;
|
||||
}
|
||||
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
|
||||
if (path_len == 0xFF) {
|
||||
sprintf(_origin, "(F) %s", from_name);
|
||||
} else {
|
||||
sprintf(_origin, "(%d) %s", (uint32_t) path_len, from_name);
|
||||
}
|
||||
StrHelper::strncpy(_msg, text, sizeof(_msg));
|
||||
|
||||
if (_display != NULL) {
|
||||
if (!_display->isOn() && !hasConnection()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
if (_display->isOn()) {
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer
|
||||
_need_refresh = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::renderBatteryIndicator(uint16_t batteryMilliVolts) {
|
||||
// Convert millivolts to percentage
|
||||
#ifndef BATT_MIN_MILLIVOLTS
|
||||
#define BATT_MIN_MILLIVOLTS 3000
|
||||
#endif
|
||||
#ifndef BATT_MAX_MILLIVOLTS
|
||||
#define BATT_MAX_MILLIVOLTS 4200
|
||||
#endif
|
||||
const int minMilliVolts = BATT_MIN_MILLIVOLTS;
|
||||
const int maxMilliVolts = BATT_MAX_MILLIVOLTS;
|
||||
int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
|
||||
if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
|
||||
if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
|
||||
|
||||
// battery icon
|
||||
int iconWidth = 24;
|
||||
int iconHeight = 12;
|
||||
int iconX = _display->width() - iconWidth - 5; // Position the icon near the top-right corner
|
||||
int iconY = 0;
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
|
||||
// battery outline
|
||||
_display->drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
|
||||
// battery "cap"
|
||||
_display->fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2);
|
||||
|
||||
// fill the battery based on the percentage
|
||||
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
||||
_display->fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
||||
}
|
||||
|
||||
void UITask::renderCurrScreen() {
|
||||
if (_display == NULL) return; // assert() ??
|
||||
|
||||
char tmp[80];
|
||||
if (_alert[0]) {
|
||||
_display->setTextSize(1.4);
|
||||
uint16_t textWidth = _display->getTextWidth(_alert);
|
||||
_display->setCursor((_display->width() - textWidth) / 2, 22);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->print(_alert);
|
||||
_alert[0] = 0;
|
||||
_need_refresh = true;
|
||||
return;
|
||||
} else if (_origin[0] && _msg[0]) { // message preview
|
||||
// render message preview
|
||||
_display->setCursor(0, 0);
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->print(_node_prefs->node_name);
|
||||
|
||||
_display->setCursor(0, 12);
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
_display->print(_origin);
|
||||
_display->setCursor(0, 24);
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->print(_msg);
|
||||
|
||||
_display->setCursor(_display->width() - 28, 9);
|
||||
_display->setTextSize(2);
|
||||
_display->setColor(DisplayDriver::ORANGE);
|
||||
sprintf(tmp, "%d", _msgcount);
|
||||
_display->print(tmp);
|
||||
_display->setColor(DisplayDriver::YELLOW); // last color will be kept on T114
|
||||
} else if ((millis() - ui_started_at) < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
// meshcore logo
|
||||
_display->setColor(DisplayDriver::BLUE);
|
||||
int logoWidth = 128;
|
||||
_display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
||||
|
||||
// version info
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
_display->setTextSize(1);
|
||||
uint16_t textWidth = _display->getTextWidth(_version_info);
|
||||
_display->setCursor((_display->width() - textWidth) / 2, 22);
|
||||
_display->print(_version_info);
|
||||
} else { // home screen
|
||||
// node name
|
||||
_display->setCursor(0, 0);
|
||||
_display->setTextSize(1);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
_display->print(_node_prefs->node_name);
|
||||
|
||||
// battery voltage
|
||||
renderBatteryIndicator(_board->getBattMilliVolts());
|
||||
|
||||
// freq / sf
|
||||
_display->setCursor(0, 20);
|
||||
_display->setColor(DisplayDriver::YELLOW);
|
||||
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
||||
_display->print(tmp);
|
||||
|
||||
// bw / cr
|
||||
_display->setCursor(0, 30);
|
||||
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
||||
_display->print(tmp);
|
||||
|
||||
// BT pin
|
||||
if (!_connected && the_mesh.getBLEPin() != 0) {
|
||||
_display->setColor(DisplayDriver::RED);
|
||||
_display->setTextSize(2);
|
||||
_display->setCursor(0, 43);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
_display->print(tmp);
|
||||
_display->setColor(DisplayDriver::GREEN);
|
||||
} else {
|
||||
_display->setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
}
|
||||
_need_refresh = false;
|
||||
}
|
||||
|
||||
void UITask::userLedHandler() {
|
||||
#ifdef PIN_STATUS_LED
|
||||
static int state = 0;
|
||||
static int next_change = 0;
|
||||
static int last_increment = 0;
|
||||
|
||||
int cur_time = millis();
|
||||
if (cur_time > next_change) {
|
||||
if (state == 0) {
|
||||
state = 1;
|
||||
if (_msgcount > 0) {
|
||||
last_increment = LED_ON_MSG_MILLIS;
|
||||
} else {
|
||||
last_increment = LED_ON_MILLIS;
|
||||
}
|
||||
next_change = cur_time + last_increment;
|
||||
} else {
|
||||
state = 0;
|
||||
next_change = cur_time + LED_CYCLE_MILLIS - last_increment;
|
||||
}
|
||||
digitalWrite(PIN_STATUS_LED, state == LED_STATE_ON);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/*
|
||||
hardware-agnostic pre-shutdown activity should be done here
|
||||
*/
|
||||
void UITask::shutdown(bool restart){
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
/* note: we have a choice here -
|
||||
we can do a blocking buzzer.loop() with non-deterministic consequences
|
||||
or we can set a flag and delay the shutdown for a couple of seconds
|
||||
while a non-blocking buzzer.loop() plays out in UITask::loop()
|
||||
*/
|
||||
buzzer.shutdown();
|
||||
uint32_t buzzer_timer = millis(); // fail-safe shutdown
|
||||
while (buzzer.isPlaying() && (millis() - 2500) < buzzer_timer)
|
||||
buzzer.loop();
|
||||
|
||||
#endif // PIN_BUZZER
|
||||
|
||||
if (restart) {
|
||||
_board->reboot();
|
||||
} else {
|
||||
radio_driver.powerOff();
|
||||
_board->powerOff();
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::loop() {
|
||||
#ifdef PIN_USER_BTN
|
||||
if (_userButton) {
|
||||
_userButton->update();
|
||||
}
|
||||
#endif
|
||||
#ifdef PIN_USER_BTN_ANA
|
||||
if (_userButtonAnalog) {
|
||||
_userButtonAnalog->update();
|
||||
}
|
||||
#endif
|
||||
userLedHandler();
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
if (buzzer.isPlaying()) buzzer.loop();
|
||||
#endif
|
||||
|
||||
if (_display != NULL && _display->isOn()) {
|
||||
static bool _firstBoot = true;
|
||||
if(_firstBoot && (millis() - ui_started_at) >= BOOT_SCREEN_MILLIS) {
|
||||
_need_refresh = true;
|
||||
_firstBoot = false;
|
||||
}
|
||||
if (millis() >= _next_refresh && _need_refresh) {
|
||||
_display->startFrame();
|
||||
renderCurrScreen();
|
||||
_display->endFrame();
|
||||
|
||||
_next_refresh = millis() + 1000; // refresh every second
|
||||
}
|
||||
if (millis() > _auto_off) {
|
||||
_display->turnOff();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::handleButtonAnyPress() {
|
||||
MESH_DEBUG_PRINTLN("UITask: any press triggered");
|
||||
// called on any button press before other events, to wake up the display quickly
|
||||
// do not refresh the display here, as it may block the button handler
|
||||
if (_display != NULL) {
|
||||
_displayWasOn = _display->isOn(); // Track display state before any action
|
||||
if (!_displayWasOn) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::handleButtonShortPress() {
|
||||
MESH_DEBUG_PRINTLN("UITask: short press triggered");
|
||||
if (_display != NULL) {
|
||||
// Only clear message preview if display was already on before button press
|
||||
if (_displayWasOn) {
|
||||
// If display was on and showing message preview, clear it
|
||||
if (_origin[0] && _msg[0]) {
|
||||
clearMsgPreview();
|
||||
} else {
|
||||
// Otherwise, refresh the display
|
||||
_need_refresh = true;
|
||||
}
|
||||
} else {
|
||||
_need_refresh = true; // display just turned on, so we need to refresh
|
||||
}
|
||||
// Note: Display turn-on and auto-off timer extension are handled by handleButtonAnyPress
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::handleButtonDoublePress() {
|
||||
MESH_DEBUG_PRINTLN("UITask: double press triggered, sending advert");
|
||||
// ADVERT
|
||||
#ifdef PIN_BUZZER
|
||||
notify(UIEventType::ack);
|
||||
#endif
|
||||
if (the_mesh.advert()) {
|
||||
MESH_DEBUG_PRINTLN("Advert sent!");
|
||||
sprintf(_alert, "Advert sent!");
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("Advert failed!");
|
||||
sprintf(_alert, "Advert failed..");
|
||||
}
|
||||
_need_refresh = true;
|
||||
}
|
||||
|
||||
void UITask::handleButtonTriplePress() {
|
||||
MESH_DEBUG_PRINTLN("UITask: triple press triggered");
|
||||
// Toggle buzzer quiet mode
|
||||
#ifdef PIN_BUZZER
|
||||
if (buzzer.isQuiet()) {
|
||||
buzzer.quiet(false);
|
||||
notify(UIEventType::ack);
|
||||
sprintf(_alert, "Buzzer: ON");
|
||||
} else {
|
||||
buzzer.quiet(true);
|
||||
sprintf(_alert, "Buzzer: OFF");
|
||||
}
|
||||
_node_prefs->buzzer_quiet = buzzer.isQuiet();
|
||||
the_mesh.savePrefs();
|
||||
_need_refresh = true;
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::handleButtonQuadruplePress() {
|
||||
MESH_DEBUG_PRINTLN("UITask: quad press triggered");
|
||||
if (_sensors != NULL) {
|
||||
// toggle GPS onn/off
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
||||
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
notify(UIEventType::ack);
|
||||
sprintf(_alert, "GPS: Disabled");
|
||||
} else {
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
notify(UIEventType::ack);
|
||||
sprintf(_alert, "GPS: Enabled");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_need_refresh = true;
|
||||
}
|
||||
|
||||
void UITask::handleButtonLongPress() {
|
||||
MESH_DEBUG_PRINTLN("UITask: long press triggered");
|
||||
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
|
||||
the_mesh.enterCLIRescue();
|
||||
} else {
|
||||
shutdown();
|
||||
}
|
||||
}
|
||||
73
examples/beacon_sensor/ui-orig/UITask.h
Normal file
73
examples/beacon_sensor/ui-orig/UITask.h
Normal file
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include <MeshCore.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
#include <helpers/ui/buzzer.h>
|
||||
#endif
|
||||
|
||||
#include "../AbstractUITask.h"
|
||||
#include "../NodePrefs.h"
|
||||
|
||||
#include "Button.h"
|
||||
|
||||
class UITask : public AbstractUITask {
|
||||
DisplayDriver* _display;
|
||||
SensorManager* _sensors;
|
||||
#ifdef PIN_BUZZER
|
||||
genericBuzzer buzzer;
|
||||
#endif
|
||||
unsigned long _next_refresh, _auto_off;
|
||||
NodePrefs* _node_prefs;
|
||||
char _version_info[32];
|
||||
char _origin[62];
|
||||
char _msg[80];
|
||||
char _alert[80];
|
||||
int _msgcount;
|
||||
bool _need_refresh = true;
|
||||
bool _displayWasOn = false; // Track display state before button press
|
||||
unsigned long ui_started_at;
|
||||
|
||||
// Button handlers
|
||||
#ifdef PIN_USER_BTN
|
||||
Button* _userButton = nullptr;
|
||||
#endif
|
||||
#ifdef PIN_USER_BTN_ANA
|
||||
Button* _userButtonAnalog = nullptr;
|
||||
#endif
|
||||
|
||||
void renderCurrScreen();
|
||||
void userLedHandler();
|
||||
void renderBatteryIndicator(uint16_t batteryMilliVolts);
|
||||
|
||||
// Button action handlers
|
||||
void handleButtonAnyPress();
|
||||
void handleButtonShortPress();
|
||||
void handleButtonDoublePress();
|
||||
void handleButtonTriplePress();
|
||||
void handleButtonQuadruplePress();
|
||||
void handleButtonLongPress();
|
||||
|
||||
|
||||
public:
|
||||
|
||||
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
|
||||
_next_refresh = 0;
|
||||
ui_started_at = 0;
|
||||
}
|
||||
void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
|
||||
|
||||
bool hasDisplay() const { return _display != NULL; }
|
||||
void clearMsgPreview();
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void loop() override;
|
||||
|
||||
void shutdown(bool restart = false);
|
||||
};
|
||||
BIN
flasher/Heltec_T114_Beacon_BLE.dfu.zip
Normal file
BIN
flasher/Heltec_T114_Beacon_BLE.dfu.zip
Normal file
Binary file not shown.
521
flasher/configurator.html
Normal file
521
flasher/configurator.html
Normal file
@@ -0,0 +1,521 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MeshCore Configurator</title>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
:root { --border-radius: 0.5rem; }
|
||||
.nav-bar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; padding: 0.5rem 0; }
|
||||
.nav-bar h1 { margin: 0; font-size: 1.1rem; }
|
||||
pre.term { background: #111; color: #0f0; padding: 0.75rem; border-radius: 0.4rem; max-height: 300px; overflow: auto; font-size: 0.78rem; }
|
||||
.spinner { display: inline-block; width: 1rem; height: 1rem; border: 2px solid var(--primary); border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.config-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
@media (max-width: 768px) { .config-grid { grid-template-columns: 1fr; } }
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
.byte-counter { font-size: 0.75rem; float: right; }
|
||||
.busy-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 999; }
|
||||
.busy-overlay article { text-align: center; }
|
||||
.snackbar { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); background: var(--card-background); padding: 0.75rem 1.5rem; border-radius: 0.5rem; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 1000; display: none; }
|
||||
.snackbar.active { display: block; }
|
||||
.repeat-fieldset { margin-bottom: 1rem; }
|
||||
fieldset { margin-bottom: 1rem; }
|
||||
.unsaved { color: var(--warning); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
.console-output { background: #111; color: #0f0; padding: 0.5rem; border-radius: 0.4rem; max-height: 300px; overflow: auto; font-family: monospace; font-size: 0.78rem; }
|
||||
.console-input-line { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.console-prompt { color: #0f0; }
|
||||
.console-output input { background: transparent; border: none; color: #0f0; font-family: monospace; font-size: 0.78rem; outline: none; flex: 1; }
|
||||
.leaflet-container { height: 300px; border-radius: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container" id="app">
|
||||
<nav class="nav-bar">
|
||||
<h1>⚙ MeshCore Configurator</h1>
|
||||
<span v-if="app.busy" class="spinner"></span>
|
||||
<span style="flex:1"></span>
|
||||
<a href="./index.html" style="font-size:0.9rem;">← Flasher</a>
|
||||
</nav>
|
||||
|
||||
<div v-if="app.busy" class="busy-overlay">
|
||||
<article>
|
||||
<progress indeterminate></progress>
|
||||
<p>{{ app.busy }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="snackbar" :class="{ active: snackbar.show }">{{ snackbar.text }}</div>
|
||||
|
||||
<!-- Connect / Disconnect -->
|
||||
<article v-if="!app.connected">
|
||||
<header><strong>💻 Подключение к устройству</strong></header>
|
||||
<p>Нажми «Подключиться» и выбери порт T114 в окне браузера.</p>
|
||||
<button @click="connect" :disabled="app.connecting" class="contrast">
|
||||
<span v-if="app.connecting"><span class="spinner"></span> Подключение...</span>
|
||||
<span v-else>⚙ Подключиться</span>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<!-- Configuration form -->
|
||||
<div v-else>
|
||||
<div class="grid" style="margin-bottom: 1rem;">
|
||||
<button @click="disconnect" class="secondary">✕ Отключиться</button>
|
||||
<button @click="refreshData" class="outline">↻ Обновить</button>
|
||||
<button @click="reboot" class="outline">↻ Reboot</button>
|
||||
<button @click="eraseConfirm" class="outline" style="color:var(--error)">🗑 Factory reset</button>
|
||||
</div>
|
||||
|
||||
<div class="grid" style="margin-bottom: 1rem;">
|
||||
<article>
|
||||
<strong>Версия:</strong> {{ app.device.version || '—' }}
|
||||
</article>
|
||||
<article>
|
||||
<strong>Роль:</strong> <code>{{ app.device.role || '—' }}</code>
|
||||
</article>
|
||||
<article>
|
||||
<strong>Clock:</strong> {{ app.device.clock || '—' }}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article>
|
||||
<header><strong>Public Key</strong></header>
|
||||
<code style="word-break:break-all;">{{ app.device.pubKey || '—' }}</code>
|
||||
</article>
|
||||
|
||||
<div class="config-grid">
|
||||
<!-- Name & Location -->
|
||||
<fieldset class="full-width repeat-fieldset">
|
||||
<legend>Name & Location</legend>
|
||||
<div class="field border label">
|
||||
<input placeholder=" " :value="app.device.vars.name" @input="onNameInput">
|
||||
<label>Name</label>
|
||||
</div>
|
||||
<div><span class="byte-counter" :style="{ color: nameBytes > nameMaxBytes ? 'var(--error)' : 'var(--muted-color)' }">{{ nameBytes }} / {{ nameMaxBytes }} bytes</span></div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Latitude</label>
|
||||
<input type="text" v-model="app.device.vars.lat" pattern="-?[0-9]{1,2}([.][0-9]{1,6})?">
|
||||
</div>
|
||||
<div>
|
||||
<label>Longitude</label>
|
||||
<input type="text" v-model="app.device.vars.lon" pattern="-?[0-9]{1,3}([.][0-9]{1,6})?">
|
||||
</div>
|
||||
<div style="display:flex;align-items:end;">
|
||||
<button class="secondary" @click="showMap" style="width:100%;">🗺 Карта</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Access -->
|
||||
<fieldset class="full-width repeat-fieldset">
|
||||
<legend>Access</legend>
|
||||
<div>
|
||||
<label>New Admin password</label>
|
||||
<input type="password" v-model="app.newPassword" placeholder="Оставь пустым, чтобы не менять">
|
||||
</div>
|
||||
<div>
|
||||
<label>Guest password</label>
|
||||
<input v-model="app.device.vars['guest.password']">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Radio settings -->
|
||||
<fieldset class="full-width repeat-fieldset">
|
||||
<legend>Radio settings</legend>
|
||||
<div>
|
||||
<label>Preset</label>
|
||||
<select @change="setRadioPreset($event.target.value)">
|
||||
<option v-for="(p, i) in presets" :selected="p === activePreset" :value="i">{{ p.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Frequency (MHz)</label>
|
||||
<input type="number" v-model="app.device.vars.radio.freq" step="0.001">
|
||||
</div>
|
||||
<div>
|
||||
<label>Bandwidth (kHz)</label>
|
||||
<select v-model="app.device.vars.radio.bw">
|
||||
<option>7.8</option><option>10.4</option><option>15.6</option><option>20.8</option>
|
||||
<option>31.25</option><option>41.7</option><option>62.5</option>
|
||||
<option>125</option><option>250</option><option>500</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Spreading factor</label>
|
||||
<select v-model="app.device.vars.radio.sf">
|
||||
<option>7</option><option>8</option><option>9</option><option>10</option><option>11</option><option>12</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Coding rate</label>
|
||||
<select v-model="app.device.vars.radio.cr">
|
||||
<option>5</option><option>6</option><option>7</option><option>8</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>TX Power (dBm)</label>
|
||||
<input type="number" v-model="app.device.vars.tx" min="1" max="22">
|
||||
</div>
|
||||
<div>
|
||||
<label>Duty cycle (%)</label>
|
||||
<input type="number" v-model="dutyCycle" min="1" max="50">
|
||||
<small>Airtime factor: {{ app.device.vars.af }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Advertising -->
|
||||
<fieldset class="full-width repeat-fieldset">
|
||||
<legend>Advertising</legend>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Advert interval (min, 0=off)</label>
|
||||
<input type="number" v-model="app.device.vars['advert.interval']" min="0" max="240">
|
||||
</div>
|
||||
<div>
|
||||
<label>Flood advert interval (hrs)</label>
|
||||
<input type="number" v-model="app.device.vars['flood.advert.interval']" min="0" max="168">
|
||||
</div>
|
||||
<div>
|
||||
<label>Flood max hops</label>
|
||||
<input type="number" v-model="app.device.vars['flood.max']" min="0" max="64">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label><input type="checkbox" v-model="app.device.vars.repeat"> Repeater mode</label>
|
||||
<label v-if="app.device.role === 'room-server'"><input type="checkbox" v-model="app.device.vars['allow.read.only']"> Read only</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Advanced -->
|
||||
<fieldset class="full-width repeat-fieldset">
|
||||
<legend><label><input type="checkbox" v-model="app.showAdvanced"> Advanced settings</label></legend>
|
||||
<div v-if="app.showAdvanced">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Loop detection</label>
|
||||
<select v-model="app.device.vars['loop.detect']">
|
||||
<option value="off">Off</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="moderate">Moderate</option>
|
||||
<option value="strict">Strict</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Path hash mode</label>
|
||||
<select v-model="app.device.vars['path.hash.mode']">
|
||||
<option value="0">1-byte (0)</option>
|
||||
<option value="1">2-byte (1)</option>
|
||||
<option value="2">3-byte (2)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>RX delay base</label>
|
||||
<input type="number" v-model="app.device.vars.rxdelay" min="0" max="20" step="0.1">
|
||||
</div>
|
||||
<div>
|
||||
<label>TX delay factor</label>
|
||||
<input type="number" v-model="app.device.vars.txdelay" min="0" max="2" step="0.1">
|
||||
</div>
|
||||
<div>
|
||||
<label>Direct TX delay</label>
|
||||
<input type="number" v-model="app.device.vars['direct.txdelay']" min="0" max="2" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Interference threshold</label>
|
||||
<input type="number" v-model="app.device.vars['int.thresh']" min="0" max="255">
|
||||
</div>
|
||||
<div>
|
||||
<label>AGC reset interval</label>
|
||||
<input type="number" v-model="app.device.vars['agc.reset.interval']" min="0" step="4">
|
||||
</div>
|
||||
</div>
|
||||
<label><input type="checkbox" v-model="multiAcks"> Multi ACKs</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div v-if="hasChanges" class="unsaved">ⓘ Есть несохранённые изменения</div>
|
||||
<button @click="saveData" :disabled="app.locked" class="contrast" style="width:100%;">💾 Save settings</button>
|
||||
</div>
|
||||
|
||||
<footer style="margin-top:2rem;text-align:center;font-size:0.8rem;color:var(--muted-color);">
|
||||
<a href="./index.html">← Назад к Flasher</a>
|
||||
</footer>
|
||||
|
||||
<!-- Map dialog -->
|
||||
<dialog id="mapDialog">
|
||||
<article>
|
||||
<header><strong>Choose location from map</strong> <a href="#" @click.prevent="closeMap">✕</a></header>
|
||||
<button @click="requestLocation" class="secondary">📍 Request location</button>
|
||||
<div id="map" class="leaflet-container"></div>
|
||||
<footer>
|
||||
<a href="#" @click.prevent="setMapLatLon" class="contrast">Set location</a>
|
||||
<a href="#" @click.prevent="closeMap">Cancel</a>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
import { SerialCLI } from './lib/serial-cli.js';
|
||||
|
||||
const UTF8 = new TextEncoder();
|
||||
|
||||
const app = Vue.createApp({
|
||||
data() {
|
||||
return {
|
||||
app: {
|
||||
connecting: false,
|
||||
connected: false,
|
||||
locked: false,
|
||||
busy: '',
|
||||
showAdvanced: false,
|
||||
newPassword: '',
|
||||
device: {
|
||||
version: '', clock: '', role: '', pubKey: '', prvKey: '',
|
||||
vars: {
|
||||
name: '', repeat: true, 'allow.read.only': false,
|
||||
radio: { freq: 868.731, sf: 7, cr: 7, bw: '62.5' },
|
||||
tx: 22, af: 1,
|
||||
rxdelay: 0, txdelay: 0.5, 'direct.txdelay': 0.2,
|
||||
'flood.max': 64, 'flood.advert.interval': 0, 'advert.interval': 0,
|
||||
'guest.password': '',
|
||||
lat: 0, lon: 0,
|
||||
'int.thresh': 0, 'agc.reset.interval': 0,
|
||||
'multi.acks': 0, 'owner.info': '',
|
||||
'path.hash.mode': 0, 'loop.detect': 'off',
|
||||
},
|
||||
varsDevice: {},
|
||||
},
|
||||
},
|
||||
cli: null,
|
||||
snackbar: { show: false, text: '' },
|
||||
presets: [{ title: 'Custom' }],
|
||||
map: null, marker: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
nameBytes() { return UTF8.encode(String(this.app.device.vars.name || '')).length; },
|
||||
nameMaxBytes() {
|
||||
const lat = Number(this.app.device.vars.lat);
|
||||
const lon = Number(this.app.device.vars.lon);
|
||||
return (lat !== 0 || lon !== 0) ? 24 : 32;
|
||||
},
|
||||
dutyCycle: {
|
||||
get() { const af = Number(this.app.device.vars.af) || 0; return Math.round(100 / (af + 1)); },
|
||||
set(v) { const dc = Number(v); if (dc >= 1 && dc <= 50) this.app.device.vars.af = ((100 / dc) - 1).toFixed(1); },
|
||||
},
|
||||
multiAcks: {
|
||||
get() { return this.app.device.vars['multi.acks'] == 1; },
|
||||
set(v) { this.app.device.vars['multi.acks'] = v ? 1 : 0; },
|
||||
},
|
||||
activePreset() {
|
||||
const r = this.app.device.vars.radio;
|
||||
return this.presets.find(p =>
|
||||
Number(p.frequency) == r.freq && Number(p.spreading_factor) == r.sf &&
|
||||
Number(p.bandwidth) == r.bw && Number(p.coding_rate) == r.cr
|
||||
) || this.presets[0];
|
||||
},
|
||||
hasChanges() {
|
||||
const v = this.app.device.vars;
|
||||
const vd = this.app.device.varsDevice;
|
||||
for (const k of Object.keys(v)) {
|
||||
if (!(k in vd)) continue;
|
||||
if (JSON.stringify(v[k]) !== JSON.stringify(vd[k])) return true;
|
||||
}
|
||||
return !!this.app.newPassword;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
showMsg(text, ms) {
|
||||
this.snackbar = { show: true, text };
|
||||
setTimeout(() => { this.snackbar.show = false; }, ms || 2000);
|
||||
},
|
||||
async connect() {
|
||||
this.app.connecting = true;
|
||||
try {
|
||||
this.cli = new SerialCLI();
|
||||
await this.cli.connect(115200);
|
||||
await this.cli.setTime(Math.floor(Date.now() / 1000));
|
||||
this.app.connected = true;
|
||||
await this.loadData();
|
||||
await this.loadPresets();
|
||||
} catch (e) {
|
||||
alert(`Connect error: ${e.message}`);
|
||||
} finally {
|
||||
this.app.connecting = false;
|
||||
}
|
||||
},
|
||||
async disconnect() {
|
||||
if (this.cli) await this.cli.disconnect();
|
||||
this.cli = null;
|
||||
this.app.connected = false;
|
||||
},
|
||||
async loadData() {
|
||||
this.app.busy = 'Reading configuration...';
|
||||
const c = this.cli;
|
||||
const v = this.app.device.vars;
|
||||
const vd = this.app.device.varsDevice;
|
||||
try {
|
||||
this.app.device.version = await c.getVersion();
|
||||
this.app.device.clock = await c.getClock();
|
||||
this.app.device.role = await c.sendCommand('get role');
|
||||
this.app.device.role = c.parseVariableResponse(this.app.device.role);
|
||||
this.app.device.pubKey = await c.sendCommand('get public.key');
|
||||
this.app.device.pubKey = c.parseVariableResponse(this.app.device.pubKey);
|
||||
try {
|
||||
const pk = await c.getVariable('prv.key');
|
||||
this.app.device.prvKey = c.parseVariableResponse(pk);
|
||||
} catch {}
|
||||
for (const key of Object.keys(v)) {
|
||||
try {
|
||||
const resp = await c.getVariable(key);
|
||||
let val = c.parseVariableResponse(resp);
|
||||
if (val === null) continue;
|
||||
if (key === 'radio') {
|
||||
const parts = String(val).split(',');
|
||||
val = { freq: Number(parts[0]).toFixed(3), bw: parts[1].replace('.0',''), sf: parts[2], cr: parts[3] };
|
||||
}
|
||||
if (['rxdelay','txdelay','direct.txdelay'].includes(key)) val = Math.round(Number(val) * 10) / 10;
|
||||
if (['lat','lon'].includes(key)) val = Math.round(Number(val) * 100000) / 100000;
|
||||
if (key === 'loop.detect') val = val === false ? 'off' : String(val);
|
||||
if (key === 'multi.acks') val = String(Number(val));
|
||||
v[key] = val;
|
||||
vd[key] = typeof val === 'object' ? { ...val } : val;
|
||||
} catch {}
|
||||
}
|
||||
} finally {
|
||||
this.app.busy = '';
|
||||
}
|
||||
},
|
||||
async refreshData() {
|
||||
await this.loadData();
|
||||
this.showMsg('Configuration reloaded');
|
||||
},
|
||||
async saveData() {
|
||||
this.app.locked = true;
|
||||
this.app.busy = 'Saving...';
|
||||
const c = this.cli;
|
||||
const v = this.app.device.vars;
|
||||
const vd = this.app.device.varsDevice;
|
||||
try {
|
||||
let needsReboot = false;
|
||||
const rebootKeys = new Set(['radio', 'prv.key']);
|
||||
for (const key of Object.keys(v)) {
|
||||
if (!(key in vd)) continue;
|
||||
if (JSON.stringify(v[key]) === JSON.stringify(vd[key])) continue;
|
||||
let val = v[key];
|
||||
if (rebootKeys.has(key)) needsReboot = true;
|
||||
if (key === 'repeat' || key === 'allow.read.only') val = val ? 'on' : 'off';
|
||||
if (key === 'radio') val = `${v.radio.freq},${v.radio.bw}.0,${v.radio.sf},${v.radio.cr}`;
|
||||
await c.setVariable(key, val);
|
||||
}
|
||||
if (this.app.newPassword) {
|
||||
await c.sendCommand(`password ${this.app.newPassword}`);
|
||||
this.app.newPassword = '';
|
||||
}
|
||||
await this.loadData();
|
||||
this.showMsg('Settings saved!', 3000);
|
||||
if (needsReboot && confirm('Some changes require a reboot. Reboot now?')) {
|
||||
await c.reboot();
|
||||
this.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
alert(`Save error: ${e.message}`);
|
||||
} finally {
|
||||
this.app.busy = '';
|
||||
this.app.locked = false;
|
||||
}
|
||||
},
|
||||
async loadPresets() {
|
||||
try {
|
||||
const res = await fetch('https://api.meshcore.nz/api/v1/config');
|
||||
const data = await res.json();
|
||||
this.presets = [{ title: 'Custom' }, ...data.config.suggested_radio_settings.entries];
|
||||
} catch {}
|
||||
},
|
||||
setRadioPreset(idx) {
|
||||
const p = this.presets[idx];
|
||||
if (!p.frequency) return;
|
||||
const r = this.app.device.vars.radio;
|
||||
r.freq = p.frequency;
|
||||
r.sf = p.spreading_factor;
|
||||
r.bw = p.bandwidth;
|
||||
r.cr = p.coding_rate;
|
||||
},
|
||||
onNameInput(e) {
|
||||
const text = e.target.value;
|
||||
if (UTF8.encode(text).length <= this.nameMaxBytes) {
|
||||
this.app.device.vars.name = text;
|
||||
} else {
|
||||
e.target.value = this.app.device.vars.name;
|
||||
}
|
||||
},
|
||||
async reboot() {
|
||||
if (!confirm('Reboot device?')) return;
|
||||
await this.cli.reboot();
|
||||
this.disconnect();
|
||||
},
|
||||
async eraseConfirm() {
|
||||
if (!confirm('Factory reset? All data will be lost!')) return;
|
||||
await this.cli.erase();
|
||||
await this.cli.reboot();
|
||||
this.disconnect();
|
||||
},
|
||||
showMap() {
|
||||
const d = document.getElementById('mapDialog');
|
||||
d.showModal();
|
||||
this.$nextTick(() => {
|
||||
if (!this.map) this.initMap();
|
||||
const v = this.app.device.vars;
|
||||
this.map.setView([v.lat || 0, v.lon || 0], 2);
|
||||
this.marker.setLatLng([v.lat || 0, v.lon || 0]);
|
||||
setTimeout(() => this.map.invalidateSize(), 100);
|
||||
});
|
||||
},
|
||||
initMap() {
|
||||
this.map = L.map('map', { maxBounds: [[-90, -180], [90, 200]] });
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(this.map);
|
||||
this.marker = L.marker([0, 0]).addTo(this.map);
|
||||
this.map.on('click', (e) => this.marker.setLatLng(e.latlng));
|
||||
},
|
||||
requestLocation() {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
this.marker.setLatLng([pos.coords.latitude, pos.coords.longitude]);
|
||||
this.map.setView([pos.coords.latitude, pos.coords.longitude], 7);
|
||||
},
|
||||
() => alert('Location access denied')
|
||||
);
|
||||
},
|
||||
setMapLatLon() {
|
||||
const pos = this.marker.getLatLng();
|
||||
this.app.device.vars.lat = pos.lat.toFixed(5);
|
||||
this.app.device.vars.lon = pos.lng.toFixed(5);
|
||||
this.closeMap();
|
||||
},
|
||||
closeMap() {
|
||||
document.getElementById('mapDialog').close();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
152
flasher/create_dfu_zip.py
Normal file
152
flasher/create_dfu_zip.py
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert .hex or .uf2 to nRF52 DFU .zip for Web Serial flashing.
|
||||
|
||||
Usage:
|
||||
python3 create_dfu_zip.py firmware.hex firmware.dfu.zip
|
||||
python3 create_dfu_zip.py firmware.uf2 firmware.dfu.zip
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import struct
|
||||
import zipfile
|
||||
import hashlib
|
||||
|
||||
def hex_to_bin(hex_path):
|
||||
"""Convert Intel HEX to raw binary."""
|
||||
from intelhex import IntelHex
|
||||
ih = IntelHex(hex_path)
|
||||
# Use the full address range or just the used portion
|
||||
min_addr = ih.minaddr() if ih.minaddr() is not None else 0
|
||||
max_addr = ih.maxaddr() if ih.maxaddr() is not None else 0
|
||||
size = max_addr - min_addr + 1
|
||||
# Align to page size (4KB for nRF52840)
|
||||
page_size = 0x1000
|
||||
aligned_size = ((size + page_size - 1) // page_size) * page_size
|
||||
data = ih.tobinarray(start=min_addr, size=aligned_size)
|
||||
return bytes(data), min_addr
|
||||
|
||||
def uf2_to_bin(uf2_path):
|
||||
"""Convert UF2 to raw binary."""
|
||||
FAMILY_NRF52840 = 0xADA52840
|
||||
data = {}
|
||||
with open(uf2_path, 'rb') as f:
|
||||
while True:
|
||||
block = f.read(512)
|
||||
if not block or len(block) < 512:
|
||||
break
|
||||
magic0, magic1, flags, addr, size, seq, num = struct.unpack_from('<IIIIIII', block, 0)
|
||||
if magic0 != 0x0A324655 or magic1 != 0x9E5D5157:
|
||||
continue
|
||||
payload = block[32:32+size]
|
||||
for i in range(0, len(payload), 4):
|
||||
word = struct.unpack_from('<I', payload, i)[0]
|
||||
offset = addr + i
|
||||
data[offset] = word
|
||||
if not data:
|
||||
raise ValueError("No valid UF2 blocks found")
|
||||
addresses = sorted(data.keys())
|
||||
min_addr = addresses[0] & ~0xFFF # align to 4K
|
||||
max_addr = addresses[-1]
|
||||
page_size = 0x1000
|
||||
size = max_addr - min_addr + 1
|
||||
aligned_size = ((size + page_size - 1) // page_size) * page_size
|
||||
bin_data = bytearray(aligned_size)
|
||||
for addr, word in data.items():
|
||||
offset = addr - min_addr
|
||||
if offset + 4 <= len(bin_data):
|
||||
struct.pack_into('<I', bin_data, offset, word)
|
||||
return bytes(bin_data), min_addr
|
||||
|
||||
def create_dfu_zip(bin_data, base_addr, fw_version=1, hw_version=52):
|
||||
"""Create nRF DFU zip from binary data."""
|
||||
|
||||
# Build init packet (.dat) per nRF DFU spec
|
||||
# Format (little-endian):
|
||||
# [0x01] - signature (DFU init packet)
|
||||
# [fw_type: 1] - 0x04 = application
|
||||
# [sd_count: 4] - number of SD requirements (1)
|
||||
# [sd_array: sd_count * 4] - SD requirement (0xFFFE = any)
|
||||
# [hw_version: 4]
|
||||
# [fw_version: 4]
|
||||
# [fwid_type: 2] - 0x0001 = SHA-256 (not used, set to 0)
|
||||
# [fwid_length: 2]
|
||||
# [fwid: fwid_length]
|
||||
|
||||
# Simpler init packet format as expected by dfu.js:
|
||||
# It expects init_packet_data with components.data array
|
||||
|
||||
init_packet = bytearray()
|
||||
init_packet.append(0x01) # signature
|
||||
init_packet.append(0x04) # fw_type: application
|
||||
# softdevice requirements
|
||||
init_packet += struct.pack('<I', 0xFFFE) # SD required: any
|
||||
init_packet += struct.pack('<I', 0xFFFFFFFF) # terminator
|
||||
init_packet += struct.pack('<I', hw_version) # HW version
|
||||
init_packet += struct.pack('<I', fw_version) # FW version
|
||||
# FWID (empty)
|
||||
init_packet += struct.pack('<II', 0, 0) # type=0, len=0
|
||||
|
||||
# build manifest
|
||||
manifest = {
|
||||
"manifest": {
|
||||
"application": {
|
||||
"bin_file": "firmware.bin",
|
||||
"dat_file": "firmware.dat",
|
||||
"init_packet_data": {
|
||||
"fw_version": fw_version,
|
||||
"hw_version": hw_version,
|
||||
"softdevice_req": [0xFFFE],
|
||||
"components": [
|
||||
{
|
||||
"data": list(init_packet)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return manifest, bin_data, bytes(init_packet)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: create_dfu_zip.py <input.hex|uf2> <output.zip> [fw_version] [hw_version]")
|
||||
sys.exit(1)
|
||||
|
||||
input_path = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
fw_version = int(sys.argv[3]) if len(sys.argv) > 3 else 1
|
||||
hw_version = int(sys.argv[4]) if len(sys.argv) > 4 else 52
|
||||
|
||||
ext = os.path.splitext(input_path)[1].lower()
|
||||
|
||||
if ext == '.hex':
|
||||
try:
|
||||
bin_data, base_addr = hex_to_bin(input_path)
|
||||
except ImportError:
|
||||
print("Error: intelhex package required for .hex files.")
|
||||
print("Install: pip install intelhex")
|
||||
sys.exit(1)
|
||||
elif ext == '.uf2':
|
||||
bin_data, base_addr = uf2_to_bin(input_path)
|
||||
else:
|
||||
print(f"Unsupported format: {ext}")
|
||||
sys.exit(1)
|
||||
|
||||
manifest, fw_bin, fw_dat = create_dfu_zip(bin_data, base_addr, fw_version, hw_version)
|
||||
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("manifest.json", json.dumps(manifest, indent=2))
|
||||
zf.writestr("firmware.bin", fw_bin)
|
||||
zf.writestr("firmware.dat", fw_dat)
|
||||
|
||||
size = os.path.getsize(output_path)
|
||||
print(f"DFU zip created: {output_path}")
|
||||
print(f" Size: {size} bytes ({size/1024:.0f} KB)")
|
||||
print(f" Firmware: {len(fw_bin)} bytes @ 0x{base_addr:08X}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
396
flasher/index.html
Normal file
396
flasher/index.html
Normal file
@@ -0,0 +1,396 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MeshCore Firmware Flasher</title>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<style>
|
||||
:root { --border-radius: 0.5rem; }
|
||||
.badge { font-size: 0.7rem; padding: 0.15rem 0.4rem; border-radius: 0.25rem; background: var(--primary); color: var(--primary-inverse); white-space: nowrap; }
|
||||
.badge-sm { font-size: 0.65rem; padding: 0.1rem 0.3rem; }
|
||||
pre.term { background: #111; color: #0f0; padding: 0.75rem; border-radius: 0.4rem; max-height: 300px; overflow: auto; font-size: 0.78rem; line-height: 1.3; white-space: pre-wrap; word-break: break-all; }
|
||||
.nav-bar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; padding: 0.5rem 0; }
|
||||
.nav-bar h1 { margin: 0; font-size: 1.1rem; }
|
||||
.release-notes { font-size: 0.85rem; background: var(--card-sectionning-background); padding: 0.75rem; border-radius: 0.4rem; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
|
||||
.spinner { display: inline-block; width: 1rem; height: 1rem; border: 2px solid var(--primary); border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.flash-progress { margin: 1rem 0; }
|
||||
.flash-progress progress { width: 100%; }
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--muted-color); }
|
||||
.step-link { cursor: pointer; color: var(--primary); }
|
||||
.step-link:hover { text-decoration: underline; }
|
||||
.file-row { padding: 0.6rem 0; border-bottom: 1px solid var(--card-border-color); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.file-row:last-child { border-bottom: none; }
|
||||
.btn-group { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.release-card { cursor: pointer; transition: opacity 0.15s; }
|
||||
.release-card:hover { opacity: 0.85; }
|
||||
.flash-container { border: 1px solid var(--card-border-color); border-radius: var(--border-radius); padding: 1rem; margin-top: 1rem; }
|
||||
footer { margin-top: 2rem; text-align: center; font-size: 0.8rem; color: var(--muted-color); }
|
||||
.cors-note { font-size: 0.8rem; background: var(--warning-background); color: var(--warning-color); padding: 0.5rem; border-radius: 0.3rem; margin-top: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container" id="app">
|
||||
<nav class="nav-bar">
|
||||
<h1>⚡ MeshCore Flasher</h1>
|
||||
<span v-if="!loading && releases.length" class="badge">{{ releases.length }} релиз(ов)</span>
|
||||
<span v-if="loading" class="spinner"></span>
|
||||
<span v-if="error" style="color: var(--error); font-size: 0.85rem;">{{ error }}</span>
|
||||
<span style="flex:1"></span>
|
||||
<a href="https://git2.ua1zbe.ru/ua1zbe/meshcore-simple-sensor" target="_blank" style="font-size:0.9rem;">🔗 Репозиторий</a>
|
||||
</nav>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav v-if="view !== 'releases'" style="font-size:0.9rem; margin-bottom:0.5rem;">
|
||||
<a href="#" @click.prevent="view = 'releases'; selectedRelease = null; selectedAsset = null">← Все релизы</a>
|
||||
<template v-if="view === 'release' && selectedRelease">
|
||||
<span> / {{ selectedRelease.tag_name }}</span>
|
||||
</template>
|
||||
<template v-if="view === 'flash' && selectedRelease && selectedAsset">
|
||||
<a href="#" @click.prevent="view='release'; selectedAsset=null" class="step-link"> / {{ selectedRelease.tag_name }}</a>
|
||||
<span> / {{ selectedAsset.name }}</span>
|
||||
</template>
|
||||
<template v-if="view === 'console'">
|
||||
<span> / Консоль</span>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Warning if loaded via CORS proxy -->
|
||||
<article v-if="corsProxy" class="cors-note">
|
||||
ⓘ Релизы загружены через CORS-прокси. Для прямой загрузки настрой CORS в Gitea:
|
||||
<code>app.ini: [cors] ENABLED = true; ALLOW_DOMAIN = *</code> или размести flasher на том же домене.
|
||||
</article>
|
||||
|
||||
<!-- Flash view -->
|
||||
<div v-if="view === 'flash' && selectedRelease && selectedAsset" class="flash-container">
|
||||
<hgroup>
|
||||
<h5>{{ selectedAsset.name }}</h5>
|
||||
<p class="size-info">{{ (selectedAsset.size / 1024).toFixed(0) }} KB · {{ selectedRelease.tag_name }}</p>
|
||||
</hgroup>
|
||||
<div v-if="selectedRelease.body" class="release-notes" style="margin-bottom:1rem;">{{ selectedRelease.body }}</div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h6>Способ 1: UF2 (drag-n-drop)</h6>
|
||||
<ol>
|
||||
<li>Зажми <kbd>BOOT/PRG</kbd> на T114</li>
|
||||
<li>Подключи USB к ПК</li>
|
||||
<li>Отпусти — появится диск <code>T114</code></li>
|
||||
<li>Перетащи .uf2 файл на этот диск</li>
|
||||
</ol>
|
||||
<a :href="selectedAsset.browser_download_url" class="button" role="button" download>⬇ Скачать UF2</a>
|
||||
</div>
|
||||
<div>
|
||||
<h6>Способ 2: Web DFU (экспер.)</h6>
|
||||
<ol>
|
||||
<li>Дважды кликни RESET на T114</li>
|
||||
<li>Нажми «Enter DFU mode» и выбери порт</li>
|
||||
<li>Нажми «Flash DFU»</li>
|
||||
</ol>
|
||||
<div class="btn-group">
|
||||
<button v-if="!dfu.ready" class="secondary" @click="enterDfuMode" :disabled="!supportsSerial">⚙ Enter DFU mode</button>
|
||||
<button v-else disabled class="secondary">✓ DFU mode</button>
|
||||
<button @click="flashDfu" :disabled="!dfu.ready || flashing.active" class="contrast">⚡ Flash DFU</button>
|
||||
</div>
|
||||
<p v-if="!supportsSerial" style="font-size:0.8rem;color:var(--error);margin-top:0.5rem;">
|
||||
Web Serial не поддерживается. Используй Chrome/Edge на десктопе.
|
||||
</p>
|
||||
<p v-if="!isZip(selectedAsset.name)" style="font-size:0.8rem;color:var(--muted-color);margin-top:0.5rem;">
|
||||
ⓘ Для DFU нужен .zip файл. В релизе только .uf2.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="flashing.active" class="flash-progress">
|
||||
<progress :value="flashing.percent" max="100"></progress>
|
||||
<p v-if="flashing.percent < 100" style="font-size:0.85rem;">
|
||||
<span class="spinner"></span> Прошивка... {{ Math.round(flashing.percent) }}%
|
||||
</p>
|
||||
<p v-else style="font-size:0.85rem;color:var(--success);">✓ Готово!</p>
|
||||
<pre class="term">{{ flashing.log }}</pre>
|
||||
<div v-if="flashing.done" class="btn-group">
|
||||
<button class="secondary" @click="resetFlash">← Назад к файлу</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="secondary" style="margin-top:1rem;" @click="view='release'; selectedAsset=null">← Назад к релизу</button>
|
||||
</div>
|
||||
|
||||
<!-- Serial Console -->
|
||||
<div v-if="view === 'console'">
|
||||
<hgroup>
|
||||
<h5>💻 Serial Console (115200 бод)</h5>
|
||||
<p>Подключение к T114 для AT-команд</p>
|
||||
</hgroup>
|
||||
<div class="btn-group" style="margin-bottom:0.75rem;">
|
||||
<button v-if="!console.connected" @click="openConsole" :disabled="!supportsSerial">⚙ Подключиться</button>
|
||||
<button v-else class="secondary" @click="closeConsole">✕ Отключиться</button>
|
||||
<button v-if="console.connected" @click="consoleReset">↻ Reset</button>
|
||||
</div>
|
||||
<pre class="term" style="max-height:400px;">{{ console.log || 'Нажми «Подключиться» и выбери порт T114...' }}</pre>
|
||||
<div v-if="console.connected" style="display:flex;gap:0.5rem;">
|
||||
<input type="text" v-model="console.input" @keyup.enter="sendConsole" placeholder="AT команда..." style="flex:1;">
|
||||
<button @click="sendConsole" class="contrast">Отправить</button>
|
||||
</div>
|
||||
<details style="margin-top:0.5rem;">
|
||||
<summary>AT команды</summary>
|
||||
<ul style="font-size:0.8rem;columns:2;">
|
||||
<li><code>ver</code> — версия</li>
|
||||
<li><code>log</code> — лог пакетов</li>
|
||||
<li><code>erase</code> — стереть FS</li>
|
||||
<li><code>reboot</code> — перезагрузка</li>
|
||||
<li><code>advert</code> — отправить ADV</li>
|
||||
<li><code>get freq</code> — частота</li>
|
||||
<li><code>set freq 868.7</code> — частота</li>
|
||||
<li><code>get af</code> — Air-time factor</li>
|
||||
<li><code>set name</code> — имя</li>
|
||||
<li><code>password <pass></code> — пароль</li>
|
||||
</ul>
|
||||
</details>
|
||||
<button class="secondary" style="margin-top:1rem;" @click="view='releases'">← Назад</button>
|
||||
</div>
|
||||
|
||||
<!-- Release content -->
|
||||
<div v-else-if="view === 'release' && selectedRelease">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;">
|
||||
<h5>{{ selectedRelease.name || selectedRelease.tag_name }}</h5>
|
||||
<span class="badge">{{ selectedRelease.assets.length }} файл(ов)</span>
|
||||
</div>
|
||||
<p v-if="selectedRelease.body" class="release-notes">{{ selectedRelease.body }}</p>
|
||||
<div v-if="selectedRelease.assets.length">
|
||||
<div v-for="asset in selectedRelease.assets" :key="asset.id" class="file-row">
|
||||
<div>
|
||||
<strong>{{ asset.name }}</strong>
|
||||
<span style="font-size:0.8rem;color:var(--muted-color);">
|
||||
· {{ (asset.size / 1024).toFixed(0) }} KB
|
||||
</span>
|
||||
<span v-if="asset.download_count" class="badge badge-sm" style="margin-left:0.4rem;">
|
||||
{{ asset.download_count }} загрузок
|
||||
</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a :href="asset.browser_download_url" role="button" class="secondary outline small" download>⬇ Скачать</a>
|
||||
<button class="small contrast" @click="startFlash(asset)">⚡ Прошить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>Нет файлов прошивки в этом релизе</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main: releases list -->
|
||||
<div v-else>
|
||||
<div v-if="loading" class="empty-state">
|
||||
<span class="spinner" style="width:2rem;height:2rem;"></span>
|
||||
<p>Загрузка релизов...</p>
|
||||
</div>
|
||||
<div v-else-if="!releases.length" class="empty-state">
|
||||
<p>📄 Релизов пока нет</p>
|
||||
<a href="https://git2.ua1zbe.ru/ua1zbe/meshcore-simple-sensor/releases" target="_blank" class="button outline">Перейти к релизам</a>
|
||||
</div>
|
||||
<div v-else>
|
||||
<article v-for="r in releases" :key="r.id" class="release-card" @click="openRelease(r)">
|
||||
<header style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<strong>{{ r.name || r.tag_name }}</strong>
|
||||
<span class="badge">{{ r.assets.length }} файл(ов)</span>
|
||||
</header>
|
||||
<p v-if="r.body" style="font-size:0.85rem;max-height:2.5rem;overflow:hidden;margin:0;">{{ r.body }}</p>
|
||||
<footer style="font-size:0.75rem;color:var(--muted-color);margin-top:0.5rem;">
|
||||
{{ formatDate(r.published_at) }}
|
||||
· {{ r.assets.reduce((s, a) => s + (a.download_count||0), 0) }} скачиваний
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<nav style="justify-content:center;gap:1rem;">
|
||||
<a href="#" @click.prevent="view='releases'; selectedRelease=null; selectedAsset=null">Список релизов</a>
|
||||
<a href="#" @click.prevent="view='console'">💻 Консоль</a>
|
||||
<a href="./configurator.html">⚙ Конфигуратор</a>
|
||||
<a href="https://git2.ua1zbe.ru/ua1zbe/meshcore-simple-sensor" target="_blank">Git2.ua1zbe.ru</a>
|
||||
</nav>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
import { Dfu } from './lib/dfu.js';
|
||||
import { SerialConsole } from './lib/console.js';
|
||||
|
||||
const GITEA_API = 'https://git2.ua1zbe.ru/api/v1/repos/ua1zbe/meshcore-simple-sensor/releases';
|
||||
const OWN_ORIGIN = location.origin;
|
||||
const API_ORIGIN = new URL(GITEA_API).origin;
|
||||
|
||||
const app = Vue.createApp({
|
||||
data() {
|
||||
return {
|
||||
releases: [],
|
||||
loading: true,
|
||||
error: '',
|
||||
corsProxy: false,
|
||||
view: 'releases',
|
||||
selectedRelease: null,
|
||||
selectedAsset: null,
|
||||
dfu: { ready: false, port: null },
|
||||
flashing: { active: false, done: false, percent: 0, log: '', error: '' },
|
||||
console: { connected: false, instance: null, input: '', log: '' },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
supportsSerial() {
|
||||
return 'serial' in navigator;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadReleases();
|
||||
},
|
||||
methods: {
|
||||
formatDate(d) {
|
||||
return new Date(d).toLocaleDateString('ru-RU');
|
||||
},
|
||||
isZip(name) {
|
||||
return name && name.toLowerCase().endsWith('.zip');
|
||||
},
|
||||
async loadReleases() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
// Try local releases.json first (no CORS), then API, then CORS proxy
|
||||
try {
|
||||
const localRes = await fetch('./releases.json');
|
||||
if (localRes.ok) {
|
||||
const data = await localRes.json();
|
||||
this.releases = Array.isArray(data) ? data.reverse() : [];
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
if (OWN_ORIGIN === API_ORIGIN) {
|
||||
await this._fetchDirect();
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this._fetchDirect();
|
||||
} catch {
|
||||
await this._fetchCorsProxy();
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
async _fetchDirect() {
|
||||
const res = await fetch(GITEA_API);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this.releases = Array.isArray(data) ? data.reverse() : [];
|
||||
},
|
||||
async _fetchCorsProxy() {
|
||||
this.error = 'Прямой доступ к API заблокирован CORS.';
|
||||
const proxy = `https://api.allorigins.win/raw?url=${encodeURIComponent(GITEA_API)}`;
|
||||
const res = await fetch(proxy);
|
||||
if (!res.ok) throw new Error(`Proxy HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this.releases = Array.isArray(data) ? data.reverse() : [];
|
||||
this.corsProxy = true;
|
||||
this.error = '';
|
||||
},
|
||||
openRelease(release) {
|
||||
this.selectedRelease = release;
|
||||
this.selectedAsset = null;
|
||||
this.view = 'release';
|
||||
},
|
||||
startFlash(asset) {
|
||||
this.selectedAsset = asset;
|
||||
this.view = 'flash';
|
||||
},
|
||||
async enterDfuMode() {
|
||||
if (!this.supportsSerial) return;
|
||||
try {
|
||||
const port = await navigator.serial.requestPort();
|
||||
await Dfu.forceDfuMode(port);
|
||||
this.dfu.ready = true;
|
||||
this.dfu.port = port;
|
||||
} catch (e) {
|
||||
this.error = `DFU mode: ${e.message}`;
|
||||
}
|
||||
},
|
||||
async flashDfu() {
|
||||
if (!this.dfu.port || !this.selectedAsset) return;
|
||||
if (!this.isZip(this.selectedAsset.name)) {
|
||||
this.flashing.active = true;
|
||||
this.flashing.done = true;
|
||||
this.flashing.log = 'DFU требуется .zip файл. Используй скачивание .uf2 и drag-n-drop.\n';
|
||||
this.flashing.percent = 100;
|
||||
return;
|
||||
}
|
||||
this.flashing.active = true;
|
||||
this.flashing.done = false;
|
||||
this.flashing.percent = 0;
|
||||
this.flashing.log = '';
|
||||
try {
|
||||
this.flashing.log += 'Скачивание прошивки...\n';
|
||||
const resp = await fetch(this.selectedAsset.browser_download_url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
this.flashing.log += `Загружено: ${(blob.size / 1024).toFixed(0)} KB\n`;
|
||||
this.flashing.log += 'Запуск DFU...\n';
|
||||
const dfu = new Dfu(this.dfu.port);
|
||||
dfu.dfuUpdate(blob, (pct) => {
|
||||
this.flashing.percent = pct;
|
||||
});
|
||||
this.flashing.log += 'Готово!\n';
|
||||
this.flashing.percent = 100;
|
||||
} catch (e) {
|
||||
this.flashing.log += `ERROR: ${e.message}\n`;
|
||||
this.flashing.error = e.message;
|
||||
} finally {
|
||||
this.flashing.done = true;
|
||||
}
|
||||
},
|
||||
resetFlash() {
|
||||
this.flashing.active = false;
|
||||
this.flashing.done = false;
|
||||
this.flashing.percent = 0;
|
||||
this.flashing.log = '';
|
||||
this.flashing.error = '';
|
||||
},
|
||||
async openConsole() {
|
||||
if (!this.supportsSerial) return;
|
||||
try {
|
||||
const port = await navigator.serial.requestPort();
|
||||
const sc = new SerialConsole(port);
|
||||
sc.onOutput = (text) => {
|
||||
this.console.log += text;
|
||||
};
|
||||
this.console.instance = sc;
|
||||
await sc.connect();
|
||||
this.console.connected = true;
|
||||
this.console.log = '';
|
||||
this.view = 'console';
|
||||
} catch (e) {
|
||||
this.error = `Console: ${e.message}`;
|
||||
}
|
||||
},
|
||||
async closeConsole() {
|
||||
if (this.console.instance) {
|
||||
await this.console.instance.disconnect();
|
||||
}
|
||||
this.console.connected = false;
|
||||
this.console.instance = null;
|
||||
},
|
||||
async consoleReset() {
|
||||
if (this.console.instance) {
|
||||
await this.console.instance.reset();
|
||||
this.console.log += '-- RESET --\n';
|
||||
}
|
||||
},
|
||||
async sendConsole() {
|
||||
if (!this.console.instance || !this.console.input) return;
|
||||
await this.console.instance.sendCommand(this.console.input);
|
||||
this.console.input = '';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
98
flasher/lib/console.js
Normal file
98
flasher/lib/console.js
Normal file
@@ -0,0 +1,98 @@
|
||||
function delay(msecs) {
|
||||
return new Promise((resolve) => setTimeout(resolve, msecs));
|
||||
}
|
||||
|
||||
class LineBreakTransformer {
|
||||
chunks = '';
|
||||
port = null;
|
||||
|
||||
transform(chunk, controller) {
|
||||
// Append new chunks to existing chunks.
|
||||
this.chunks += chunk;
|
||||
// For each line breaks in chunks, send the parsed lines out.
|
||||
const lines = this.chunks.split('\r\n');
|
||||
this.chunks = lines.pop();
|
||||
lines.forEach((line) => controller.enqueue(line + '\r\n'));
|
||||
}
|
||||
|
||||
flush(controller) {
|
||||
// When the stream is closed, flush any remaining chunks out.
|
||||
controller.enqueue(this.chunks);
|
||||
}
|
||||
}
|
||||
|
||||
export class SerialConsole {
|
||||
connected = false;
|
||||
constructor(port) {
|
||||
this.port = port;
|
||||
this.controller = new AbortController();
|
||||
this.signal = this.controller.signal;
|
||||
this.onOutput = (text) => {
|
||||
console.log(text);
|
||||
};
|
||||
}
|
||||
|
||||
async connect() {
|
||||
try {
|
||||
await this.port.open({ baudRate: 115200 });
|
||||
this.connected = true;
|
||||
await this.port.readable
|
||||
.pipeThrough(new TextDecoderStream(), { signal: this.signal })
|
||||
.pipeThrough(new TransformStream(new LineBreakTransformer()))
|
||||
.pipeTo(
|
||||
new WritableStream({
|
||||
write: (chunk) => {
|
||||
this.addLine(chunk.replace('\r', ''));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Check AFTER the pipeTo has completed (or been aborted)
|
||||
if (!this.signal.aborted) {
|
||||
this.addLine('\n\n*** Terminal disconnected');
|
||||
this.connected = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.addLine(`\n\n*** Terminal disconnected: ${e}`);
|
||||
this.connected = false;
|
||||
} finally {
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
addLine(text) {
|
||||
this.onOutput(text);
|
||||
}
|
||||
|
||||
async sendCommand(command) {
|
||||
const encoder = new TextEncoder();
|
||||
const writer = this.port.writable.getWriter(); // Get writer from 'this.port'
|
||||
await writer.write(encoder.encode(command + '\r\n'));
|
||||
try {
|
||||
writer.releaseLock();
|
||||
} catch (err) {
|
||||
console.error('Ignoring release lock error', err);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
this.controller.abort();
|
||||
await delay(50);
|
||||
await this.port.close();
|
||||
}
|
||||
|
||||
async reset() {
|
||||
console.debug('Triggering reset');
|
||||
await this.port.setSignals({
|
||||
dataTerminalReady: false,
|
||||
requestToSend: true,
|
||||
});
|
||||
await delay(250);
|
||||
await this.port.setSignals({
|
||||
dataTerminalReady: false,
|
||||
requestToSend: false,
|
||||
});
|
||||
|
||||
await delay(1250);
|
||||
}
|
||||
}
|
||||
469
flasher/lib/dfu.js
Normal file
469
flasher/lib/dfu.js
Normal file
@@ -0,0 +1,469 @@
|
||||
import * as zip from "./zip.min.js";
|
||||
|
||||
// Constants adapted from dfu/dfu_transport_serial.py
|
||||
const DFU_TOUCH_BAUD = 1200;
|
||||
const SERIAL_PORT_OPEN_WAIT_TIME = 0.1;
|
||||
const TOUCH_RESET_WAIT_TIME = 1.5;
|
||||
|
||||
const DEFAULT_SERIAL_PORT_TIMEOUT = 1.0; // Timeout time on serial port read
|
||||
const FLASH_PAGE_SIZE = 4096;
|
||||
const FLASH_PAGE_ERASE_TIME = 0.0897; // nRF52840 max erase time
|
||||
const FLASH_WORD_WRITE_TIME = 0.000100; // nRF52840 max write time
|
||||
const FLASH_PAGE_WRITE_TIME = (FLASH_PAGE_SIZE / 4) * FLASH_WORD_WRITE_TIME;
|
||||
const DFU_PACKET_MAX_SIZE = 512;
|
||||
|
||||
const DATA_INTEGRITY_CHECK_PRESENT = 1;
|
||||
const RELIABLE_PACKET = 1;
|
||||
const HCI_PACKET_TYPE = 14;
|
||||
|
||||
const DFU_INIT_PACKET = 1;
|
||||
const DFU_START_PACKET = 3;
|
||||
const DFU_DATA_PACKET = 4;
|
||||
const DFU_STOP_DATA_PACKET = 5;
|
||||
const DFU_ERASE_PAGE = 6; // Added for explicit page erase
|
||||
|
||||
const DFU_UPDATE_MODE_APP = 4;
|
||||
|
||||
// --- Utility Functions (adapted from dfu/util.py) ---
|
||||
|
||||
function int32ToBytes(value) {
|
||||
const buffer = new ArrayBuffer(4);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint32(0, value, true); // Little-endian
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
function int16ToBytes(value) {
|
||||
const buffer = new ArrayBuffer(2);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint16(0, value, true); // Little-endian
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
function slipPartsToFourBytes(seq, dip, rp, pktType, pktLen) {
|
||||
const ints = new Uint8Array(4);
|
||||
ints[0] = seq | (((seq + 1) % 8) << 3) | (dip << 6) | (rp << 7);
|
||||
ints[1] = pktType | ((pktLen & 0x000F) << 4);
|
||||
ints[2] = (pktLen & 0x0FF0) >> 4;
|
||||
ints[3] = (~(ints[0] + ints[1] + ints[2]) + 1) & 0xFF;
|
||||
return ints;
|
||||
}
|
||||
|
||||
function slipEncodeEscChars(data) {
|
||||
const result = [];
|
||||
for (const byte of data) {
|
||||
if (byte === 0xC0) {
|
||||
result.push(0xDB, 0xDC);
|
||||
} else if (byte === 0xDB) {
|
||||
result.push(0xDB, 0xDD);
|
||||
} else {
|
||||
result.push(byte);
|
||||
}
|
||||
}
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
|
||||
// --- CRC16 Calculation (adapted from dfu/crc16.py) ---
|
||||
|
||||
function calcCrc16(data, crc = 0xFFFF) {
|
||||
if (!(data instanceof Uint8Array)) {
|
||||
throw new Error("calcCrc16 requires Uint8Array input");
|
||||
}
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = ((crc >> 8) & 0x00FF) | ((crc << 8) & 0xFF00);
|
||||
crc ^= data[i];
|
||||
crc ^= (crc & 0x00FF) >> 4;
|
||||
crc ^= (crc << 8) << 4;
|
||||
crc ^= ((crc & 0x00FF) << 4) << 1;
|
||||
}
|
||||
return crc & 0xFFFF;
|
||||
}
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
// --- HciPacket Class (adapted from dfu/dfu_transport_serial.py) ---
|
||||
|
||||
class HciPacket {
|
||||
static sequenceNumber = 0;
|
||||
|
||||
constructor(data) {
|
||||
HciPacket.sequenceNumber = (HciPacket.sequenceNumber + 1) % 8;
|
||||
let tempData = [];
|
||||
|
||||
const slipBytes = slipPartsToFourBytes(
|
||||
HciPacket.sequenceNumber,
|
||||
DATA_INTEGRITY_CHECK_PRESENT,
|
||||
RELIABLE_PACKET,
|
||||
HCI_PACKET_TYPE,
|
||||
data.length
|
||||
);
|
||||
tempData = tempData.concat(Array.from(slipBytes));
|
||||
|
||||
tempData = tempData.concat(Array.from(data));
|
||||
|
||||
// Add CRC
|
||||
const crc = calcCrc16(new Uint8Array(tempData));
|
||||
tempData.push(crc & 0xFF);
|
||||
tempData.push((crc & 0xFF00) >> 8);
|
||||
|
||||
const encoded = slipEncodeEscChars(new Uint8Array(tempData));
|
||||
this.data = new Uint8Array([0xC0, ...encoded, 0xC0]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Main DFU Class ---
|
||||
|
||||
export class Dfu {
|
||||
/**
|
||||
* @param {SerialPort} port - The Web Serial API port object.
|
||||
* @param {boolean} [eraseBeforeUpdate=false] - Whether to erase the entire flash before updating.
|
||||
*/
|
||||
constructor(port, eraseBeforeUpdate = false) {
|
||||
this.port = port;
|
||||
this.transferInProgress = false;
|
||||
this.lastAck = -1;
|
||||
this.eraseBeforeUpdate = eraseBeforeUpdate; // Store the erase flag
|
||||
}
|
||||
|
||||
getReader() {
|
||||
const reader = this.port.readable.getReader();
|
||||
|
||||
return {
|
||||
read() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
reader.releaseLock();
|
||||
reject(new Error("Read timeout"));
|
||||
}, DEFAULT_SERIAL_PORT_TIMEOUT * 1000 * 5)
|
||||
|
||||
reader.read().then(result => {
|
||||
clearTimeout(timeoutHandle);
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
},
|
||||
releaseLock() {
|
||||
return reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendPacket(pkt) {
|
||||
if (!this.port || !this.port.writable) {
|
||||
throw new Error("Serial port not open or not writable.");
|
||||
}
|
||||
|
||||
const writer = this.port.writable.getWriter();
|
||||
try {
|
||||
await writer.write(pkt.data);
|
||||
console.debug("Sent packet:", pkt.data.length);
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
|
||||
await this.getAck(); // Wait for ACK after sending
|
||||
}
|
||||
|
||||
async getAck() {
|
||||
if (!this.port || !this.port.readable) {
|
||||
throw new Error("Serial port not open or not readable.");
|
||||
}
|
||||
|
||||
const reader = this.getReader();
|
||||
let buffer = [];
|
||||
let c0Count = 0;
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
while (c0Count < 2) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
throw new Error("Stream closed before receiving full ACK.");
|
||||
}
|
||||
|
||||
if (value) {
|
||||
for (const byte of value) {
|
||||
buffer.push(byte);
|
||||
if (byte === 0xC0) {
|
||||
c0Count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
HciPacket.sequenceNumber = 0;
|
||||
throw e;
|
||||
}
|
||||
finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// Extract the SLIP frame between the two 0xC0 delimiters, ignoring any
|
||||
// stale bytes that arrived before the opening delimiter.
|
||||
const firstC0 = buffer.indexOf(0xC0);
|
||||
const secondC0 = buffer.indexOf(0xC0, firstC0 + 1);
|
||||
if (firstC0 === -1 || secondC0 === -1) {
|
||||
throw new Error("Received incomplete ACK.");
|
||||
}
|
||||
const decodedData = this.decodeSlip(buffer.slice(firstC0 + 1, secondC0));
|
||||
|
||||
if (decodedData.length < 2) {
|
||||
throw new Error("Received incomplete ACK.");
|
||||
}
|
||||
const ack = (decodedData[0] >> 3) & 0x07;
|
||||
|
||||
// Check for valid ACK sequence
|
||||
if (this.lastAck !== -1 && ack !== (this.lastAck + 1) % 8) {
|
||||
HciPacket.sequenceNumber = 0; // Reset on bad ack
|
||||
throw new Error(`Invalid ACK sequence. Expected ${(this.lastAck + 1) % 8}, got ${ack}`);
|
||||
}
|
||||
this.lastAck = ack;
|
||||
|
||||
return ack;
|
||||
}
|
||||
|
||||
decodeSlip(data) {
|
||||
const result = [];
|
||||
let i = 0;
|
||||
while (i < data.length) {
|
||||
if (data[i] === 0xDB) {
|
||||
i++;
|
||||
if (i >= data.length) {
|
||||
throw new Error("Invalid SLIP escape sequence: incomplete.");
|
||||
}
|
||||
if (data[i] === 0xDC) {
|
||||
result.push(0xC0);
|
||||
} else if (data[i] === 0xDD) {
|
||||
result.push(0xDB);
|
||||
} else {
|
||||
throw new Error(`Invalid SLIP escape sequence: DB followed by ${data[i].toString(16)}`);
|
||||
}
|
||||
} else if (data[i] === 0xC0) {
|
||||
// Ignore 0xC0 (start/end of packet)
|
||||
}
|
||||
else {
|
||||
result.push(data[i]);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return new Uint8Array(result);
|
||||
}
|
||||
|
||||
async sendInitPacket(initPacket) {
|
||||
const frame = new Uint8Array([
|
||||
...int32ToBytes(DFU_INIT_PACKET),
|
||||
...initPacket,
|
||||
...int16ToBytes(0x0000), // Padding
|
||||
]);
|
||||
const packet = new HciPacket(frame);
|
||||
await this.sendPacket(packet);
|
||||
}
|
||||
|
||||
// THANKS Liam!!!
|
||||
static async forceDfuMode(port) {
|
||||
// open port
|
||||
await port.open({
|
||||
baudRate: DFU_TOUCH_BAUD,
|
||||
});
|
||||
|
||||
// wait SERIAL_PORT_OPEN_WAIT_TIME before closing port
|
||||
await sleep(SERIAL_PORT_OPEN_WAIT_TIME * 1000);
|
||||
|
||||
// close port
|
||||
await port.close();
|
||||
|
||||
// wait TOUCH_RESET_WAIT_TIME for device to enter into DFU mode
|
||||
await sleep(TOUCH_RESET_WAIT_TIME * 1000);
|
||||
}
|
||||
|
||||
async sendStartDfu(mode, softdeviceSize = 0, bootloaderSize = 0, appSize = 0) {
|
||||
const frame = new Uint8Array([
|
||||
...int32ToBytes(DFU_START_PACKET),
|
||||
...int32ToBytes(mode),
|
||||
...int32ToBytes(softdeviceSize),
|
||||
...int32ToBytes(bootloaderSize),
|
||||
...int32ToBytes(appSize),
|
||||
]);
|
||||
|
||||
const packet = new HciPacket(frame);
|
||||
await this.sendPacket(packet);
|
||||
|
||||
// Calculate and apply erase wait time.
|
||||
const totalSize = softdeviceSize + bootloaderSize + appSize;
|
||||
const eraseWaitTime = Math.max(0.5, ((totalSize / FLASH_PAGE_SIZE) + 1) * FLASH_PAGE_ERASE_TIME);
|
||||
await sleep(eraseWaitTime * 1000);
|
||||
}
|
||||
|
||||
|
||||
async sendErasePage(pageAddress) {
|
||||
const frame = new Uint8Array([
|
||||
...int32ToBytes(DFU_ERASE_PAGE),
|
||||
...int32ToBytes(pageAddress),
|
||||
]);
|
||||
const packet = new HciPacket(frame);
|
||||
await this.sendPacket(packet);
|
||||
await sleep(FLASH_PAGE_ERASE_TIME * 1000); // Wait for page erase
|
||||
}
|
||||
|
||||
|
||||
async eraseFlash(appSize) {
|
||||
console.log("Erasing flash...");
|
||||
const numPages = Math.ceil(appSize / FLASH_PAGE_SIZE);
|
||||
|
||||
// Assuming application starts at address 0x00000000
|
||||
let startAddress = 0x00000000;
|
||||
|
||||
for (let i = 0; i < numPages; i++) {
|
||||
const pageAddress = startAddress + (i * FLASH_PAGE_SIZE);
|
||||
console.log(`Erasing page ${i} at address 0x${pageAddress.toString(16)}`);
|
||||
await this.sendErasePage(pageAddress);
|
||||
}
|
||||
console.log("Flash erase complete.");
|
||||
}
|
||||
|
||||
|
||||
async sendFirmware(firmware, progressCallback) {
|
||||
const frames = [];
|
||||
let totalBytes = firmware.length;
|
||||
|
||||
// Chunk firmware into DFU packets
|
||||
for (let i = 0; i < firmware.length; i += DFU_PACKET_MAX_SIZE) {
|
||||
const chunk = firmware.subarray(i, i + DFU_PACKET_MAX_SIZE);
|
||||
const frame = new Uint8Array([
|
||||
...int32ToBytes(DFU_DATA_PACKET),
|
||||
...chunk,
|
||||
]);
|
||||
const dataPacket = new HciPacket(frame);
|
||||
frames.push(dataPacket);
|
||||
}
|
||||
|
||||
let bytesSent = 0;
|
||||
// Brief stabilization pause before starting data transfer (mirrors Python's implicit
|
||||
// pause at count=0 — it sleeps FLASH_PAGE_WRITE_TIME after the very first packet).
|
||||
await sleep(FLASH_PAGE_WRITE_TIME * 1000);
|
||||
|
||||
// Send firmware packets
|
||||
for (const [index, pkt] of frames.entries()) {
|
||||
await this.sendPacket(pkt);
|
||||
bytesSent += pkt.data.length - 6; // Correctly calculate sent bytes, excluding SLIP overhead
|
||||
|
||||
if (progressCallback) {
|
||||
const progress = Math.min(100, Math.round((bytesSent / totalBytes) * 100)); // Ensure progress doesn't exceed 100
|
||||
progressCallback(progress);
|
||||
}
|
||||
|
||||
// Wait after every 8 frames (one flash page)
|
||||
if ((index + 1) % 8 === 0) {
|
||||
await sleep(FLASH_PAGE_WRITE_TIME * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the last page to be written
|
||||
await sleep(FLASH_PAGE_WRITE_TIME * 1000);
|
||||
|
||||
// Send stop packet
|
||||
const stopPacket = new HciPacket(int32ToBytes(DFU_STOP_DATA_PACKET));
|
||||
await this.sendPacket(stopPacket);
|
||||
}
|
||||
|
||||
async dfuUpdate(zipFile, progressCallback) {
|
||||
if (this.transferInProgress) {
|
||||
throw new Error("DFU update already in progress.");
|
||||
}
|
||||
this.transferInProgress = true;
|
||||
this.lastAck = -1; // Reset last ACK
|
||||
HciPacket.sequenceNumber = 0; // Reset HCI sequence number
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
await this.port.open({ baudRate: 115200 }); // Open with correct baudrate
|
||||
|
||||
const reader = new zip.ZipReader(new zip.BlobReader(zipFile));
|
||||
const entries = await reader.getEntries();
|
||||
|
||||
let manifest = null;
|
||||
let firmwareFiles = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
const filename = decoder.decode(entry.rawFilename);
|
||||
console.debug('Found zip filename: ', filename);
|
||||
if (filename === 'manifest.json') {
|
||||
const text = await entry.getData(new zip.TextWriter());
|
||||
manifest = JSON.parse(text);
|
||||
} else if (filename.endsWith('.bin') || filename.endsWith('.dat')) {
|
||||
firmwareFiles[filename] = await entry.getData(new zip.Uint8ArrayWriter());
|
||||
}
|
||||
}
|
||||
|
||||
await reader.close();
|
||||
|
||||
if (!manifest) {
|
||||
throw new Error("manifest.json not found in the ZIP file.");
|
||||
}
|
||||
if (!firmwareFiles[manifest.manifest.application.bin_file] ||
|
||||
!firmwareFiles[manifest.manifest.application.dat_file])
|
||||
{
|
||||
throw new Error("Application .bin or .dat file not found.");
|
||||
}
|
||||
|
||||
const appBin = firmwareFiles[manifest.manifest.application.bin_file];
|
||||
const initPacket = firmwareFiles[manifest.manifest.application.dat_file];
|
||||
const appSize = appBin.length;
|
||||
|
||||
// Erase flash if requested
|
||||
if (this.eraseBeforeUpdate) {
|
||||
await this.eraseFlash(appSize);
|
||||
}
|
||||
|
||||
// Start DFU
|
||||
await this.sendStartDfu(DFU_UPDATE_MODE_APP, 0, 0, appSize);
|
||||
|
||||
// Send Init Packet
|
||||
await this.sendInitPacket(initPacket);
|
||||
|
||||
// Send Firmware
|
||||
await this.sendFirmware(appBin, progressCallback);
|
||||
|
||||
console.log("DFU update complete.");
|
||||
|
||||
} catch (error) {
|
||||
console.error("DFU Update failed:", error);
|
||||
throw error; // Re-throw the error for handling by the caller
|
||||
} finally {
|
||||
this.transferInProgress = false;
|
||||
if (this.port && this.port.readable) {
|
||||
try {
|
||||
const reader = this.port.readable.getReader();
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
|
||||
} catch (error) {
|
||||
// Ignore errors when trying to cancel the reader
|
||||
console.debug(`Error: closing reader: ${error}`);
|
||||
}
|
||||
}
|
||||
if (this.port && this.port.writable) {
|
||||
try {
|
||||
const writer = this.port.writable.getWriter();
|
||||
await writer.close();
|
||||
writer.releaseLock();
|
||||
} catch(error) {
|
||||
// Ignore errors when trying to close the writer
|
||||
console.debug(`Error: closing writer: ${error}`);
|
||||
}
|
||||
}
|
||||
if (this.port) {
|
||||
try {
|
||||
await this.port.close();
|
||||
}
|
||||
catch (error) {
|
||||
// Ignore errors when trying to close the port
|
||||
console.debug(`Error: closing port: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
756
flasher/lib/serial-cli.js
Normal file
756
flasher/lib/serial-cli.js
Normal file
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* SerialCLI - A class for communicating with devices via Web Serial API
|
||||
* Handles sending commands, receiving responses, and parsing multi-line data
|
||||
*/
|
||||
|
||||
export class SerialCLI {
|
||||
constructor(debug = false) { // Added debug parameter
|
||||
this.port = null;
|
||||
this.reader = null;
|
||||
this.writer = null;
|
||||
this.readBuffer = "";
|
||||
this.isReading = false;
|
||||
this.commandQueue = [];
|
||||
this.currentCommand = null;
|
||||
this.decoder = new TextDecoder();
|
||||
this.encoder = new TextEncoder();
|
||||
this.responseTimeout = 5000; // 5 seconds timeout for responses
|
||||
this.commandDelay = 100; // 100ms delay between commands
|
||||
this.debug = debug; // Initialize debug mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable debug logging
|
||||
* @param {boolean} enabled - True to enable debug mode, false to disable
|
||||
*/
|
||||
setDebug(enabled) {
|
||||
this.debug = enabled;
|
||||
if (this.debug) {
|
||||
console.log("SerialCLI Debug Mode Enabled");
|
||||
} else {
|
||||
console.log("SerialCLI Debug Mode Disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a serial device
|
||||
* @param {number} baudRate - Baud rate to use (default: 115200)
|
||||
* @returns {Promise<boolean>} True if connected, false otherwise
|
||||
*/
|
||||
async connect(baudRate = 115200) {
|
||||
if (!('serial' in navigator)) {
|
||||
console.error('Web Serial API not supported in this browser');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.port = await navigator.serial.requestPort();
|
||||
await this.port.open({ baudRate });
|
||||
|
||||
this.reader = this.port.readable.getReader();
|
||||
this.writer = this.port.writable.getWriter();
|
||||
|
||||
if (this.debug) {
|
||||
console.log(`SerialCLI: Connected to port, baud rate ${baudRate}`);
|
||||
}
|
||||
|
||||
this.startReading();
|
||||
return true; // Indicate successful connection
|
||||
} catch (error) {
|
||||
console.error("SerialCLI: Failed to connect", error);
|
||||
this.port = null; // Reset port on failure
|
||||
return false; // Indicate failed connection
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the serial device
|
||||
*/
|
||||
async disconnect() {
|
||||
if (this.reader) {
|
||||
try {
|
||||
this.isReading = false;
|
||||
await this.reader.cancel();
|
||||
// releaseLock() is handled implicitly by cancel() or closing the port
|
||||
} catch (error) {
|
||||
if (this.debug) console.error("SerialCLI: Error cancelling reader", error);
|
||||
} finally {
|
||||
this.reader = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.writer) {
|
||||
try {
|
||||
// Ensure writer is closed before releasing lock
|
||||
if (!this.writer.closed) {
|
||||
await this.writer.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.debug) console.error("SerialCLI: Error closing writer", error);
|
||||
} finally {
|
||||
try {
|
||||
this.writer.releaseLock();
|
||||
} catch(lockError) {
|
||||
// Ignore error if lock was already released
|
||||
}
|
||||
this.writer = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (this.port) {
|
||||
try {
|
||||
await this.port.close();
|
||||
if (this.debug) console.log("SerialCLI: Port closed");
|
||||
} catch (error) {
|
||||
if (this.debug) console.error("SerialCLI: Error closing port", error);
|
||||
} finally {
|
||||
this.port = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start reading data from the serial port
|
||||
* @private
|
||||
*/
|
||||
startReading() {
|
||||
if (!this.reader) return;
|
||||
|
||||
this.isReading = true;
|
||||
this.readLoop();
|
||||
if (this.debug) console.log("SerialCLI: Started reading loop");
|
||||
}
|
||||
|
||||
/**
|
||||
* Main read loop for serial data
|
||||
* @private
|
||||
*/
|
||||
async readLoop() {
|
||||
while (this.isReading && this.reader) {
|
||||
try {
|
||||
const { value, done } = await this.reader.read();
|
||||
if (done) {
|
||||
// Allow the serial port to be closed later.
|
||||
this.reader.releaseLock();
|
||||
if (this.debug) console.log("SerialCLI: Reader stream closed");
|
||||
break;
|
||||
}
|
||||
|
||||
const textChunk = this.decoder.decode(value, { stream: true }); // Use stream option for potentially multi-byte chars split across chunks
|
||||
if (this.debug) {
|
||||
console.log("SerialCLI <<< RECV:", JSON.stringify(textChunk)); // Log received data
|
||||
}
|
||||
this.processIncomingData(textChunk);
|
||||
|
||||
} catch (error) {
|
||||
console.error("SerialCLI: Error in read loop:", error);
|
||||
this.isReading = false; // Stop reading on error
|
||||
try {
|
||||
this.reader.releaseLock();
|
||||
} catch (lockError) {
|
||||
// Ignore lock release error if already released
|
||||
}
|
||||
this.reader = null;
|
||||
// Consider attempting to reconnect or notify the user
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Redundant check, but safe
|
||||
if (this.isReading && this.port?.readable && !this.reader) {
|
||||
try {
|
||||
this.reader = this.port.readable.getReader();
|
||||
this.readLoop(); // Restart loop if needed and possible
|
||||
if (this.debug) console.log("SerialCLI: Restarted reading loop after temporary reader release");
|
||||
} catch(err) {
|
||||
console.error("SerialCLI: Failed to re-acquire reader", err);
|
||||
this.isReading = false;
|
||||
}
|
||||
} else if (!this.isReading && this.debug) {
|
||||
console.log("SerialCLI: Reading loop stopped.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process incoming data from the serial port
|
||||
* @param {string} data - The data received from the serial port
|
||||
* @private
|
||||
*/
|
||||
processIncomingData(data) {
|
||||
this.readBuffer += data;
|
||||
if (this.debug) console.log("SerialCLI: Buffer:", JSON.stringify(this.readBuffer));
|
||||
|
||||
// Check if we're waiting for a response
|
||||
if (this.currentCommand) {
|
||||
this.checkForResponse();
|
||||
} else if (this.commandQueue.length > 0) {
|
||||
// If no current command but queue has items, try to execute next command
|
||||
// This should ideally only happen after a response is fully processed
|
||||
// or if the device sends unsolicited data.
|
||||
if (this.debug) console.log("SerialCLI: Received data while idle, buffer:", JSON.stringify(this.readBuffer));
|
||||
// Let's not automatically execute next command here, wait for command completion logic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a complete response has been received
|
||||
* @private
|
||||
*/
|
||||
checkForResponse() {
|
||||
if (!this.currentCommand) return;
|
||||
|
||||
// --- Refined Response Parsing Logic ---
|
||||
// A typical interaction looks like:
|
||||
// 1. Send command: `my_command\r`
|
||||
// 2. Device echoes: `my_command\r\n` (optional, depends on device)
|
||||
// 3. Device processes and sends response: ` -> OK\r\n` or multi-line for log
|
||||
// We need to find the "->" marker *after* the potential echo.
|
||||
|
||||
const { command, isLogCommand } = this.currentCommand;
|
||||
const commandWithCR = command + '\r'; // Command as sent
|
||||
const commandWithCRLF = command + '\r\n'; // Potential echo format
|
||||
|
||||
// Find the end of the command echo (could be with or without \n)
|
||||
let echoEndIndex = this.readBuffer.indexOf(commandWithCRLF);
|
||||
if (echoEndIndex !== -1) {
|
||||
echoEndIndex += commandWithCRLF.length;
|
||||
} else {
|
||||
echoEndIndex = this.readBuffer.indexOf(commandWithCR);
|
||||
if (echoEndIndex !== -1) {
|
||||
echoEndIndex += commandWithCR.length;
|
||||
} else {
|
||||
// Command echo might not have arrived fully yet, or device doesn't echo
|
||||
// Let's proceed cautiously, but this might lead to issues if echo is partial
|
||||
echoEndIndex = 0; // Assume start of buffer if no echo found yet
|
||||
if (this.debug) console.log("SerialCLI: Command echo not found yet or device doesn't echo.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Look for the response marker "->" *after* the potential echo
|
||||
const responseMarker = " -> ";
|
||||
const responseStartIndex = this.readBuffer.indexOf(responseMarker, echoEndIndex);
|
||||
|
||||
if (responseStartIndex === -1) {
|
||||
if (this.debug) console.log("SerialCLI: Response marker '->' not found after echo index", echoEndIndex);
|
||||
return; // Response marker not found yet
|
||||
}
|
||||
|
||||
const responsePayloadStartIndex = responseStartIndex + responseMarker.length;
|
||||
|
||||
// Find the end of the response (\r\n)
|
||||
// Search *after* the start of the response payload
|
||||
const newlineIndex = this.readBuffer.indexOf('\r\n', responsePayloadStartIndex);
|
||||
|
||||
if (newlineIndex === -1) {
|
||||
if (this.debug) console.log("SerialCLI: Response newline not found after payload start index", responsePayloadStartIndex);
|
||||
return; // Full response line hasn't arrived
|
||||
}
|
||||
|
||||
// Extract the response content
|
||||
const responseLine = this.readBuffer.substring(responsePayloadStartIndex, newlineIndex).trim();
|
||||
const consumedUntilIndex = newlineIndex + 2; // Include the \r\n
|
||||
|
||||
if (this.debug) console.log(`SerialCLI: Found response line: "${responseLine}"`);
|
||||
|
||||
// Special handling for log command which has multi-line response ending with EOF
|
||||
if (isLogCommand) {
|
||||
// For log, the first line might just be the confirmation, e.g., "-> OK" or similar.
|
||||
// The actual log data follows, ending with " EOF\r\n"
|
||||
const eofMarker = " EOF";
|
||||
// Look for EOF *after* the initial response line we just found
|
||||
const eofIndex = this.readBuffer.indexOf(eofMarker, consumedUntilIndex);
|
||||
|
||||
if (eofIndex !== -1) {
|
||||
const eofNewlineIndex = this.readBuffer.indexOf('\r\n', eofIndex);
|
||||
if (eofNewlineIndex !== -1) {
|
||||
// Extract the log data between the first response line and the EOF marker
|
||||
const logData = this.readBuffer.substring(consumedUntilIndex, eofIndex).trim();
|
||||
const finalConsumedIndex = eofNewlineIndex + 2;
|
||||
if (this.debug) console.log(`SerialCLI: Log EOF found. Log data length: ${logData.length}`);
|
||||
|
||||
this.readBuffer = this.readBuffer.substring(finalConsumedIndex); // Consume everything including EOF line
|
||||
this.completeCommand(logData); // Resolve with the extracted log data
|
||||
} else {
|
||||
if (this.debug) console.log("SerialCLI: Log EOF marker found, but newline missing.");
|
||||
}
|
||||
} else {
|
||||
if (this.debug) console.log("SerialCLI: Log command response started, waiting for EOF.");
|
||||
}
|
||||
} else {
|
||||
// For standard commands, the single line is the response
|
||||
this.readBuffer = this.readBuffer.substring(consumedUntilIndex); // Consume the processed part
|
||||
this.completeCommand(responseLine); // Resolve with the single response line
|
||||
}
|
||||
|
||||
if (this.debug) console.log("SerialCLI: Buffer after processing:", JSON.stringify(this.readBuffer));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Complete a command and resolve its promise with the response
|
||||
* @param {string} response - The response from the device
|
||||
* @private
|
||||
*/
|
||||
completeCommand(response) {
|
||||
if (!this.currentCommand) return;
|
||||
|
||||
clearTimeout(this.currentCommand.timeout);
|
||||
const { resolve, command } = this.currentCommand;
|
||||
if (this.debug) console.log(`SerialCLI: Command "${command}" completed with response:`, response);
|
||||
|
||||
this.currentCommand = null;
|
||||
resolve(response);
|
||||
|
||||
// Schedule next command execution after a delay
|
||||
if (this.commandQueue.length > 0) {
|
||||
if (this.debug) console.log(`SerialCLI: Scheduling next command in ${this.commandDelay}ms`);
|
||||
setTimeout(() => this.executeNextCommand(), this.commandDelay);
|
||||
} else {
|
||||
if (this.debug) console.log("SerialCLI: Command queue empty.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the next command in the queue
|
||||
* @private
|
||||
*/
|
||||
async executeNextCommand() {
|
||||
// Prevent starting a new command if one is already in progress
|
||||
if (this.currentCommand) {
|
||||
if (this.debug) console.log("SerialCLI: executeNextCommand called, but a command is already active.");
|
||||
return;
|
||||
}
|
||||
if (this.commandQueue.length === 0) {
|
||||
if (this.debug) console.log("SerialCLI: executeNextCommand called, but queue is empty.");
|
||||
return;
|
||||
}
|
||||
if (!this.writer) {
|
||||
console.error("SerialCLI: Cannot execute command, writer is not available.");
|
||||
// Reject the command? Or just log and wait? Let's reject.
|
||||
const nextCmd = this.commandQueue.shift();
|
||||
nextCmd.reject(new Error("Serial writer not available"));
|
||||
// Check if more commands need rejecting or if we should stop.
|
||||
if (this.commandQueue.length > 0) {
|
||||
setTimeout(() => this.executeNextCommand(), this.commandDelay); // Process next potential rejection
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.currentCommand = this.commandQueue.shift();
|
||||
const { command, reject } = this.currentCommand;
|
||||
|
||||
if (this.debug) console.log(`SerialCLI: Executing command: "${command}"`);
|
||||
|
||||
// Set response timeout
|
||||
this.currentCommand.timeout = setTimeout(() => {
|
||||
if (this.currentCommand && this.currentCommand.command === command) { // Ensure it's still the same command
|
||||
const timeoutMsg = `Command timeout: ${command}`;
|
||||
console.error("SerialCLI:", timeoutMsg);
|
||||
reject(new Error(timeoutMsg));
|
||||
this.currentCommand = null; // Clear current command on timeout
|
||||
|
||||
// Try the next command after a delay
|
||||
if (this.commandQueue.length > 0) {
|
||||
if (this.debug) console.log("SerialCLI: Scheduling next command after timeout.");
|
||||
setTimeout(() => this.executeNextCommand(), this.commandDelay);
|
||||
}
|
||||
}
|
||||
}, this.responseTimeout);
|
||||
|
||||
try {
|
||||
const dataToSend = this.encoder.encode(command + '\r');
|
||||
if (this.debug) {
|
||||
console.log("SerialCLI >>> SEND:", JSON.stringify(command + '\\r')); // Log data being sent
|
||||
}
|
||||
await this.writer.write(dataToSend);
|
||||
} catch (error) {
|
||||
console.error(`SerialCLI: Error writing command "${command}":`, error);
|
||||
clearTimeout(this.currentCommand.timeout);
|
||||
reject(error);
|
||||
this.currentCommand = null; // Clear current command on write error
|
||||
|
||||
// Try the next command after a delay
|
||||
if (this.commandQueue.length > 0) {
|
||||
if (this.debug) console.log("SerialCLI: Scheduling next command after write error.");
|
||||
setTimeout(() => this.executeNextCommand(), this.commandDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to the device
|
||||
* @param {string} command - The command to send
|
||||
* @param {boolean} isLogCommand - Whether this is a log command with multi-line response ending in EOF
|
||||
* @returns {Promise<string>} The device's response
|
||||
*/
|
||||
sendCommand(command, isLogCommand = false) {
|
||||
if (!this.port || !this.writer) {
|
||||
const errorMsg = 'Serial connection not open or writer unavailable';
|
||||
console.error("SerialCLI:", errorMsg);
|
||||
return Promise.reject(new Error(errorMsg));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.commandQueue.push({ command, resolve, reject, isLogCommand });
|
||||
if (this.debug) console.log(`SerialCLI: Queued command: "${command}". Queue length: ${this.commandQueue.length}`);
|
||||
|
||||
// If no current command is active, start execution immediately
|
||||
if (!this.currentCommand) {
|
||||
if (this.debug) console.log("SerialCLI: Triggering command execution from sendCommand.");
|
||||
this.executeNextCommand();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============= CONVENIENCE METHODS =============
|
||||
|
||||
/**
|
||||
* Get the device firmware version
|
||||
* @returns {Promise<string>} Version information
|
||||
*/
|
||||
async getVersion() {
|
||||
return this.sendCommand('ver');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current clock time
|
||||
* @returns {Promise<string>} Current time
|
||||
*/
|
||||
async getClock() {
|
||||
return this.sendCommand('clock');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the time (in epoch seconds)
|
||||
* @param {number} seconds - Epoch seconds
|
||||
* @returns {Promise<string>} Response from device
|
||||
*/
|
||||
async setTime(seconds) {
|
||||
// Ensure seconds is a valid number
|
||||
if (typeof seconds !== 'number' || !Number.isInteger(seconds) || seconds < 0) {
|
||||
return Promise.reject(new Error("Invalid time value. Must be a non-negative integer."));
|
||||
}
|
||||
return this.sendCommand(`time ${seconds}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reboot the device
|
||||
* @returns {Promise<string>} Response from device (Note: response might not be received if reboot is immediate)
|
||||
*/
|
||||
async reboot() {
|
||||
// Don't necessarily expect a standard response format for reboot
|
||||
// Consider adding a short delay after sending if needed by the calling code
|
||||
return this.sendCommand('reboot');
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase filesystem (factory reset)
|
||||
* @returns {Promise<string>} Response from device
|
||||
*/
|
||||
async erase() {
|
||||
return this.sendCommand('erase');
|
||||
}
|
||||
|
||||
/**
|
||||
* Force device to send an advertisement
|
||||
* @returns {Promise<string>} Response from device
|
||||
*/
|
||||
async sendAdvert() {
|
||||
return this.sendCommand('advert');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OTA update
|
||||
* @returns {Promise<string>} Response from device
|
||||
*/
|
||||
async startOTA() {
|
||||
// Might need specific node name from prefs? The C++ code suggests yes.
|
||||
// This JS version doesn't store prefs, so we send the basic command.
|
||||
// Consider adding a parameter if the node name is needed.
|
||||
return this.sendCommand('start ota');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a variable value
|
||||
* @param {string} variable - The variable name (e.g., 'name', 'lat', 'tx')
|
||||
* @returns {Promise<string>} Raw variable value string from device (e.g., "> MyNode", "> 10", "> 433.125")
|
||||
*/
|
||||
async getVariable(variable) {
|
||||
return this.sendCommand(`get ${variable}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a variable value
|
||||
* @param {string} variable - The variable name
|
||||
* @param {string|number|boolean} value - The value to set
|
||||
* @returns {Promise<string>} Response from device (usually "OK" or an error)
|
||||
*/
|
||||
async setVariable(variable, value) {
|
||||
// Convert boolean 'true'/'false' to 'on'/'off' if appropriate for specific vars later
|
||||
return this.sendCommand(`set ${variable} ${value}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set admin password
|
||||
* @param {string} password - Admin password
|
||||
* @returns {Promise<string>} Response from device
|
||||
*/
|
||||
async setPassword(password) {
|
||||
// Basic validation: ensure password is a non-empty string
|
||||
if (typeof password !== 'string' || password.length === 0) {
|
||||
return Promise.reject(new Error("Password cannot be empty."));
|
||||
}
|
||||
// Potentially add checks for invalid characters if needed
|
||||
return this.sendCommand(`password ${password}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve log data
|
||||
* @returns {Promise<string>} Log contents (multi-line string)
|
||||
*/
|
||||
async getLog() {
|
||||
return this.sendCommand('log', true); // Mark as log command for multi-line EOF handling
|
||||
}
|
||||
|
||||
/**
|
||||
* Start logging
|
||||
* @returns {Promise<string>} Response from device
|
||||
*/
|
||||
async startLogging() {
|
||||
return this.sendCommand('log start');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop logging
|
||||
* @returns {Promise<string>} Response from device
|
||||
*/
|
||||
async stopLogging() {
|
||||
return this.sendCommand('log stop');
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase the log file
|
||||
* @returns {Promise<string>} Response from device
|
||||
*/
|
||||
async eraseLog() {
|
||||
return this.sendCommand('log erase');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse response from getVariable commands, removing the "> " prefix and attempting type conversion.
|
||||
* @param {string} response - The raw response string from a getVariable command (e.g., "> MyNode", "> 10", "> on")
|
||||
* @returns {string|number|boolean|null} The parsed value, or null if parsing fails or response format is unexpected.
|
||||
*/
|
||||
parseVariableResponse(response) {
|
||||
if (typeof response !== 'string' || !response.startsWith('> ')) {
|
||||
if(this.debug) console.warn(`SerialCLI: Unexpected format for parseVariableResponse: "${response}"`);
|
||||
return null; // Or return the original response? Returning null indicates parsing issue.
|
||||
}
|
||||
|
||||
const value = response.substring(2).trim(); // Remove "> " and trim whitespace
|
||||
|
||||
// Check for empty value after prefix removal
|
||||
if (value === '') {
|
||||
return ''; // Return empty string if that was the actual value
|
||||
}
|
||||
|
||||
// Try to parse as number (integer or float)
|
||||
// Updated regex to handle negative numbers and ensure it's the *entire* string
|
||||
if (/^-?\d+(\.\d+)?$/.test(value)) {
|
||||
return Number(value); // Use Number() to handle both int and float
|
||||
}
|
||||
|
||||
// Handle boolean 'on'/'off' (case-insensitive)
|
||||
if (value.toLowerCase() === 'on') return true;
|
||||
if (value.toLowerCase() === 'off') return false;
|
||||
|
||||
// Return as string for all other cases
|
||||
return value;
|
||||
}
|
||||
|
||||
// ============= SPECIFIC VARIABLE GETTERS/SETTERS (using parseVariableResponse) =============
|
||||
|
||||
async getRole() {
|
||||
const response = await this.getVariable('role');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async getPubKey() {
|
||||
const response = await this.getVariable('public.key');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async getName() {
|
||||
const response = await this.getVariable('name');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async setName(name) {
|
||||
if (typeof name !== 'string') return Promise.reject(new Error("Name must be a string."));
|
||||
// Add validation for length or characters based on device limits if known
|
||||
return this.setVariable('name', name);
|
||||
}
|
||||
|
||||
async getLatitude() {
|
||||
const response = await this.getVariable('lat');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async setLatitude(lat) {
|
||||
if (typeof lat !== 'number') return Promise.reject(new Error("Latitude must be a number."));
|
||||
// Add validation for range (-90 to 90) if needed
|
||||
return this.setVariable('lat', lat);
|
||||
}
|
||||
|
||||
async getLongitude() {
|
||||
const response = await this.getVariable('lon');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async setLongitude(lon) {
|
||||
if (typeof lon !== 'number') return Promise.reject(new Error("Longitude must be a number."));
|
||||
// Add validation for range (-180 to 180) if needed
|
||||
return this.setVariable('lon', lon);
|
||||
}
|
||||
|
||||
async getRadioConfig() {
|
||||
const response = await this.getVariable('radio');
|
||||
const parsed = this.parseVariableResponse(response);
|
||||
if (typeof parsed === 'string') {
|
||||
const parts = parsed.split(',');
|
||||
if (parts.length === 4) {
|
||||
return {
|
||||
freq: parseFloat(parts[0]) || null,
|
||||
bw: parseFloat(parts[1]) || null,
|
||||
sf: parseInt(parts[2], 10) || null,
|
||||
cr: parseInt(parts[3], 10) || null
|
||||
};
|
||||
}
|
||||
}
|
||||
if (this.debug) console.warn("SerialCLI: Could not parse radio config response:", response);
|
||||
return null; // Indicate parsing failure
|
||||
}
|
||||
|
||||
async setRadioConfig(freq, bw, sf, cr) {
|
||||
// Add validation for types and ranges if necessary
|
||||
if (typeof freq !== 'number' || typeof bw !== 'number' || typeof sf !== 'number' || typeof cr !== 'number') {
|
||||
return Promise.reject(new Error("Invalid radio parameters. All must be numbers."));
|
||||
}
|
||||
return this.setVariable('radio', `${freq},${bw},${sf},${cr}`);
|
||||
}
|
||||
|
||||
async getTxPower() {
|
||||
const response = await this.getVariable('tx');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async setTxPower(power) {
|
||||
if (typeof power !== 'number') return Promise.reject(new Error("TX Power must be a number."));
|
||||
// Add validation for range based on device capabilities if known (e.g., 1-30)
|
||||
return this.setVariable('tx', power);
|
||||
}
|
||||
|
||||
async getAirtimeFactor() {
|
||||
const response = await this.getVariable('af');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async setAirtimeFactor(factor) {
|
||||
if (typeof factor !== 'number') return Promise.reject(new Error("Airtime factor must be a number."));
|
||||
// Add validation for range (e.g., 0-9)
|
||||
return this.setVariable('af', factor);
|
||||
}
|
||||
|
||||
async getRepeat() {
|
||||
const response = await this.getVariable('repeat');
|
||||
return this.parseVariableResponse(response); // Should return true/false
|
||||
}
|
||||
|
||||
async setRepeat(enabled) {
|
||||
if (typeof enabled !== 'boolean') return Promise.reject(new Error("Repeat value must be boolean (true/false)."));
|
||||
return this.setVariable('repeat', enabled ? 'on' : 'off');
|
||||
}
|
||||
|
||||
// Note: 'allow.read.only' is not in the C++ code provided, assuming it might exist elsewhere or is hypothetical.
|
||||
// If it exists and uses 'on'/'off', the pattern is the same as 'setRepeat'.
|
||||
// async getAllowReadOnly() { ... }
|
||||
// async setAllowReadOnly(enabled) { ... }
|
||||
|
||||
async getAdvertInterval() {
|
||||
// C++ stores as interval/2, retrieves as interval*2 (minutes)
|
||||
const response = await this.getVariable('advert.interval');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async setAdvertInterval(minutes) {
|
||||
if (typeof minutes !== 'number' || !Number.isInteger(minutes)) return Promise.reject(new Error("Advert interval must be an integer (minutes)."));
|
||||
// Add validation based on C++ code (min 60, max 240, or 0 for off)
|
||||
if (minutes !== 0 && (minutes < 60 || minutes > 240)) {
|
||||
return Promise.reject(new Error("Advert interval must be 0 (off) or between 60 and 240 minutes."));
|
||||
}
|
||||
return this.setVariable('advert.interval', minutes);
|
||||
}
|
||||
|
||||
// Note: 'flood.advert.interval' is not in the C++ code provided.
|
||||
// async getFloodAdvertInterval() { ... }
|
||||
// async setFloodAdvertInterval(hours) { ... }
|
||||
|
||||
async getGuestPassword() {
|
||||
const response = await this.getVariable('guest.password');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async setGuestPassword(password) {
|
||||
if (typeof password !== 'string') return Promise.reject(new Error("Guest password must be a string."));
|
||||
// Consider adding length/character validation
|
||||
return this.setVariable('guest.password', password);
|
||||
}
|
||||
|
||||
async getRxDelay() {
|
||||
const response = await this.getVariable('rxdelay');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async setRxDelay(delay) {
|
||||
if (typeof delay !== 'number' || delay < 0) return Promise.reject(new Error("RX Delay must be a non-negative number."));
|
||||
// Add validation for range (e.g., 0-20)
|
||||
return this.setVariable('rxdelay', delay);
|
||||
}
|
||||
|
||||
async getTxDelay() {
|
||||
const response = await this.getVariable('txdelay');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async setTxDelay(delay) {
|
||||
if (typeof delay !== 'number' || delay < 0) return Promise.reject(new Error("TX Delay factor must be a non-negative number."));
|
||||
// Add validation for range (e.g., 0-2)
|
||||
return this.setVariable('txdelay', delay);
|
||||
}
|
||||
|
||||
async getDirectTxDelay() {
|
||||
const response = await this.getVariable('direct.txdelay');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async setDirectTxDelay(delay) {
|
||||
if (typeof delay !== 'number' || delay < 0) return Promise.reject(new Error("Direct TX Delay factor must be a non-negative number."));
|
||||
// Add validation for range (e.g., 0-2)
|
||||
return this.setVariable('direct.txdelay', delay);
|
||||
}
|
||||
|
||||
async getFloodMax() {
|
||||
const response = await this.getVariable('flood.max');
|
||||
return this.parseVariableResponse(response);
|
||||
}
|
||||
|
||||
async setFloodMax(max) {
|
||||
if (typeof max !== 'number' || !Number.isInteger(max) || max < 0 || max > 64) {
|
||||
return Promise.reject(new Error("Flood Max must be an integer between 0 and 64."));
|
||||
}
|
||||
return this.setVariable('flood.max', max);
|
||||
}
|
||||
}
|
||||
118
flasher/lib/vanity-key-generator.js
Normal file
118
flasher/lib/vanity-key-generator.js
Normal file
@@ -0,0 +1,118 @@
|
||||
export class VanityKeyGenerator {
|
||||
constructor() {
|
||||
this.workers = [];
|
||||
this.running = false;
|
||||
this._resolve = null;
|
||||
this._reject = null;
|
||||
this._totalAttempts = 0;
|
||||
this.onProgress = null;
|
||||
}
|
||||
|
||||
static get numCores() {
|
||||
return navigator.hardwareConcurrency || 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate time for a given hex prefix length
|
||||
* @param {number} prefixLen - number of hex chars
|
||||
* @param {number} keysPerSec - estimated throughput
|
||||
* @returns {string} human-readable estimate
|
||||
*/
|
||||
static estimateTime(prefixLen, keysPerSec) {
|
||||
if (prefixLen === 0) return 'instant';
|
||||
const expected = Math.pow(16, prefixLen);
|
||||
const seconds = expected / keysPerSec;
|
||||
|
||||
if (seconds < 1) return 'less than a second';
|
||||
if (seconds < 60) return `~${Math.ceil(seconds)} seconds`;
|
||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)} minutes`;
|
||||
if (seconds < 86400) return `~${Math.ceil(seconds / 3600)} hours`;
|
||||
return `~${Math.ceil(seconds / 86400)} days`;
|
||||
}
|
||||
|
||||
get attempts() {
|
||||
return this._totalAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start generating a vanity key
|
||||
* @param {string} prefix - hex prefix to match (1-6 chars)
|
||||
* @returns {Promise<{ privKey: string, pubKey: string, attempts: number } | null>}
|
||||
*/
|
||||
generate(prefix) {
|
||||
if (this.running) throw new Error('Already running');
|
||||
|
||||
prefix = prefix.replace(/[^0-9a-fA-F]/g, '');
|
||||
if (prefix.length === 0 || prefix.length > 6) {
|
||||
throw new Error('Prefix must be 1-6 hex characters');
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
this._totalAttempts = 0;
|
||||
const numWorkers = VanityKeyGenerator.numCores;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
|
||||
for (let i = 0; i < numWorkers; i++) {
|
||||
const worker = new Worker(
|
||||
new URL('./vanity-key-worker.js', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
|
||||
worker.onmessage = (e) => {
|
||||
if (!this.running) return;
|
||||
const data = e.data;
|
||||
|
||||
if (data.type === 'progress') {
|
||||
this._totalAttempts += data.attempts;
|
||||
if (this.onProgress) this.onProgress(this._totalAttempts);
|
||||
} else if (data.type === 'match') {
|
||||
this._totalAttempts += data.attempts;
|
||||
const result = {
|
||||
privKey: data.privKey,
|
||||
pubKey: data.pubKey,
|
||||
attempts: this._totalAttempts,
|
||||
};
|
||||
this._stopWorkers();
|
||||
this.running = false;
|
||||
resolve(result);
|
||||
} else if (data.type === 'error') {
|
||||
this._stopWorkers();
|
||||
this.running = false;
|
||||
reject(new Error(data.message));
|
||||
} else if (data.type === 'stopped') {
|
||||
this._totalAttempts += data.attempts;
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (err) => {
|
||||
this._stopWorkers();
|
||||
this.running = false;
|
||||
reject(new Error(err.message || 'Worker error'));
|
||||
};
|
||||
|
||||
worker.postMessage({ type: 'start', prefix, progressInterval: 200 });
|
||||
this.workers.push(worker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_stopWorkers() {
|
||||
for (const worker of this.workers) {
|
||||
try { worker.postMessage({ type: 'stop' }); } catch (e) {}
|
||||
setTimeout(() => worker.terminate(), 500);
|
||||
}
|
||||
this.workers = [];
|
||||
}
|
||||
|
||||
cancel() {
|
||||
const reject = this._reject;
|
||||
this._stopWorkers();
|
||||
this.running = false;
|
||||
if (reject) reject(new Error('Cancelled'));
|
||||
this._resolve = null;
|
||||
this._reject = null;
|
||||
}
|
||||
}
|
||||
1
flasher/lib/zip.min.js
vendored
Normal file
1
flasher/lib/zip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
116
flasher/pio_create_dfu_zip.py
Normal file
116
flasher/pio_create_dfu_zip.py
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PlatformIO extra script: generate DFU .zip after build.
|
||||
|
||||
Add to platformio.ini:
|
||||
extra_scripts = post:flasher/pio_create_dfu_zip.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import struct
|
||||
import zipfile
|
||||
|
||||
# Add flasher dir to path for importing the main module
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
||||
|
||||
try:
|
||||
from create_dfu_zip import hex_to_bin, uf2_to_bin, create_dfu_zip
|
||||
except ImportError:
|
||||
# Fallback: define minimal conversion inline
|
||||
def hex_to_bin(hex_path):
|
||||
from intelhex import IntelHex
|
||||
ih = IntelHex(hex_path)
|
||||
min_addr = ih.minaddr() or 0
|
||||
max_addr = ih.maxaddr() or 0
|
||||
size = max_addr - min_addr + 1
|
||||
page_size = 0x1000
|
||||
aligned = ((size + page_size - 1) // page_size) * page_size
|
||||
return bytes(ih.tobinarray(start=min_addr, size=aligned)), min_addr
|
||||
|
||||
def create_dfu_zip(bin_data, base_addr, fw_version=1, hw_version=52):
|
||||
init_packet = bytearray()
|
||||
init_packet.append(0x01)
|
||||
init_packet.append(0x04)
|
||||
init_packet += struct.pack('<I', 0xFFFE)
|
||||
init_packet += struct.pack('<I', 0xFFFFFFFF)
|
||||
init_packet += struct.pack('<I', hw_version)
|
||||
init_packet += struct.pack('<I', fw_version)
|
||||
init_packet += struct.pack('<II', 0, 0)
|
||||
manifest = {
|
||||
"manifest": {
|
||||
"application": {
|
||||
"bin_file": "firmware.bin",
|
||||
"dat_file": "firmware.dat",
|
||||
"init_packet_data": {
|
||||
"fw_version": fw_version,
|
||||
"hw_version": hw_version,
|
||||
"softdevice_req": [0xFFFE],
|
||||
"components": [{"data": list(init_packet)}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return manifest, bin_data, bytes(init_packet)
|
||||
|
||||
|
||||
def create_dfu_zip_from_build(target, source, env):
|
||||
"""PlatformIO post-build hook."""
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
prog_name = env.subst("$PROGNAME")
|
||||
hex_file = os.path.join(build_dir, prog_name + ".hex")
|
||||
|
||||
if not os.path.isfile(hex_file):
|
||||
print(f" [DFU] {hex_file} not found, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
bin_data, base_addr = hex_to_bin(hex_file)
|
||||
except Exception as e:
|
||||
print(f" [DFU] Error: {e}")
|
||||
return
|
||||
|
||||
manifest, fw_bin, fw_dat = create_dfu_zip(bin_data, base_addr)
|
||||
|
||||
# Name the zip after the build target (environment name)
|
||||
env_name = env.subst("$PIOENV")
|
||||
# Extract firmware name from env: e.g. Heltec_t114_without_display_beacon_sensor_ble -> Heltec_T114_Beacon_BLE
|
||||
parts = env_name.split("_")
|
||||
if "beacon" in parts:
|
||||
fw_name = "Heltec_T114_Beacon"
|
||||
elif "companion" in parts:
|
||||
fw_name = "Heltec_T114_Companion_Radio"
|
||||
elif "repeater" in parts:
|
||||
fw_name = "Heltec_T114_Repeater"
|
||||
elif "room" in parts:
|
||||
fw_name = "Heltec_T114_Room_Server"
|
||||
else:
|
||||
fw_name = "firmware"
|
||||
|
||||
if "ble" in parts:
|
||||
fw_name += "_BLE"
|
||||
elif "usb" in parts:
|
||||
fw_name += "_USB"
|
||||
|
||||
zip_name = f"{fw_name}.dfu.zip"
|
||||
zip_path = os.path.join(build_dir, zip_name)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("manifest.json", json.dumps(manifest, indent=2))
|
||||
zf.writestr("firmware.bin", fw_bin)
|
||||
zf.writestr("firmware.dat", fw_dat)
|
||||
|
||||
size = os.path.getsize(zip_path)
|
||||
print(f" [DFU] Created: {zip_path} ({size / 1024:.0f} KB)")
|
||||
|
||||
|
||||
# Register hook
|
||||
Import("env")
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.hex", create_dfu_zip_from_build)
|
||||
|
||||
# Also trigger after .uf2 creation if that action exists
|
||||
try:
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.uf2", create_dfu_zip_from_build)
|
||||
except:
|
||||
pass
|
||||
25
flasher/releases.json
Normal file
25
flasher/releases.json
Normal file
@@ -0,0 +1,25 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"tag_name": "beacon-v1.0.0",
|
||||
"name": "Beacon Sensor v1.0.0",
|
||||
"body": "Прошивка Beacon Sensor для Heltec T114\n\n- Автоматическая отправка flood-объявления каждые 15 минут\n- ADV_TYPE_CHAT с ретрансляцией\n- Поддержка BMP280 по I2C (Wire1, SDA=7, SCL=8)\n- BLE (пин 123456)\n- Радиопараметры: 868.731 MHz, SF7, BW62.5, CR7\n\n## Сборка\npio run -e Heltec_t114_without_display_beacon_sensor_ble -t create_uf2\n\n## Прошивка (UF2)\n1. Зажми BOOT на T114, подключи USB\n2. Перетащи firmware.uf2 на диск T114",
|
||||
"published_at": "2026-06-05T06:36:40Z",
|
||||
"assets": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Heltec_T114_Beacon_BLE.uf2",
|
||||
"size": 887296,
|
||||
"download_count": 12,
|
||||
"browser_download_url": "./releases/Heltec_T114_Beacon_BLE.uf2"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Heltec_T114_Beacon_BLE.dfu.zip",
|
||||
"size": 305769,
|
||||
"download_count": 3,
|
||||
"browser_download_url": "./releases/Heltec_T114_Beacon_BLE.dfu.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
BIN
flasher/releases/Heltec_T114_Beacon_BLE.dfu.zip
Normal file
BIN
flasher/releases/Heltec_T114_Beacon_BLE.dfu.zip
Normal file
Binary file not shown.
32
platformio.local.ini
Normal file
32
platformio.local.ini
Normal file
@@ -0,0 +1,32 @@
|
||||
[env:Heltec_t114_without_display_simple_sensor]
|
||||
extends = Heltec_t114
|
||||
build_src_filter = ${Heltec_t114.build_src_filter}
|
||||
+<../examples/simple_sensor>
|
||||
build_flags =
|
||||
${Heltec_t114.build_flags}
|
||||
-D ADVERT_NAME='"Heltec_T114 Simple Sensor"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D SENSOR_READ_INTERVAL_SECS=60
|
||||
-D LORA_FREQ=868.856018
|
||||
-D LORA_SF=7
|
||||
-D LORA_CR=7
|
||||
|
||||
[env:heltec_v4_simple_sensor]
|
||||
extends = Heltec_lora32_v4
|
||||
build_src_filter = ${Heltec_lora32_v4.build_src_filter}
|
||||
+<../examples/simple_sensor>
|
||||
build_flags =
|
||||
${Heltec_lora32_v4.build_flags}
|
||||
-D ADVERT_NAME='"Heltec_V4 Simple Sensor"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D SENSOR_READ_INTERVAL_SECS=60
|
||||
-D LORA_FREQ=868.856018
|
||||
-D LORA_SF=7
|
||||
-D LORA_CR=7
|
||||
lib_deps =
|
||||
${Heltec_lora32_v4.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
@@ -117,6 +117,34 @@ lib_deps =
|
||||
${Heltec_t114.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:Heltec_t114_without_display_beacon_sensor_ble]
|
||||
extends = Heltec_t114
|
||||
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
|
||||
board_upload.maximum_size = 712704
|
||||
extra_scripts = post:flasher/pio_create_dfu_zip.py
|
||||
build_flags =
|
||||
${Heltec_t114.build_flags}
|
||||
-I examples/beacon_sensor/ui-new
|
||||
-D DISPLAY_CLASS=NullDisplayDriver
|
||||
-D MAX_CONTACTS=50
|
||||
-D MAX_GROUP_CHANNELS=10
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D ADVERT_NAME='"Beacon-T114"'
|
||||
-D LORA_FREQ=868.731
|
||||
-D LORA_BW=62.5
|
||||
-D LORA_SF=7
|
||||
-D LORA_CR=7
|
||||
-D OFFLINE_QUEUE_SIZE=16
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
build_src_filter = ${Heltec_t114.build_src_filter}
|
||||
+<helpers/nrf52/SerialBLEInterface.cpp>
|
||||
+<../examples/beacon_sensor/*.cpp>
|
||||
+<../examples/beacon_sensor/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${Heltec_t114.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:Heltec_t114_without_display_companion_radio_usb]
|
||||
extends = Heltec_t114
|
||||
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
|
||||
@@ -139,6 +167,29 @@ lib_deps =
|
||||
${Heltec_t114.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:Heltec_t114_without_display_beacon_sensor_usb]
|
||||
extends = Heltec_t114
|
||||
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
|
||||
board_upload.maximum_size = 712704
|
||||
extra_scripts = post:flasher/pio_create_dfu_zip.py
|
||||
build_flags =
|
||||
${Heltec_t114.build_flags}
|
||||
-I examples/beacon_sensor/ui-new
|
||||
-D DISPLAY_CLASS=NullDisplayDriver
|
||||
-D MAX_CONTACTS=50
|
||||
-D MAX_GROUP_CHANNELS=10
|
||||
-D ADVERT_NAME='"BMP280-Beacon"'
|
||||
-D OFFLINE_QUEUE_SIZE=16
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
build_src_filter = ${Heltec_t114.build_src_filter}
|
||||
+<helpers/nrf52/*.cpp>
|
||||
+<../examples/beacon_sensor/*.cpp>
|
||||
+<../examples/beacon_sensor/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${Heltec_t114.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
;
|
||||
; Heltec T114 with ST7789 display
|
||||
;
|
||||
@@ -232,6 +283,32 @@ lib_deps =
|
||||
${Heltec_t114_with_display.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:Heltec_t114_beacon_sensor_ble]
|
||||
extends = Heltec_t114_with_display
|
||||
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
|
||||
board_upload.maximum_size = 712704
|
||||
build_flags =
|
||||
${Heltec_t114_with_display.build_flags}
|
||||
-I examples/beacon_sensor/ui-new
|
||||
-D MAX_CONTACTS=50
|
||||
-D MAX_GROUP_CHANNELS=10
|
||||
-D BLE_PIN_CODE=123456
|
||||
-D ADVERT_NAME='"Beacon-T114"'
|
||||
-D LORA_FREQ=868.731
|
||||
-D LORA_BW=62.5
|
||||
-D LORA_SF=7
|
||||
-D LORA_CR=7
|
||||
-D OFFLINE_QUEUE_SIZE=16
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
build_src_filter = ${Heltec_t114_with_display.build_src_filter}
|
||||
+<helpers/nrf52/SerialBLEInterface.cpp>
|
||||
+<../examples/beacon_sensor/*.cpp>
|
||||
+<../examples/beacon_sensor/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${Heltec_t114_with_display.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:Heltec_t114_companion_radio_usb]
|
||||
extends = Heltec_t114_with_display
|
||||
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
|
||||
@@ -252,3 +329,24 @@ build_src_filter = ${Heltec_t114_with_display.build_src_filter}
|
||||
lib_deps =
|
||||
${Heltec_t114_with_display.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:Heltec_t114_beacon_sensor_usb]
|
||||
extends = Heltec_t114_with_display
|
||||
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
|
||||
board_upload.maximum_size = 712704
|
||||
build_flags =
|
||||
${Heltec_t114_with_display.build_flags}
|
||||
-I examples/beacon_sensor/ui-new
|
||||
-D MAX_CONTACTS=50
|
||||
-D MAX_GROUP_CHANNELS=10
|
||||
-D ADVERT_NAME='"BMP280-Beacon"'
|
||||
-D OFFLINE_QUEUE_SIZE=16
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
build_src_filter = ${Heltec_t114_with_display.build_src_filter}
|
||||
+<helpers/nrf52/*.cpp>
|
||||
+<../examples/beacon_sensor/*.cpp>
|
||||
+<../examples/beacon_sensor/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${Heltec_t114_with_display.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
Reference in New Issue
Block a user