feat: complete Cascade Indicator companion support

Add native-resolution Indicator UI, touch navigation, SD-backed pixel and emoji fonts, LoRa radio support, dual-slot WiFi Companion provisioning, and mesh clock fallback. Harden radio recovery and build defaults, normalize ESP32 setup-AP protocols, and document/test the operator workflows.
This commit is contained in:
mikecarper committed 2026-08-25 18:37:17 -07:00
1 parent 9b5541a44d
commit 5938cbe614
55 files changed
+4185 -175

No files matched your search

+98 -21
View File
@@ -23,12 +23,17 @@ FIRMWARE_FILENAME_INFIX=""
ESP32_FULL_BUILD=0
SINGLE_TARGET_FULL_BUILD=0
RADIO_SETTINGS_API_URL="https://api.meshcore.nz/api/v1/config"
RADIO_SETTING_TITLE=""
RADIO_FREQ_OVERRIDE=""
RADIO_BW_OVERRIDE=""
RADIO_SF_OVERRIDE=""
RADIO_CR_OVERRIDE=""
FIRMWARE_PROFILE_OVERRIDE="${FIRMWARE_PROFILE_OVERRIDE:-}"
USA_CASCADIA_RADIO_TITLE="USA Cascadia"
USA_CASCADIA_FALLBACK_FREQ="910.525"
USA_CASCADIA_FALLBACK_BW="62.5"
USA_CASCADIA_FALLBACK_SF="7"
USA_CASCADIA_FALLBACK_CR="5"
RADIO_SETTING_TITLE="$USA_CASCADIA_RADIO_TITLE"
RADIO_FREQ_OVERRIDE="$USA_CASCADIA_FALLBACK_FREQ"
RADIO_BW_OVERRIDE="$USA_CASCADIA_FALLBACK_BW"
RADIO_SF_OVERRIDE="$USA_CASCADIA_FALLBACK_SF"
RADIO_CR_OVERRIDE="$USA_CASCADIA_FALLBACK_CR"
FIRMWARE_PROFILE_OVERRIDE="${FIRMWARE_PROFILE_OVERRIDE:-cascade}"
BATCH_BUILD_MODE=0
OPTION3_BUILD_WORKERS="${OPTION3_BUILD_WORKERS:-2}"
OPTION3_PIO_JOBS="${OPTION3_PIO_JOBS:-8}"
@@ -101,8 +106,8 @@ Commands:
Options:
--firmware-version <version>: Firmware version to embed.
--radio-preset <number>: Use the numbered radio choice from the interactive menu (1 keeps target defaults).
--profile <default|cascade>: Select the firmware settings profile.
--radio-preset <name|number>: Override the USA Cascadia radio default. Stable names are usa-cascadia and target; legacy menu numbers remain accepted.
--profile <default|cascade>: Override the default Cascade firmware settings profile.
--skip-kiss|--include-kiss: Exclude (default) or include KISS modem targets in bulk builds.
--clean|--resume: Clean output or resume existing Option 3/FULL-only artifacts.
@@ -115,6 +120,11 @@ FULL-everything profile for supported ESP32 Option 1 targets, debug options,
radio settings, firmware profile, and firmware version
$ bash build.sh
Builds default to the live USA/Canada preset by name and the Cascade firmware
profile. If the preset service is offline, the radio fallback is 910.525 MHz /
BW62.5 / SF7 / CR5. To intentionally use a target's own defaults instead:
$ bash build.sh build-firmware RAK_4631_repeater --radio-preset target --profile default
Build all firmwares for device targets containing the string "RAK_4631"
$ bash build.sh build-matching-firmwares <build-match-spec>
@@ -672,11 +682,23 @@ clear_firmware_profile_overrides() {
apply_cli_radio_preset() {
local selection=$1
local preset_output row
local preset_output row title description freq bw sf cr
local -a preset_rows=()
case "${selection,,}" in
usa|usa-canada|usa-cascadia|cascadia)
resolve_usa_cascadia_radio_default
return 0
;;
default|target|target-defaults)
clear_radio_overrides
echo "Using target default radio settings."
return 0
;;
esac
if ! [[ "$selection" =~ ^[0-9]+$ ]] || [ "$selection" -lt 1 ]; then
echo "Invalid --radio-preset value: ${selection}"
echo "Invalid --radio-preset value: ${selection} (use usa-cascadia, target, or a legacy menu number)"
return 1
fi
clear_radio_overrides
@@ -698,7 +720,7 @@ apply_cli_radio_preset() {
row=${preset_rows[$((selection - 2))]}
IFS=$'\t' read -r title description freq bw sf cr <<< "$row"
set_radio_overrides "$title" "$freq" "$bw" "$sf" "$cr"
echo "Using radio setting ${selection}: ${RADIO_SETTING_TITLE} (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})"
echo "Using legacy numbered radio setting ${selection}: ${RADIO_SETTING_TITLE} (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})"
}
parse_cli_options() {
@@ -819,6 +841,43 @@ for entry in entries:
PY
}
is_usa_cascadia_radio_title() {
local title=${1,,}
[[ "$title" == usa*canada* ]]
}
set_usa_cascadia_radio_fallback() {
set_radio_overrides \
"$USA_CASCADIA_RADIO_TITLE" \
"$USA_CASCADIA_FALLBACK_FREQ" \
"$USA_CASCADIA_FALLBACK_BW" \
"$USA_CASCADIA_FALLBACK_SF" \
"$USA_CASCADIA_FALLBACK_CR"
}
resolve_usa_cascadia_radio_default() {
local preset_output row title description freq bw sf cr
set_usa_cascadia_radio_fallback
if ! preset_output=$(fetch_suggested_radio_settings) || [ -z "$preset_output" ]; then
echo "USA Cascadia preset lookup unavailable; using offline fallback (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})."
return 0
fi
while IFS= read -r row; do
[ -n "$row" ] || continue
IFS=$'\t' read -r title description freq bw sf cr <<< "$row"
if is_usa_cascadia_radio_title "$title"; then
set_radio_overrides "$USA_CASCADIA_RADIO_TITLE" "$freq" "$bw" "$sf" "$cr"
echo "Resolved USA Cascadia by preset name '${title}': ${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE}."
return 0
fi
done <<< "$preset_output"
echo "USA/Canada preset not found; using offline USA Cascadia fallback (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})."
}
is_valid_custom_radio_bandwidth() {
python3 - "$1" <<'PY'
import sys
@@ -883,7 +942,15 @@ prompt_for_custom_radio_setting() {
prompt_for_radio_build_settings() {
local -a preset_rows=()
local -a fetched_preset_rows=()
local -a options=("Keep target defaults (no radio override)")
local default_title=$RADIO_SETTING_TITLE
local default_freq=$RADIO_FREQ_OVERRIDE
local default_bw=$RADIO_BW_OVERRIDE
local default_sf=$RADIO_SF_OVERRIDE
local default_cr=$RADIO_CR_OVERRIDE
local -a options=(
"USA Cascadia (default): ${default_freq} MHz / SF${default_sf} / BW${default_bw} / CR${default_cr}"
"Keep target defaults (no radio override)"
)
local row
local title
local description
@@ -896,8 +963,6 @@ prompt_for_radio_build_settings() {
local custom_index
local preset_output
clear_radio_overrides
if preset_output=$(fetch_suggested_radio_settings); then
if [ -n "$preset_output" ]; then
mapfile -t fetched_preset_rows <<< "$preset_output"
@@ -906,6 +971,10 @@ prompt_for_radio_build_settings() {
if [ -z "$row" ]; then
continue
fi
IFS=$'\t' read -r title description freq bw sf cr <<< "$row"
if is_usa_cascadia_radio_title "$title"; then
continue
fi
preset_rows+=("$row")
done
else
@@ -934,6 +1003,13 @@ prompt_for_radio_build_settings() {
choice_index=$MENU_CHOICE
if [ "$choice_index" -eq 1 ]; then
set_radio_overrides "$default_title" "$default_freq" "$default_bw" "$default_sf" "$default_cr"
echo "Using radio setting: ${RADIO_SETTING_TITLE} (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})"
return 0
fi
if [ "$choice_index" -eq 2 ]; then
clear_radio_overrides
echo "Using target default radio settings."
return 0
fi
@@ -944,7 +1020,7 @@ prompt_for_radio_build_settings() {
return 0
fi
preset_index=$((choice_index - 2))
preset_index=$((choice_index - 3))
if [ "$preset_index" -ge 0 ] && [ "$preset_index" -lt "${#preset_rows[@]}" ]; then
IFS=$'\t' read -r title description freq bw sf cr <<< "${preset_rows[$preset_index]}"
set_radio_overrides "$title" "$freq" "$bw" "$sf" "$cr"
@@ -956,12 +1032,10 @@ prompt_for_radio_build_settings() {
prompt_for_firmware_profile_settings() {
local -a options=(
"Cascade (default): power saving + RXPS on / WiFi power save=min / path.hash.mode=2 / loop.detect=minimal / cad=on / rxdelay=2 / agc.reset.interval=8 / advert.interval=0 / flood.advert.interval=83 / multi.acks=1 / companion.manual.add=1 / companion.autoadd=0"
"Keep target defaults"
"Cascade: power saving + RXPS on / WiFi power save=min / path.hash.mode=2 / loop.detect=minimal / cad=on / rxdelay=2 / agc.reset.interval=8 / advert.interval=0 / flood.advert.interval=83 / multi.acks=1 / companion.manual.add=1 / companion.autoadd=0"
)
clear_firmware_profile_overrides
echo "Set firmware profile options:"
while true; do
print_numbered_menu "${options[@]}"
@@ -973,12 +1047,13 @@ prompt_for_firmware_profile_settings() {
case "$MENU_CHOICE" in
1)
echo "Using target default firmware profile settings."
set_firmware_profile_override "cascade"
echo "Using firmware profile: Cascade"
return 0
;;
2)
set_firmware_profile_override "cascade"
echo "Using firmware profile: Cascade"
clear_firmware_profile_overrides
echo "Using target default firmware profile settings."
return 0
;;
esac
@@ -4092,6 +4167,8 @@ main() {
if ! apply_cli_radio_preset "$RADIO_PRESET_SELECTION"; then
exit 1
fi
else
resolve_usa_cascadia_radio_default
fi
if [ $# -eq 0 ]; then
+19 -6
View File
@@ -419,6 +419,16 @@ receives their final reply.
into a selected target; it does not change that target into another firmware
role.
This fork defaults every `build.sh` target to the USA Cascadia radio preset and
the Cascade firmware profile. The script resolves the live `USA/Canada` entry
by name, so its changing number in the downloaded menu does not affect builds.
If the preset service is unavailable, it falls back to
`910.525 MHz / BW62.5 / SF7 / CR5`. Use
`--radio-preset target --profile default` only when a build intentionally needs
the target's original radio and profile defaults. `--radio-preset usa-cascadia`
is the stable explicit name; legacy numbered choices remain accepted but their
meaning can change when the service reorders or adds presets.
| Build profile | WiFi/MQTT behavior |
|---|---|
| Standard | Uses the selected target's role. Ordinary legacy-slot ESP32 repeater/room-server artifacts omit WebConfig when needed to fit. ESP32 MQTT observer and ESP-NOW bridge targets are automatically promoted to FULL; WiFi-companion targets keep their companion partition profile. |
@@ -461,7 +471,7 @@ Fresh Cascade-profile builds default to `min`; target-default builds use
highest power use. `min` and `max` reduce power but may add latency or reduce
reliability on busy nodes. A saved operator setting takes precedence on an
upgrade. ESP32 WiFi Companions expose the same values in their WebConfig WiFi
card. Full Companion also accepts the text commands from its USB terminal and
card and USB text terminal. Full Companion also accepts the text commands from
TCP port 5002. The normal binary Companion protocol can read or write the
setting over USB, BLE, or TCP port 5000 without entering terminal mode. Full
Companion rejects `none` because its simultaneous BLE transport requires WiFi
@@ -475,9 +485,9 @@ WiFi only, not LoRa transmit power.
`get wifi.status`, `get wifi.ssid`, `get wifi.powersave`, and `get wifi.cli`
are available on MQTT observers and on FULL non-MQTT repeater/room-server
builds with WebConfig. ESP32 WiFi Companions expose `wifi.powersave` through
their role-specific interfaces described above; they do not expose the full
infrastructure WiFi CLI family.
builds with WebConfig. ESP32 WiFi Companions with WebConfig expose those
commands, credential setters, and `start webconfig [ap]` through their USB text
terminal; Full Companion additionally exposes that terminal on TCP port 5002.
MQTT commands such as `get mqtt.status` and `set mqtt1.preset ...` still require
an MQTT observer target. Unknown settings return `Error: unknown setting:
<name>`. Older firmware that used the discontinued compact CLI can instead
@@ -518,5 +528,8 @@ Common causes are:
- the wrong firmware role or an older compact-CLI build without WebConfig.
For a WiFi companion, find its station IP in the router, connect the client to
TCP port 5000, and use the setup AP if it cannot join the saved network. MQTT
diagnostics apply only to a `wifi_mqtt` companion target.
TCP port 5000, and use the open `MeshCore-Setup-XXXX` AP at
`http://192.168.4.1/` if it cannot join the saved network. Current firmware
normalizes both ESP32 WiFi interfaces to standard b/g/n before advertising the
setup AP, including after an ESP-NOW image used its long-range protocol mode.
MQTT diagnostics apply only to a `wifi_mqtt` companion target.
+5 -4
View File
@@ -126,10 +126,11 @@ does not exist on that target:
- WebConfig and the `wifi.ssid`, `wifi.status`, `wifi.powersave`, and `wifi.cli` command
family require an ESP32 WebConfig build. FULL standalone repeater and
room-server builds support the corresponding WiFi setters and status
commands. ESP32 WiFi Companion exposes `wifi.powersave` in its WebConfig
form and binary protocol; Full Companion exposes its complete role-specific
text terminal over USB and TCP port 5002, including WiFi credentials,
connection status, WebConfig, CLI-tab, and power-save controls.
commands. ESP32 WiFi Companions with WebConfig expose WiFi credentials,
connection status, WebConfig, CLI-tab, and power-save controls from their USB
text terminal as well as power saving through WebConfig and the binary
protocol. Full Companion additionally exposes its complete role-specific
text terminal on TCP port 5002.
- MQTT commands require an MQTT observer target.
- `discover.scopes` requires an MQTT observer with compiled neighbor support;
it does not independently require PSRAM or the FULL parser.
+6 -5
View File
@@ -252,11 +252,12 @@ take effect on the next connection. `get wifi.pwd` is intentionally unavailable
so the standalone password is never returned by the CLI.
ESP32 WiFi Companion WebConfig exposes the same `wifi.powersave` values in its
WiFi card. Full Companion exposes the same role-specific text terminal over
USB and TCP port 5002, including the standalone `wifi.ssid`, `wifi.status`,
`wifi.powersave`, `wifi.cli`, and WebConfig command families. Credential
writes reply before restarting the WiFi station, so a TCP client should expect
to reconnect at the new address; USB password input is masked. Binary
WiFi card. Every ESP32 WiFi Companion with WebConfig exposes the standalone
`wifi.ssid`, `wifi.status`, `wifi.powersave`, `wifi.cli`, and WebConfig command
families through its USB text terminal. Full Companion exposes the same
role-specific terminal on TCP port 5002. Credential writes reply before
restarting the WiFi station, so a TCP client should expect to reconnect at the
new address; USB password input is masked. Binary
Companion clients can use command bytes
`0x46` and `0x47` over USB, BLE, or TCP port 5000 without the terminal-start
token. WiFi-only Companions accept all three modes; Full Companion rejects
+13 -3
View File
@@ -13,6 +13,10 @@
#include "NodePrefs.h"
#ifndef UI_USB_AUTO_OFF_MULTIPLIER
#define UI_USB_AUTO_OFF_MULTIPLIER 5UL
#endif
enum class UIEventType {
none,
contactMessage,
@@ -35,11 +39,14 @@ protected:
bool isDisplayAutoOffDue(unsigned long configured_deadline,
unsigned long configured_timeout_millis) const {
unsigned long deadline = configured_deadline;
#if UI_USB_AUTO_OFF_MULTIPLIER > 1
if (_board->isUsbHostConnected()) {
// The configured deadline already includes the first timeout period.
// Add four more periods for a total of 5x while attached to a computer.
deadline += configured_timeout_millis * 4UL;
// Add the remaining periods while attached to a computer.
deadline += configured_timeout_millis
* (UI_USB_AUTO_OFF_MULTIPLIER - 1UL);
}
#endif
return static_cast<int32_t>(millis() - deadline) > 0;
}
@@ -59,7 +66,10 @@ public:
void enableBluetooth() { _interfaceManager->enableBluetooth(); }
void disableBluetooth() { _interfaceManager->disableBluetooth(); }
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 newMsg(uint8_t path_len, const char* from_name,
const char* text, int msgcount,
int channel_idx = -1,
const char* channel_name = nullptr) = 0;
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual void loop() = 0;
};
+53 -9
View File
@@ -847,9 +847,26 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
}
bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
return _prefs.isRepeatEn();
if (!_prefs.isRepeatEn()) return false;
#ifdef COMPANION_MESH_CLOCK_SYNC
_clock_sync.observeAcceptedFlood(packet);
#endif
return true;
}
#ifdef COMPANION_MESH_CLOCK_SYNC
void MyMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
uint32_t timestamp, const uint8_t* app_data,
size_t app_data_len) {
BaseChatMesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len);
_clock_sync.observeVerifiedAdvert(packet, id, timestamp);
}
void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
_clock_sync.observeGroupPacket(packet);
}
#endif
bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const {
if (packet == NULL) return false;
// A companion may retry its own advert once, using the core's deliberately
@@ -982,7 +999,13 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
if (getChannel(channel_idx, channel_details)) {
channel_name = channel_details.name;
}
if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len);
char channel_label[64];
snprintf(channel_label, sizeof(channel_label), "Ch %u %s",
(unsigned int)channel_idx, channel_name);
if (_ui) {
_ui->newMsg(path_len, channel_label, text, offline_queue_len,
channel_idx, channel_name);
}
#endif
if (pkt->isRouteFlood() && is_emergency_channel) {
@@ -1357,6 +1380,10 @@ void MyMesh::onSendTimeout() {
MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui)
: BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables),
#ifdef COMPANION_MESH_CLOCK_SYNC
_clock_sync(radio, _clock_sync_millis, rtc, _clock_sync_acl, sensors,
_prefs.airtime_factor),
#endif
_serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) {
_iter_started = false;
_cli_rescue = false;
@@ -1606,6 +1633,12 @@ void MyMesh::begin(bool has_display, bool radio_available) {
_store->saveChannels(this);
}
#ifdef COMPANION_MESH_CLOCK_SYNC
// Fixed fallback policy for this Companion build. A successful host time
// update below suppresses mesh correction for the remainder of the boot.
_clock_sync.begin(nullptr);
#endif
configureRadioFromPrefs();
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
@@ -1808,9 +1841,10 @@ void MyMesh::scheduleNormalRadio(char* reply, size_t reply_size) {
_temp_radio_failures = 0;
snprintf(reply, reply_size, "OK - normal radio restore scheduled");
}
#endif
bool MyMesh::handleFullOtaCommand(const char* command, char* reply,
size_t reply_size) {
bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
size_t reply_size) {
if (!command || !reply || reply_size == 0) return false;
while (*command == ' ') command++;
@@ -1971,6 +2005,7 @@ bool MyMesh::handleFullOtaCommand(const char* command, char* reply,
}
#endif
#if defined(COMPANION_RADIO_FULL)
if (strcmp(command, "tempradio") == 0) {
if (_temp_radio_set_at) {
snprintf(reply, reply_size, "TempRadio pending: %.3f,%.2f,%u,%u",
@@ -2021,10 +2056,12 @@ bool MyMesh::handleFullOtaCommand(const char* command, char* reply,
snprintf(reply, reply_size, "%s", ota_reply);
return true;
}
#endif
return false;
}
#if defined(COMPANION_RADIO_FULL)
void MyMesh::serviceTempRadio() {
const unsigned long now = _ms->getMillis();
const bool retry_ready = !_temp_radio_retry_at
@@ -2964,6 +3001,9 @@ void MyMesh::handleCmdFrame(size_t len) {
uint32_t curr = getRTCClock()->getCurrentTime();
if (secs >= curr) {
getRTCClock()->setCurrentTime(secs);
#ifdef COMPANION_MESH_CLOCK_SYNC
_clock_sync.onManualClockSet();
#endif
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
@@ -4753,13 +4793,11 @@ void MyMesh::handleTerminalCommand(char* command) {
if (*command == 0) return;
mesh::cli::normalizeCommandVerb(command);
#if defined(COMPANION_RADIO_FULL)
char full_reply[160];
if (handleFullOtaCommand(command, full_reply, sizeof(full_reply))) {
terminalOutput().printf(" %s\r\n", full_reply);
char local_reply[160];
if (handleLocalControlCommand(command, local_reply, sizeof(local_reply))) {
terminalOutput().printf(" %s\r\n", local_reply);
return;
}
#endif
mesh::cli::TerminalChannelMessage channel_message;
const mesh::cli::TerminalChannelCommandMatch channel_match =
@@ -4869,6 +4907,9 @@ void MyMesh::handleTerminalCommand(char* command) {
uint32_t current = getRTCClock()->getCurrentTime();
if (timestamp >= current) {
getRTCClock()->setCurrentTime(timestamp);
#ifdef COMPANION_MESH_CLOCK_SYNC
_clock_sync.onManualClockSet();
#endif
terminalOutput().print(" OK - clock set\r\n");
} else {
terminalOutput().print(" ERROR: clock cannot go backwards\r\n");
@@ -5518,6 +5559,9 @@ void MyMesh::loop() {
serviceTempRadio();
#endif
BaseChatMesh::loop();
#ifdef COMPANION_MESH_CLOCK_SYNC
_clock_sync.loop();
#endif
#ifdef ENABLE_USB_INTERFACE
serviceTerminalLogin();
serviceTerminalCommand();
+20 -5
View File
@@ -47,6 +47,11 @@
#include <helpers/TerminalDisplayFilter.h>
#include <target.h>
#ifdef COMPANION_MESH_CLOCK_SYNC
#include <helpers/ClientACL.h>
#include <helpers/MeshClockSync.h>
#endif
/* ---------------------------------- CONFIGURATION ------------------------------------- */
#ifndef LORA_FREQ
@@ -171,11 +176,10 @@ public:
#endif
#endif
#if defined(COMPANION_RADIO_FULL)
// Local-only control surface shared by the USB terminal and WiFi port 5002.
// Supports bounded TempRadio windows plus the serve-only `ota ...` CLI.
bool handleFullOtaCommand(const char* command, char* reply, size_t reply_size);
#endif
// Local control shared by every USB terminal. ESP32 WiFi companions expose
// setup/status commands here; Full builds add TempRadio and OTA commands.
bool handleLocalControlCommand(const char* command, char* reply,
size_t reply_size);
int getRecentlyHeard(AdvertPath dest[], int max_num);
@@ -200,6 +204,12 @@ protected:
bool filterRecvFloodPacket(mesh::Packet* packet) override;
bool allowPacketForward(const mesh::Packet* packet) override;
bool allowFloodRetry(const mesh::Packet* packet) const override;
#ifdef COMPANION_MESH_CLOCK_SYNC
void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
uint32_t timestamp, const uint8_t* app_data,
size_t app_data_len) override;
void onGroupPacketRecv(mesh::Packet* packet) override;
#endif
bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
bool sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
@@ -362,6 +372,11 @@ private:
DataStore* _store;
CompanionNodePrefs _prefs;
#ifdef COMPANION_MESH_CLOCK_SYNC
ArduinoMillis _clock_sync_millis;
ClientACL _clock_sync_acl;
mesh::MeshClockSync _clock_sync;
#endif
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
MQTTPrefs _mqtt_prefs;
MQTTBridge* _mqtt_bridge;
+2 -2
View File
@@ -370,7 +370,7 @@ static void resetUsbMotaMode() {
static void leaveUsbMotaMode(bool acknowledge) {
char reply[160] = {0};
the_mesh.handleFullOtaCommand("ota folder off", reply, sizeof(reply));
the_mesh.handleLocalControlCommand("ota folder off", reply, sizeof(reply));
if (acknowledge) {
Serial.print("\r\n");
Serial.print(reply);
@@ -388,7 +388,7 @@ static void enterUsbMotaMode() {
usb_mota_disconnect_armed = isUsbTerminalDataConnected();
char reply[160] = {0};
if (!the_mesh.handleFullOtaCommand("ota folder on", reply, sizeof(reply))
if (!the_mesh.handleLocalControlCommand("ota folder on", reply, sizeof(reply))
|| strncmp(reply, "ERR", 3) == 0) {
Serial.print("\r\n");
Serial.print(reply[0] ? reply : "ERR could not enter mOTA seeder mode");
+278 -44
View File
@@ -18,6 +18,9 @@
#ifndef USB_MESSAGE_PREVIEW_MILLIS
#define USB_MESSAGE_PREVIEW_MILLIS 15000UL
#endif
#ifndef UI_RADIO_REFRESH_MILLIS
#define UI_RADIO_REFRESH_MILLIS 2000UL
#endif
#ifndef BLE_PAIRING_DISPLAY_MILLIS
#define BLE_PAIRING_DISPLAY_MILLIS 120000UL
#endif
@@ -126,7 +129,7 @@ class HomeScreen : public UIScreen {
AdvertPath recent[UI_RECENT_LIST_SIZE];
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
// Convert millivolts to percentage
#ifndef BATT_MIN_MILLIVOLTS
#define BATT_MIN_MILLIVOLTS 3000
@@ -136,14 +139,19 @@ class HomeScreen : public UIScreen {
#endif
const int minMilliVolts = BATT_MIN_MILLIVOLTS;
const int maxMilliVolts = BATT_MAX_MILLIVOLTS;
int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
const bool showBattery = batteryMilliVolts != 0;
int batteryPercentage = showBattery
? ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts)
: 0;
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 iconX = showBattery
? display.width() - iconWidth - 5
: display.width() - 5;
int iconY = 0;
display.setColor(UIColor::title_txt);
@@ -178,25 +186,28 @@ class HomeScreen : public UIScreen {
#ifdef PIN_BUZZER
if (muted) statusWidth += 9;
#endif
display.setCursor(iconX - statusWidth - uptimeWidth - spaceWidth, iconY);
int statusStartX = iconX - statusWidth - uptimeWidth - spaceWidth;
display.setCursor(statusStartX, iconY);
display.print(uptime);
// battery outline
display.drawRect(iconX, iconY, iconWidth, iconHeight);
if (showBattery) {
// battery outline
display.drawRect(iconX, iconY, iconWidth, iconHeight);
// battery "cap"
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2);
// 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);
// fill the battery based on the percentage
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
}
// Show external power beside the battery. Most boards have no
// charge-complete signal, so use a high percentage band for the plug.
if (charging) {
static constexpr int BATT_FULL_PCT = 95;
const uint8_t* symbol =
batteryPercentage >= BATT_FULL_PCT ? plug_icon : charging_icon;
!showBattery || batteryPercentage >= BATT_FULL_PCT ? plug_icon : charging_icon;
display.setColor(UIColor::title_txt);
display.drawXbm(iconX - 9, iconY + 1, symbol, 8, 8);
}
@@ -208,6 +219,8 @@ class HomeScreen : public UIScreen {
display.drawXbm(iconX - (charging ? 18 : 9), iconY + 1, muted_icon, 8, 8);
}
#endif
return statusStartX;
}
CayenneLPP sensors_lpp;
@@ -255,16 +268,18 @@ public:
display.setColor(UIColor::title_bkg);
display.fillRect(0, 0, display.width(), 12);
char tmp[80];
// node name
// status indicators
display.setTextSize(1);
display.setColor(UIColor::title_txt);
int statusStartX = renderBatteryIndicator(display, _task->getBattMilliVolts());
// node name
char filtered_name[sizeof(_node_prefs->node_name)];
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
display.setCursor(0, 2);
display.print(filtered_name);
// battery voltage
renderBatteryIndicator(display, _task->getBattMilliVolts());
int availableNameWidth = statusStartX - 2;
if (availableNameWidth > 0) {
display.drawTextEllipsized(0, 2, availableNameWidth, filtered_name);
}
// curr page indicator
if (UIColor::title_bkg == UIColor::window_bkg) {
@@ -285,8 +300,12 @@ public:
if (_page == HomePage::FIRST) {
display.setColor(UIColor::primary_txt);
display.setTextSize(2);
sprintf(tmp, "MSG: %d", _task->getMsgCount());
sprintf(tmp, "INBOX: %d", _task->getPreviewCount());
display.drawTextCentered(display.width() / 2, 22, tmp);
display.setTextSize(1);
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 43,
"tap center: inbox");
#ifdef UI_SHOW_CLOCK
display.setTextSize(3);
@@ -371,7 +390,12 @@ public:
sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm);
display.print(tmp);
display.setCursor(0, 53);
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
float noise_floor = radio_driver.getNoiseFloorDbm();
if (noise_floor == 0.0f) {
strcpy(tmp, "Noise floor: measuring");
} else {
snprintf(tmp, sizeof(tmp), "Noise floor: %.1f", noise_floor);
}
display.print(tmp);
} else if (_page == HomePage::BLUETOOTH) {
display.setColor(UIColor::corp_blue);
@@ -522,7 +546,7 @@ public:
}
#endif
}
return 5000; // next render after 5000 ms
return _page == HomePage::RADIO ? UI_RADIO_REFRESH_MILLIS : 5000;
}
bool handleInput(char c) override {
@@ -545,6 +569,10 @@ public:
}
return true;
}
if (c == KEY_ENTER && _page == HomePage::FIRST) {
_task->showMessages();
return true;
}
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
_task->notify(UIEventType::ack);
if (the_mesh.advert()) {
@@ -585,42 +613,184 @@ class MsgPreviewScreen : public UIScreen {
UITask* _task;
mesh::RTCClock* _rtc;
static constexpr int CHANNEL_FILTER_ALL = -2;
static constexpr int CHANNEL_FILTER_DIRECT = -1;
struct MsgEntry {
uint32_t timestamp;
int channel_idx;
char channel_name[32];
char origin[62];
char msg[UI_MSG_PREVIEW_SIZE];
};
#define MAX_UNREAD_MSGS 32
int num_unread;
int view_offset;
int channel_filter;
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; }
bool matchesFilter(const MsgEntry& entry) const {
return channel_filter == CHANNEL_FILTER_ALL
|| entry.channel_idx == channel_filter;
}
void addPreview(uint8_t path_len, const char* from_name, const char* msg) {
int filteredCount() const {
int count = 0;
for (int age = 0; age < num_unread; ++age) {
int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS;
if (matchesFilter(unread[index])) ++count;
}
return count;
}
const MsgEntry* filteredEntry(int offset) const {
int match = 0;
for (int age = 0; age < num_unread; ++age) {
int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS;
if (!matchesFilter(unread[index])) continue;
if (match++ == offset) return &unread[index];
}
return nullptr;
}
int buildChannelFilters(int* filters, int capacity) const {
int count = 0;
if (count < capacity) filters[count++] = CHANNEL_FILTER_ALL;
for (int channel_idx = 0;
channel_idx < MAX_GROUP_CHANNELS && count < capacity;
++channel_idx) {
ChannelDetails details;
if (the_mesh.getChannel(channel_idx, details)
&& details.name[0] != 0) {
filters[count++] = channel_idx;
}
}
if (count < capacity) filters[count++] = CHANNEL_FILTER_DIRECT;
return count;
}
void cycleChannelFilter(int direction) {
int filters[MAX_GROUP_CHANNELS + 2];
int count = buildChannelFilters(
filters, sizeof(filters) / sizeof(filters[0]));
if (count == 0) return;
int selected = 0;
while (selected < count && filters[selected] != channel_filter) {
++selected;
}
if (selected == count) selected = 0;
selected = (selected + direction + count) % count;
channel_filter = filters[selected];
view_offset = 0;
}
void channelFilterLabel(char* label, size_t size) const {
if (channel_filter == CHANNEL_FILTER_ALL) {
StrHelper::strncpy(label, "All channels", size);
return;
}
if (channel_filter == CHANNEL_FILTER_DIRECT) {
StrHelper::strncpy(label, "Direct", size);
return;
}
ChannelDetails details;
if (the_mesh.getChannel(channel_filter, details)
&& details.name[0] != 0) {
snprintf(label, size, "Ch %d %s", channel_filter, details.name);
return;
}
for (int age = 0; age < num_unread; ++age) {
int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS;
if (unread[index].channel_idx == channel_filter
&& unread[index].channel_name[0] != 0) {
snprintf(label, size, "Ch %d %s", channel_filter,
unread[index].channel_name);
return;
}
}
snprintf(label, size, "Ch %d", channel_filter);
}
void renderChannelFilter(DisplayDriver& display) const {
const int bar_height = 24;
const int bar_y = display.height() - bar_height;
display.setColor(UIColor::window_bkg);
display.fillRect(0, bar_y, display.width(), bar_height);
display.setColor(UIColor::corp_blue);
display.drawRect(0, bar_y, display.width(), 1);
char channel[48];
channelFilterLabel(channel, sizeof(channel));
display.setTextSize(1);
const int text_y = bar_y + 8;
display.setCursor(8, text_y);
display.print("<");
display.drawTextCentered(display.width() / 2, text_y, channel);
display.setCursor(display.width() - display.getTextWidth(">") - 8,
text_y);
display.print(">");
}
public:
MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), num_unread(0), view_offset(0),
channel_filter(CHANNEL_FILTER_ALL) {}
bool hasMessages() const { return num_unread > 0; }
int messageCount() const { return num_unread; }
void clear() {
num_unread = 0;
view_offset = 0;
channel_filter = CHANNEL_FILTER_ALL;
}
void addPreview(uint8_t path_len, const char* from_name, const char* msg,
int channel_idx, const char* channel_name) {
head = (head + 1) % MAX_UNREAD_MSGS;
if (num_unread < MAX_UNREAD_MSGS) num_unread++;
view_offset = 0;
channel_filter = channel_idx;
auto p = &unread[head];
p->timestamp = _rtc->getCurrentTime();
p->channel_idx = channel_idx;
StrHelper::strncpy(p->channel_name,
channel_name == nullptr ? "" : channel_name,
sizeof(p->channel_name));
if (path_len == 0xFF) {
sprintf(p->origin, "(D) %s:", from_name);
snprintf(p->origin, sizeof(p->origin), "%s [direct]:", from_name);
} else {
sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name);
snprintf(p->origin, sizeof(p->origin), "%s [%uh]:", from_name,
(unsigned int)path_len);
}
StrHelper::strncpy(p->msg, msg, sizeof(p->msg));
}
int render(DisplayDriver& display) override {
char tmp[16];
char tmp[24];
int filtered_count = filteredCount();
if (view_offset >= filtered_count) view_offset = 0;
display.setCursor(0, 0);
display.setTextSize(1);
display.setColor(UIColor::corp_blue);
sprintf(tmp, "Unread: %d", num_unread);
snprintf(tmp, sizeof(tmp), "Message %d/%d",
filtered_count == 0 ? 0 : view_offset + 1, filtered_count);
display.print(tmp);
auto p = &unread[head];
const MsgEntry* p = filteredEntry(view_offset);
if (p == nullptr) {
display.drawRect(0, 11, display.width(), 1);
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 40,
"No buffered messages");
renderChannelFilter(display);
return 5000;
}
int secs = _rtc->getCurrentTime() - p->timestamp;
if (secs < 60) {
@@ -647,6 +817,8 @@ public:
display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg));
display.printWordWrap(filtered_msg, display.width());
renderChannelFilter(display);
#if AUTO_OFF_MILLIS==0 // probably e-ink
return 10000; // 10 s
#else
@@ -656,15 +828,22 @@ public:
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();
}
if (view_offset + 1 < filteredCount()) ++view_offset;
return true;
}
if (c == KEY_PREV || c == KEY_LEFT) {
if (view_offset > 0) --view_offset;
return true;
}
if (c == KEY_DOWN) {
cycleChannelFilter(1);
return true;
}
if (c == KEY_UP) {
cycleChannelFilter(-1);
return true;
}
if (c == KEY_ENTER) {
num_unread = 0; // clear unread queue
_task->gotoHomeScreen();
return true;
}
@@ -727,6 +906,14 @@ void UITask::showAlert(const char* text, int duration_millis) {
_alert_expiry = millis() + duration_millis;
}
void UITask::showMessages() {
setCurrScreen(msg_preview);
}
int UITask::getPreviewCount() const {
return static_cast<const MsgPreviewScreen*>(msg_preview)->messageCount();
}
void UITask::notify(UIEventType t) {
#if defined(PIN_BUZZER)
switch(t){
@@ -763,14 +950,20 @@ void UITask::msgRead(int msgcount) {
_deferred_msg_preview = false;
const bool holding_usb_preview = curr == msg_preview && _msg_preview_until != 0
&& static_cast<int32_t>(millis() - _msg_preview_until) < 0;
if (!holding_usb_preview) gotoHomeScreen();
if (!holding_usb_preview) {
static_cast<MsgPreviewScreen*>(msg_preview)->clear();
gotoHomeScreen();
}
}
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx,
const char* channel_name) {
_msgcount = msgcount;
((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text);
((MsgPreviewScreen *)msg_preview)
->addPreview(path_len, from_name, text, channel_idx, channel_name);
if (isPairingScreenActive()) {
// Keep the PIN visible, but retain the preview so it can be shown after
// pairing completes or the pairing display window expires.
@@ -781,7 +974,8 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
// A connected app drains the offline queue almost immediately, which calls
// msgRead(0). While attached to a computer, retain the actual message screen
// for 15 seconds even though the app has already consumed the message.
// for the configured preview interval even though the app has already
// consumed the message.
_msg_preview_until = _board->isUsbHostConnected()
? millis() + USB_MESSAGE_PREVIEW_MILLIS
: 0;
@@ -955,11 +1149,17 @@ void UITask::loop() {
if (ev == BUTTON_EVENT_CLICK) {
c = checkDisplayOn(KEY_NEXT);
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
c = handleLongPress(KEY_ENTER);
c = (_display != NULL && !_display->isOn())
? checkDisplayOn(KEY_ENTER)
: handleLongPress(KEY_ENTER);
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
c = handleDoubleClick(KEY_PREV);
c = (_display != NULL && !_display->isOn())
? checkDisplayOn(KEY_ENTER)
: handleDoubleClick(KEY_PREV);
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
c = handleTripleClick(KEY_SELECT);
c = (_display != NULL && !_display->isOn())
? checkDisplayOn(KEY_ENTER)
: handleTripleClick(KEY_SELECT);
}
#endif
#endif
@@ -988,6 +1188,39 @@ void UITask::loop() {
_analogue_pin_read_millis = millis();
}
#endif
#ifdef HAS_TOUCH
if (_display != NULL
&& (int32_t)(millis() - next_touch_check) >= 0) {
next_touch_check = millis() + 25;
int touch_x = -1;
int touch_y = -1;
const bool touched = _display->getTouch(&touch_x, &touch_y);
const mesh::ui::TouchAction action = touch_input.update(
touched, touch_x, touch_y, _display->width(), _display->height(),
curr == msg_preview);
if (c == 0) {
switch (action) {
case mesh::ui::TouchAction::Previous:
c = checkDisplayOn(KEY_PREV);
break;
case mesh::ui::TouchAction::Next:
c = checkDisplayOn(KEY_NEXT);
break;
case mesh::ui::TouchAction::Select:
c = checkDisplayOn(KEY_ENTER);
break;
case mesh::ui::TouchAction::VerticalPrevious:
c = checkDisplayOn(KEY_UP);
break;
case mesh::ui::TouchAction::VerticalNext:
c = checkDisplayOn(KEY_DOWN);
break;
case mesh::ui::TouchAction::None:
break;
}
}
}
#endif
#if defined(BACKLIGHT_BTN)
if (millis() > next_backlight_btn_check) {
bool touch_state = digitalRead(PIN_BUTTON2);
@@ -1021,10 +1254,11 @@ void UITask::loop() {
if (curr) curr->poll();
if (curr == msg_preview && _msgcount == 0 && _msg_preview_until != 0
if (_msgcount == 0 && _msg_preview_until != 0
&& static_cast<int32_t>(millis() - _msg_preview_until) >= 0) {
_msg_preview_until = 0;
gotoHomeScreen();
static_cast<MsgPreviewScreen*>(msg_preview)->clear();
if (curr == msg_preview) gotoHomeScreen();
}
if (_display != NULL && _display->isOn()) {
+24 -1
View File
@@ -7,6 +7,9 @@
#include <helpers/MultiSerialInterface.h>
#include <Arduino.h>
#include <helpers/sensors/LPPDataHelpers.h>
#ifdef HAS_TOUCH
#include <helpers/ui/TouchInput.h>
#endif
#ifndef LED_STATE_ON
#define LED_STATE_ON 1
@@ -41,6 +44,22 @@ class UITask : public AbstractUITask {
int _msgcount;
unsigned long ui_started_at, next_batt_chck;
int next_backlight_btn_check = 0;
#ifdef HAS_TOUCH
#ifndef TOUCH_CENTER_ZONE_PERCENT
#define TOUCH_CENTER_ZONE_PERCENT 34
#endif
#if defined(TOUCH_REVERSE_SWIPE) \
&& defined(TOUCH_SEPARATE_VERTICAL_SWIPES)
mesh::ui::TouchInput touch_input{true, true, TOUCH_CENTER_ZONE_PERCENT};
#elif defined(TOUCH_REVERSE_SWIPE)
mesh::ui::TouchInput touch_input{true, false, TOUCH_CENTER_ZONE_PERCENT};
#elif defined(TOUCH_SEPARATE_VERTICAL_SWIPES)
mesh::ui::TouchInput touch_input{false, true, TOUCH_CENTER_ZONE_PERCENT};
#else
mesh::ui::TouchInput touch_input{false, false, TOUCH_CENTER_ZONE_PERCENT};
#endif
unsigned long next_touch_check = 0;
#endif
#ifdef PIN_STATUS_LED
int led_state = 0;
int next_led_change = 0;
@@ -83,8 +102,10 @@ public:
void serviceWiFiToggleButton();
void gotoHomeScreen() { setCurrScreen(home); }
void showMessages();
void showAlert(const char* text, int duration_millis);
int getMsgCount() const { return _msgcount; }
int getPreviewCount() const;
bool hasDisplay() const { return _display != NULL; }
bool isButtonPressed() const;
@@ -103,7 +124,9 @@ public:
// from AbstractUITask
void msgRead(int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx = -1,
const char* channel_name = nullptr) override;
void notify(UIEventType t = UIEventType::none) override;
void loop() override;
+5 -1
View File
@@ -156,7 +156,11 @@ void UITask::clearMsgPreview() {
_need_refresh = true;
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx,
const char* channel_name) {
(void)channel_idx;
(void)channel_name;
_msgcount = msgcount;
#ifdef HAS_DRV2605
+3 -1
View File
@@ -78,7 +78,9 @@ public:
// from AbstractUITask
void msgRead(int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx = -1,
const char* channel_name = nullptr) override;
void notify(UIEventType t = UIEventType::none) override;
void loop() override;
+5 -1
View File
@@ -515,7 +515,11 @@ void UITask::msgRead(int msgcount) {
}
}
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx,
const char* channel_name) {
(void)channel_idx;
(void)channel_name;
_msgcount = msgcount;
if (_display != NULL) {
+3 -1
View File
@@ -106,7 +106,9 @@ public:
// from AbstractUITask
void msgRead(int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
void newMsg(uint8_t path_len, const char* from_name, const char* text,
int msgcount, int channel_idx = -1,
const char* channel_name = nullptr) override;
void notify(UIEventType t = UIEventType::none) override;
void loop() override;
+3
View File
@@ -94,6 +94,9 @@ public:
}
virtual int getNoiseFloor() const { return 0; }
virtual float getNoiseFloorDbm() const {
return static_cast<float>(getNoiseFloor());
}
virtual void triggerNoiseFloorCalibrate(int threshold) { }
+16 -2
View File
@@ -19,14 +19,22 @@
#ifndef FIRMWARE_BUILD_EPOCH
#define FIRMWARE_BUILD_EPOCH 0UL
#endif
#ifndef MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT
#define MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT 9
#endif
#ifndef MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS
#define MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS (30ULL * 60ULL * 1000ULL)
#endif
namespace {
constexpr char PREFS_FILE[] = "/clock_sync";
constexpr uint8_t REQUIRED_SAMPLES_MIN = 3;
constexpr uint8_t REQUIRED_SAMPLES_MAX = 16;
constexpr uint8_t REQUIRED_SAMPLES_DEFAULT = 9;
constexpr uint64_t STARTUP_DELAY_MILLIS = 30ULL * 60ULL * 1000ULL;
constexpr uint8_t REQUIRED_SAMPLES_DEFAULT =
MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT;
constexpr uint64_t STARTUP_DELAY_MILLIS =
MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS;
constexpr uint64_t RETRY_INTERVAL_MILLIS = 30ULL * 60ULL * 1000ULL;
constexpr uint64_t RESYNC_INTERVAL_MILLIS = 7ULL * 24ULL * 60ULL * 60ULL * 1000ULL;
constexpr uint32_t SAMPLE_MAX_AGE_MILLIS = 2UL * 60UL * 60UL * 1000UL;
@@ -35,6 +43,12 @@ constexpr uint32_t DRIFT_MIN_SECONDS = 30UL;
constexpr uint32_t DRIFT_MAX_SECONDS = 86400UL;
constexpr uint16_t VALID_YEARS = 10;
static_assert(REQUIRED_SAMPLES_DEFAULT >= REQUIRED_SAMPLES_MIN
&& REQUIRED_SAMPLES_DEFAULT <= REQUIRED_SAMPLES_MAX,
"mesh clock-sync sample default is outside the supported range");
static_assert(STARTUP_DELAY_MILLIS > 0,
"mesh clock-sync startup delay must be positive");
// Public-channel AES key, zero-padded to the shared-secret buffer width used
// by Utils::MACThenDecrypt().
const uint8_t PUBLIC_CHANNEL_SECRET[PUB_KEY_SIZE] = {
+10 -1
View File
@@ -5,6 +5,7 @@
#include <WiFi.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <esp_wifi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <strings.h>
@@ -296,7 +297,15 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi
WiFi.mode(WIFI_AP_STA);
if (!WiFi.softAPConfig(SETUP_IP, SETUP_IP, SETUP_MASK)
|| !WiFi.softAP(impl->ap_name, nullptr)) {
|| !WiFi.softAP(impl->ap_name, nullptr)
|| esp_wifi_set_protocol(
WIFI_IF_AP,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N)
!= ESP_OK
|| esp_wifi_set_protocol(
WIFI_IF_STA,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N)
!= ESP_OK) {
WiFi.softAPdisconnect(true);
return false;
}
+4
View File
@@ -42,6 +42,10 @@ static void OnDataRecv(const uint8_t *mac, const uint8_t *data, int len) {
void ESPNOWRadio::init() {
// Set device as a Wi-Fi Station
// ESP-NOW's LR protocol is local to this radio transport. Keeping WiFi
// driver writes in RAM prevents a later conventional WiFi image from
// inheriting the proprietary protocol bit through NVS.
WiFi.persistent(false);
WiFi.mode(WIFI_STA);
// Long Range mode
esp_wifi_set_protocol(WIFI_IF_STA, WIFI_PROTOCOL_LR);
+23 -1
View File
@@ -603,7 +603,22 @@ bool WebConfigServer::startSetupMode(char reply[]) {
#else
ap_ok = WiFi.softAP(_ap_ssid);
#endif
if (ap_ok) break;
if (ap_ok) {
// ESP-NOW can leave the persistent AP protocol mask with the proprietary
// LR bit set. The driver then reports a healthy SoftAP, but ordinary
// phones and laptops cannot discover its beacon. A WiFi companion must
// advertise using the interoperable 2.4 GHz protocol set.
const esp_err_t ap_protocol_result = esp_wifi_set_protocol(
WIFI_IF_AP,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N);
const esp_err_t sta_protocol_result = esp_wifi_set_protocol(
WIFI_IF_STA,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N);
if (ap_protocol_result == ESP_OK && sta_protocol_result == ESP_OK) break;
Serial.printf("WebConfig protocol reset failed: AP=%d STA=%d\n",
(int)ap_protocol_result, (int)sta_protocol_result);
ap_ok = false;
}
Serial.printf(
"WebConfig AP attempt %u failed: mode_ok=%d disconnect_ok=%d mode=%d heap=%u largest=%u\n",
@@ -677,6 +692,13 @@ bool WebConfigServer::startAutoMode(char reply[]) {
}
WiFi.mode(WIFI_STA);
if (esp_wifi_set_protocol(
WIFI_IF_STA,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N)
!= ESP_OK) {
strcpy(reply, "Err: failed to reset WiFi station protocol");
return false;
}
WiFi.setAutoReconnect(true);
_retry_saved_wifi_in_setup = false;
_setup_reconnect_in_progress = false;
+19 -1
View File
@@ -2,7 +2,7 @@
#include <stdint.h>
// AUTO-GENERATED by tools/mota/gen_targets.py - do not edit by hand.
// 577 OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32)
// 594 OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32)
// to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT
// transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes.
// Size-constrained receivers can set OTA_TARGET_NAME_TABLE=0. They still match targets by ID;
@@ -68,6 +68,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xb12e6e01, "Heltec_E290_room_server" },
{ 0xf6f835ec, "Heltec_mesh_solar_repeater" },
{ 0x7fab017f, "heltec_rc32_companion_radio_ble" },
{ 0x9e23928a, "heltec_rc32_companion_radio_full" },
{ 0xf310a4d1, "heltec_rc32_companion_radio_usb" },
{ 0xdd5953ec, "heltec_rc32_companion_radio_wifi" },
{ 0x97574bc2, "heltec_rc32_kiss_modem" },
@@ -77,6 +78,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x57f69746, "heltec_rc32_sensor" },
{ 0xbc05d6a7, "heltec_rc32_terminal_chat" },
{ 0x5bad80f1, "heltec_rc32_without_display_companion_radio_ble" },
{ 0xb4f727a8, "heltec_rc32_without_display_companion_radio_full" },
{ 0x112d34c4, "heltec_rc32_without_display_companion_radio_usb" },
{ 0xf10a26df, "heltec_rc32_without_display_companion_radio_wifi" },
{ 0xc5409ced, "heltec_rc32_without_display_repeater" },
@@ -120,6 +122,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xa3e8d8ce, "heltec_tracker_v1_1_room_server_observer_mqtt" },
{ 0xb16009a0, "heltec_tracker_v2_companion_radio_ble_femoff" },
{ 0xdc780e80, "heltec_tracker_v2_companion_radio_ble_femon" },
{ 0x28b8007c, "heltec_tracker_v2_companion_radio_full_femon" },
{ 0x4aa63180, "heltec_tracker_v2_companion_radio_usb_femoff" },
{ 0x6fddcbf1, "heltec_tracker_v2_companion_radio_usb_femon" },
{ 0xf4fb5484, "heltec_tracker_v2_companion_radio_wifi_femoff" },
@@ -155,13 +158,16 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xceeddfaf, "Heltec_v3_room_server_observer_mqtt" },
{ 0x21519537, "Heltec_v3_sensor" },
{ 0x382bb181, "Heltec_v3_terminal_chat" },
{ 0xad1bcd63, "heltec_v4_2_v4_3_companion_radio_full_femon" },
{ 0x5a2980e6, "heltec_v4_3_companion_radio_ble_femoff" },
{ 0x90581c83, "heltec_v4_3_companion_radio_ble_ps_femoff" },
{ 0x90a6a854, "heltec_v4_3_companion_radio_full_femoff" },
{ 0x356df17c, "heltec_v4_3_companion_radio_usb_femoff" },
{ 0x99a084fc, "heltec_v4_3_companion_radio_wifi_femoff" },
{ 0x64c9dfc9, "heltec_v4_3_companion_radio_wifi_mqtt_femoff" },
{ 0x2a2ffc92, "heltec_v4_3_expansionkit_tft_companion_radio_ble_femoff" },
{ 0x2c13b8e9, "heltec_v4_3_tft_companion_radio_ble_femoff" },
{ 0x1ee7d867, "heltec_v4_3_tft_companion_radio_full_femoff" },
{ 0xb016979a, "heltec_v4_3_tft_companion_radio_usb_femoff" },
{ 0xb49b6b55, "heltec_v4_3_tft_companion_radio_wifi_femoff" },
{ 0x01d8f124, "heltec_v4_companion_radio_ble" },
@@ -181,6 +187,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xf75feb3e, "heltec_v4_kiss_modem" },
{ 0xbc9cdc70, "heltec_v4_r8_companion_radio_ble" },
{ 0x19e31fc6, "heltec_v4_r8_companion_radio_ble_ps" },
{ 0xa01b330e, "heltec_v4_r8_companion_radio_full" },
{ 0x36c78d86, "heltec_v4_r8_companion_radio_usb" },
{ 0xdf722326, "heltec_v4_r8_companion_radio_wifi" },
{ 0xfa901c87, "heltec_v4_r8_kiss_modem" },
@@ -189,6 +196,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xb4cb035a, "heltec_v4_r8_sensor" },
{ 0xbb429dde, "heltec_v4_r8_terminal_chat" },
{ 0x883e3edc, "heltec_v4_r8_tft_companion_radio_ble" },
{ 0x4a9d1c6c, "heltec_v4_r8_tft_companion_radio_full" },
{ 0xa0965a6b, "heltec_v4_r8_tft_companion_radio_usb" },
{ 0x59ea25fa, "heltec_v4_r8_tft_companion_radio_wifi" },
{ 0x1758bdb9, "heltec_v4_r8_tft_kiss_modem" },
@@ -204,6 +212,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x0b6847ed, "heltec_v4_sensor" },
{ 0xab3e1ad3, "heltec_v4_terminal_chat" },
{ 0x04a8b763, "heltec_v4_tft_companion_radio_ble_femon" },
{ 0xc456437e, "heltec_v4_tft_companion_radio_full_femon" },
{ 0xb3ecafca, "heltec_v4_tft_companion_radio_usb_femon" },
{ 0xf3afcde6, "heltec_v4_tft_companion_radio_wifi_femon" },
{ 0x87513d56, "heltec_v4_tft_repeater" },
@@ -311,6 +320,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x07907102, "LilyGo_T_Impulse_Plus_repeater" },
{ 0x3898ea56, "LilyGo_TBeam_1W_companion_radio_ble" },
{ 0x2fb7e218, "LilyGo_TBeam_1W_companion_radio_ble_ps" },
{ 0x8c61d4c8, "LilyGo_TBeam_1W_companion_radio_full" },
{ 0x4f722e70, "LilyGo_TBeam_1W_companion_radio_usb" },
{ 0x694eed25, "LilyGo_TBeam_1W_companion_radio_wifi" },
{ 0x85b8b9d7, "LilyGo_TBeam_1W_kiss_modem" },
@@ -364,6 +374,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xd45277ff, "Meshimi_companion_radio_ble_" },
{ 0x2cad9a9d, "Meshimi_repeater_" },
{ 0x02a4a8c1, "meshnology_w12_companion_radio_ble" },
{ 0x4a709348, "meshnology_w12_companion_radio_full" },
{ 0x35d960bd, "meshnology_w12_companion_radio_usb" },
{ 0x62977023, "meshnology_w12_companion_radio_wifi" },
{ 0x17a7ed8c, "meshnology_w12_kiss_modem" },
@@ -376,6 +387,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x11b131e7, "Minewsemi_me25ls01_repeater_lora_ota_no_external_sensors" },
{ 0xf8259880, "Nano_G2_Ultra_repeater" },
{ 0x063f1f00, "nibble_screen_connect_companion_radio_ble_" },
{ 0x70d71f92, "nibble_screen_connect_companion_radio_full_" },
{ 0xa8a79da5, "nibble_screen_connect_companion_radio_usb_" },
{ 0xc0d3815b, "nibble_screen_connect_companion_radio_wifi_" },
{ 0x966ac88e, "nibble_screen_connect_kiss_modem_" },
@@ -384,6 +396,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xc42cc90d, "nibble_screen_connect_room_server_" },
{ 0xc24791c3, "nibble_screen_connect_terminal_chat_" },
{ 0x15e11067, "nibble_zero_connect_companion_radio_ble_" },
{ 0x2dbb2669, "nibble_zero_connect_companion_radio_full_" },
{ 0x90799c32, "nibble_zero_connect_companion_radio_usb_" },
{ 0x881a6a48, "nibble_zero_connect_companion_radio_wifi_" },
{ 0xf26af51c, "nibble_zero_connect_repeater_" },
@@ -408,6 +421,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x4d0b1601, "R1Neo_sensor" },
{ 0xab2ac09e, "R1Neo_terminal_chat" },
{ 0xa17fc4f7, "RAK_3112_companion_radio_ble" },
{ 0xe4b2bd22, "RAK_3112_companion_radio_full" },
{ 0x90c4eb3f, "RAK_3112_companion_radio_usb" },
{ 0x19d9ce91, "RAK_3112_companion_radio_wifi" },
{ 0x121cc72f, "RAK_3112_kiss_modem" },
@@ -438,6 +452,8 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x51fa1a49, "RAK_WisMesh_Tag_sensor" },
{ 0xdb73638b, "SenseCap_Solar_repeater" },
{ 0xd904c2d5, "SenseCapIndicator-ESPNow_comp_radio_usb" },
{ 0x620569f4, "SenseCapIndicator-LoRa_comp_radio_usb" },
{ 0x08483f6b, "SenseCapIndicator-LoRa_comp_radio_usb_wifi" },
{ 0x0db48f35, "solarxiao_30S_companion_radio_ble" },
{ 0x20532f6c, "solarxiao_30S_companion_radio_usb" },
{ 0x293ced32, "solarxiao_30S_kiss_modem" },
@@ -455,6 +471,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0xf2128c81, "Station_G2_repeater" },
{ 0x1b34e7b4, "Station_G2_repeater_bridge_espnow" },
{ 0x8e549407, "Station_G3_ESP32_companion_radio_ble" },
{ 0x6ca142f4, "Station_G3_ESP32_companion_radio_full" },
{ 0x1deac038, "Station_G3_ESP32_companion_radio_usb" },
{ 0x2a2f303b, "Station_G3_ESP32_companion_radio_wifi" },
{ 0x0ba4453d, "Station_G3_ESP32_kiss_modem" },
@@ -584,6 +601,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
{ 0x60f5fdc2, "Xiao_S3_sensor" },
{ 0xe8f79d22, "Xiao_S3_WIO_companion_radio_ble" },
{ 0xb97bbb15, "Xiao_S3_WIO_companion_radio_ble_ps" },
{ 0xd466590c, "Xiao_S3_WIO_companion_radio_full" },
{ 0x9f27be15, "Xiao_S3_WIO_companion_radio_serial" },
{ 0x396fecc7, "Xiao_S3_WIO_companion_radio_usb" },
{ 0xf94e3407, "Xiao_S3_WIO_companion_radio_wifi" },
+29 -19
View File
@@ -62,6 +62,7 @@ void RadioLibWrapper::begin() {
}
_noise_floor = 0;
_noise_floor_centi_dbm = 0;
_noise_floor_valid = false;
_threshold = 0;
_cad_enabled = false;
@@ -71,7 +72,7 @@ void RadioLibWrapper::begin() {
// start average out some samples
_num_floor_samples = 0;
_floor_sample_sum = 0;
_floor_sample_sum_centi_dbm = 0;
_nf_calib_active = false;
_nf_last_calib = 0;
_nf_sample_from = 0;
@@ -232,7 +233,7 @@ void RadioLibWrapper::recalibrateNoiseFloor() {
_nf_refresh_requested = true;
_nf_last_calib = 0;
_num_floor_samples = 0;
_floor_sample_sum = 0;
_floor_sample_sum_centi_dbm = 0;
const unsigned long now = millis();
_nf_sample_from = now + NF_CALIB_SETTLE_MS;
@@ -251,7 +252,7 @@ void RadioLibWrapper::requestNoiseFloorRefresh() {
if (_nf_refresh_requested) return;
_nf_refresh_requested = true;
_num_floor_samples = 0;
_floor_sample_sum = 0;
_floor_sample_sum_centi_dbm = 0;
_nf_calib_deadline = 0; // starts when continuous RX is actually available
}
@@ -425,7 +426,7 @@ void RadioLibWrapper::noiseFloorCalibCheck(unsigned long now) {
_nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS;
_nf_sample_from = now + NF_CALIB_SETTLE_MS;
_num_floor_samples = 0; // start a fresh batch for this window
_floor_sample_sum = 0;
_floor_sample_sum_centi_dbm = 0;
if (!isPacketPendingOrReceiving()) {
requestRestartRecv(); // startReceiveMode() selects continuous RX
}
@@ -463,22 +464,29 @@ void RadioLibWrapper::loop() {
_nf_calib_deadline = now + NF_CONTINUOUS_TIMEOUT_MS;
}
if (_nf_refresh_requested && _num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES
&& _floor_sample_sum != 0) {
int16_t sampled_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES;
if (sampled_floor < -120) {
sampled_floor = -120; // clamp to lower bound of -120dBi
&& _floor_sample_sum_centi_dbm != 0) {
int32_t sampled_floor_centi_dbm =
_floor_sample_sum_centi_dbm / NUM_NOISE_FLOOR_SAMPLES;
if (sampled_floor_centi_dbm < -12000) {
sampled_floor_centi_dbm = -12000;
}
if (_noise_floor_valid) {
// Favor the fresh high-rate batch while retaining a small amount of
// history: 25% previous floor + 75% newly sampled floor. Round the
// negative dBm result to the nearest integer instead of toward zero.
int32_t weighted_floor = (int32_t)_noise_floor + 3L * sampled_floor;
_noise_floor = weighted_floor < 0 ? (weighted_floor - 2) / 4
: (weighted_floor + 2) / 4;
// history: 25% previous floor + 75% newly sampled floor.
int32_t weighted_floor = _noise_floor_centi_dbm
+ 3L * sampled_floor_centi_dbm;
_noise_floor_centi_dbm = weighted_floor < 0
? (weighted_floor - 2) / 4
: (weighted_floor + 2) / 4;
} else {
_noise_floor = sampled_floor;
_noise_floor_centi_dbm = sampled_floor_centi_dbm;
}
_floor_sample_sum = 0;
// Preserve the existing whole-dB API and wire formats while exposing the
// fractional 64-sample mean to local displays.
_noise_floor = _noise_floor_centi_dbm < 0
? (_noise_floor_centi_dbm - 50) / 100
: (_noise_floor_centi_dbm + 50) / 100;
_floor_sample_sum_centi_dbm = 0;
_noise_floor_valid = true;
_nf_refresh_requested = false;
_nf_last_calib = now;
@@ -503,7 +511,7 @@ void RadioLibWrapper::loop() {
_nf_last_calib = now;
_nf_calib_deadline = 0;
_num_floor_samples = 0;
_floor_sample_sum = 0;
_floor_sample_sum_centi_dbm = 0;
}
if (_nf_refresh_requested && state == STATE_RX
@@ -516,13 +524,15 @@ void RadioLibWrapper::loop() {
if (!_rx_ps_armed
&& !(_nf_sample_from != 0 && (long)(now - _nf_sample_from) < 0)
&& !isReceivingPacket()) {
int rssi = getCurrentRSSI();
if (!_noise_floor_valid || rssi < _noise_floor + SAMPLING_THRESHOLD) {
float rssi = getCurrentRSSI();
if (!_noise_floor_valid
|| rssi < getNoiseFloorDbm() + SAMPLING_THRESHOLD) {
// With no valid baseline (startup, AGC reset, or gain change), seed
// unconditionally. Otherwise reject likely traffic above the current
// floor plus the sampling margin.
_num_floor_samples++;
_floor_sample_sum += rssi;
_floor_sample_sum_centi_dbm +=
static_cast<int32_t>(rssi * 100.0f);
}
}
}
+5 -1
View File
@@ -30,12 +30,13 @@ protected:
mesh::MainBoard* _board;
uint32_t n_recv, n_sent, n_recv_errors;
int16_t _noise_floor, _threshold;
int32_t _noise_floor_centi_dbm;
float _last_rssi, _last_snr;
bool _cad_enabled;
bool _noise_floor_valid;
bool _nf_refresh_requested;
uint16_t _num_floor_samples;
int32_t _floor_sample_sum;
int32_t _floor_sample_sum_centi_dbm;
unsigned long last_recv_millis;
unsigned long last_radio_interrupt_millis; // updated on any ISR event, even CRC errors
bool _rx_ps_enabled;
@@ -203,6 +204,9 @@ public:
virtual int16_t performChannelScan();
int getNoiseFloor() const override { return _noise_floor; }
float getNoiseFloorDbm() const override {
return _noise_floor_centi_dbm / 100.0f;
}
void triggerNoiseFloorCalibrate(int threshold) override;
void recalibrateNoiseFloor() override;
void setCADEnabled(bool enable) override { _cad_enabled = enable; }
@@ -691,12 +691,17 @@ bool EnvironmentSensorManager::begin() {
MESH_DEBUG_PRINTLN("Second I2C initialized on pins SDA: %d SCL: %d", ENV_PIN_SDA, ENV_PIN_SCL);
#endif
_active_sensor_count = 0;
// Avoid touching a shared I2C bus when no environmental drivers were built.
if (SENSOR_TABLE_SIZE == 0) {
return true;
}
// Scan the I2C bus before touching any sensor library.
bool detected[128] = {};
scanI2CBus(TELEM_WIRE, detected);
// Walk the sensor table and initialize only detected devices.
_active_sensor_count = 0;
for (size_t i = 0; i < SENSOR_TABLE_SIZE && _active_sensor_count < MAX_ACTIVE_SENSORS; i++) {
const SensorDef& def = SENSOR_TABLE[i];
// One static driver instance per type: an alternate address is a fallback, not a second device.
+123
View File
@@ -0,0 +1,123 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh {
namespace ui {
class ColorEmojiAtlas {
static constexpr size_t FOOTER_SIZE = 40;
static constexpr size_t ATLAS_HEADER_SIZE = 12;
static constexpr uint16_t FIRST_EMOJI = 0xE000;
const uint8_t* _pixels = nullptr;
uint16_t _count = 0;
uint16_t _stride = 0;
uint8_t _width = 0;
uint8_t _height = 0;
uint8_t _transparent = 0;
uint8_t _text_scale = 1;
static uint16_t read16(const uint8_t* p) {
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
}
static uint32_t read32(const uint8_t* p) {
return (uint32_t)p[0]
| ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16)
| ((uint32_t)p[3] << 24);
}
static uint32_t crc32(const uint8_t* data, size_t size) {
uint32_t crc = 0xFFFFFFFFUL;
for (size_t i = 0; i < size; ++i) {
crc ^= data[i];
for (uint8_t bit = 0; bit < 8; ++bit) {
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
}
}
return ~crc;
}
public:
void reset() {
_pixels = nullptr;
_count = 0;
_stride = 0;
_width = 0;
_height = 0;
_transparent = 0;
_text_scale = 1;
}
bool begin(const uint8_t* font_data, size_t font_size) {
reset();
if (font_data == nullptr || font_size < FOOTER_SIZE) return false;
const uint8_t* footer = font_data + font_size - FOOTER_SIZE;
static const uint8_t FOOTER_MAGIC[8] = {
'M', 'C', 'E', 'M', 'A', 'P', '2', 0
};
if (memcmp(footer, FOOTER_MAGIC, sizeof(FOOTER_MAGIC)) != 0) {
return false;
}
uint32_t atlas_offset = read32(footer + 20);
uint32_t atlas_size = read32(footer + 24);
uint32_t atlas_crc = read32(footer + 28);
if (atlas_offset > font_size - FOOTER_SIZE
|| atlas_size > font_size - FOOTER_SIZE - atlas_offset
|| atlas_size < ATLAS_HEADER_SIZE) {
return false;
}
const uint8_t* atlas = font_data + atlas_offset;
if (crc32(atlas, atlas_size) != atlas_crc
|| memcmp(atlas, "CE01", 4) != 0) {
return false;
}
uint16_t count = read16(atlas + 4);
uint8_t width = atlas[6];
uint8_t height = atlas[7];
uint8_t transparent = atlas[8];
uint8_t text_scale = atlas[9];
uint16_t stride = read16(atlas + 10);
if (count == 0 || width == 0 || height == 0
|| width > 64 || height > 64
|| text_scale == 0 || text_scale > 8
|| stride != (uint16_t)width * height
|| (size_t)count * stride != atlas_size - ATLAS_HEADER_SIZE) {
return false;
}
_pixels = atlas + ATLAS_HEADER_SIZE;
_count = count;
_stride = stride;
_width = width;
_height = height;
_transparent = transparent;
_text_scale = text_scale;
return true;
}
bool isReady() const { return _pixels != nullptr; }
uint8_t width() const { return _width; }
uint8_t height() const { return _height; }
uint8_t transparent() const { return _transparent; }
uint8_t textScale() const { return _text_scale; }
uint16_t count() const { return _count; }
const uint8_t* glyph(uint16_t mapped_codepoint) const {
if (!isReady() || mapped_codepoint < FIRST_EMOJI) return nullptr;
uint16_t index = mapped_codepoint - FIRST_EMOJI;
if (index >= _count) return nullptr;
return _pixels + (size_t)index * _stride;
}
};
} // namespace ui
} // namespace mesh
+32 -5
View File
@@ -15,6 +15,14 @@ class DisplayDriver {
int _w, _h;
protected:
DisplayDriver(int w, int h) { _w = w; _h = h; }
static size_t trimLastUTF8Codepoint(char* str, size_t length) {
if (length == 0) return 0;
size_t start = length - 1;
while (start > 0 && ((uint8_t)str[start] & 0xC0) == 0x80) --start;
str[start] = 0;
return start;
}
public:
//enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light
@@ -37,6 +45,11 @@ public:
virtual void drawRect(int x, int y, int w, int h) = 0;
virtual void drawXbm(int x, int y, const uint8_t* bits, int w, int h) = 0;
virtual uint16_t getTextWidth(const char* str) = 0;
virtual bool getTouch(int* x, int* y) {
(void)x;
(void)y;
return false;
}
virtual void drawTextCentered(int mid_x, int y, const char* str) { // helper method (override to optimise)
int w = getTextWidth(str);
setCursor(mid_x - w/2, y);
@@ -72,9 +85,16 @@ public:
virtual void drawTextEllipsized(int x, int y, int max_width, const char* str) {
char temp_str[256]; // reasonable buffer size
size_t len = strlen(str);
if (len >= sizeof(temp_str)) len = sizeof(temp_str) - 1;
if (len >= sizeof(temp_str)) {
len = sizeof(temp_str) - 1;
// If the fixed buffer cuts through a UTF-8 sequence, omit that whole
// codepoint instead of passing malformed text to the display driver.
while (len > 0 && ((uint8_t)str[len] & 0xC0) == 0x80) --len;
}
memcpy(temp_str, str, len);
temp_str[len] = 0;
if (max_width <= 0) return;
if (getTextWidth(temp_str) <= max_width) {
setCursor(x, y);
@@ -95,12 +115,19 @@ public:
}
int ellipsis_width = getTextWidth(ellipsis);
int str_len = strlen(temp_str);
size_t ellipsis_len = strlen(ellipsis);
size_t str_len = strlen(temp_str);
while (str_len > 0 && getTextWidth(temp_str) > max_width - ellipsis_width) {
temp_str[--str_len] = 0;
while (str_len > 0
&& (getTextWidth(temp_str) > max_width - ellipsis_width
|| str_len + ellipsis_len >= sizeof(temp_str))) {
str_len = trimLastUTF8Codepoint(temp_str, str_len);
}
if (ellipsis_width <= max_width) {
memcpy(temp_str + str_len, ellipsis, ellipsis_len + 1);
} else {
temp_str[0] = 0;
}
strcat(temp_str, ellipsis);
setCursor(x, y);
print(temp_str);
+178
View File
@@ -0,0 +1,178 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh {
namespace ui {
class EmojiFontMap {
static constexpr size_t MAP_HEADER_SIZE = 12;
static constexpr size_t NODE_SIZE = 6;
static constexpr size_t EDGE_SIZE = 3;
static constexpr size_t FOOTER_V1_SIZE = 24;
static constexpr size_t FOOTER_V2_SIZE = 40;
const uint8_t* _nodes = nullptr;
const uint8_t* _edges = nullptr;
uint16_t _node_count = 0;
uint16_t _edge_count = 0;
static uint16_t read16(const uint8_t* p) {
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
}
static uint32_t read32(const uint8_t* p) {
return (uint32_t)p[0]
| ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16)
| ((uint32_t)p[3] << 24);
}
static uint32_t crc32(const uint8_t* data, size_t size) {
uint32_t crc = 0xFFFFFFFFUL;
for (size_t i = 0; i < size; ++i) {
crc ^= data[i];
for (uint8_t bit = 0; bit < 8; ++bit) {
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
}
}
return ~crc;
}
const uint8_t* node(uint16_t index) const {
return _nodes + (size_t)index * NODE_SIZE;
}
const uint8_t* edge(uint16_t index) const {
return _edges + (size_t)index * EDGE_SIZE;
}
bool findChild(uint16_t node_index, uint8_t value,
uint16_t& child) const {
const uint8_t* current = node(node_index);
uint16_t first = read16(current);
uint16_t count = read16(current + 2);
uint16_t low = 0;
uint16_t high = count;
while (low < high) {
uint16_t middle = low + (high - low) / 2;
const uint8_t* candidate = edge(first + middle);
if (candidate[0] < value) {
low = middle + 1;
} else {
high = middle;
}
}
if (low >= count) return false;
const uint8_t* match = edge(first + low);
if (match[0] != value) return false;
child = read16(match + 1);
return child < _node_count;
}
public:
void reset() {
_nodes = nullptr;
_edges = nullptr;
_node_count = 0;
_edge_count = 0;
}
bool begin(const uint8_t* font_data, size_t font_size) {
reset();
if (font_data == nullptr || font_size < FOOTER_V1_SIZE) return false;
static const uint8_t FOOTER_V1_MAGIC[8] = {
'M', 'C', 'E', 'M', 'A', 'P', '1', 0
};
static const uint8_t FOOTER_V2_MAGIC[8] = {
'M', 'C', 'E', 'M', 'A', 'P', '2', 0
};
size_t footer_size = FOOTER_V1_SIZE;
const uint8_t* footer = font_data + font_size - footer_size;
if (font_size >= FOOTER_V2_SIZE
&& memcmp(font_data + font_size - FOOTER_V2_SIZE,
FOOTER_V2_MAGIC, sizeof(FOOTER_V2_MAGIC)) == 0) {
footer_size = FOOTER_V2_SIZE;
footer = font_data + font_size - footer_size;
} else if (memcmp(footer, FOOTER_V1_MAGIC,
sizeof(FOOTER_V1_MAGIC)) != 0) {
return false;
}
uint32_t map_offset = read32(footer + 8);
uint32_t map_size = read32(footer + 12);
uint32_t map_crc = read32(footer + 16);
if (map_offset > font_size - footer_size
|| map_size > font_size - footer_size - map_offset
|| map_size < MAP_HEADER_SIZE) {
return false;
}
const uint8_t* map = font_data + map_offset;
if (crc32(map, map_size) != map_crc
|| memcmp(map, "EM01", 4) != 0) {
return false;
}
uint16_t node_count = read16(map + 4);
uint16_t edge_count = read16(map + 6);
size_t expected_size = MAP_HEADER_SIZE
+ (size_t)node_count * NODE_SIZE
+ (size_t)edge_count * EDGE_SIZE;
if (node_count == 0 || expected_size != map_size) return false;
const uint8_t* nodes = map + MAP_HEADER_SIZE;
const uint8_t* edges = nodes + (size_t)node_count * NODE_SIZE;
for (uint16_t i = 0; i < node_count; ++i) {
const uint8_t* current = nodes + (size_t)i * NODE_SIZE;
uint16_t first = read16(current);
uint16_t count = read16(current + 2);
uint16_t output = read16(current + 4);
if (first > edge_count || count > edge_count - first) return false;
if (output != 0 && (output < 0xE000 || output > 0xF8FF)) return false;
int previous = -1;
for (uint16_t j = 0; j < count; ++j) {
const uint8_t* current_edge = edges + (size_t)(first + j) * EDGE_SIZE;
if (current_edge[0] <= previous
|| read16(current_edge + 1) >= node_count) {
return false;
}
previous = current_edge[0];
}
}
_nodes = nodes;
_edges = edges;
_node_count = node_count;
_edge_count = edge_count;
return true;
}
bool isReady() const { return _nodes != nullptr; }
size_t longestMatch(const uint8_t* text, size_t length,
uint16_t& mapped_codepoint) const {
mapped_codepoint = 0;
if (!isReady() || text == nullptr || length == 0) return 0;
uint16_t node_index = 0;
size_t best_length = 0;
for (size_t i = 0; i < length; ++i) {
uint16_t child;
if (!findChild(node_index, text[i], child)) break;
node_index = child;
uint16_t output = read16(node(node_index) + 4);
if (output != 0) {
mapped_codepoint = output;
best_length = i + 1;
}
}
return best_length;
}
};
} // namespace ui
} // namespace mesh
+511 -30
View File
@@ -1,5 +1,108 @@
#include "LGFXDisplay.h"
#ifndef DISPLAY_ROTATION
#define DISPLAY_ROTATION 1
#endif
#ifndef DISPLAY_BRIGHTNESS
#define DISPLAY_BRIGHTNESS 64
#endif
namespace {
uint32_t readBigEndian32(const uint8_t* data) {
return ((uint32_t)data[0] << 24)
| ((uint32_t)data[1] << 16)
| ((uint32_t)data[2] << 8)
| (uint32_t)data[3];
}
uint32_t readLittleEndian32(const uint8_t* data) {
return (uint32_t)data[0]
| ((uint32_t)data[1] << 8)
| ((uint32_t)data[2] << 16)
| ((uint32_t)data[3] << 24);
}
bool validateVlw(const uint8_t* data, size_t size) {
static const size_t HEADER_SIZE = 24;
static const size_t RECORD_SIZE = 28;
static const size_t FOOTER_V1_SIZE = 24;
static const size_t FOOTER_V2_SIZE = 40;
static const uint8_t FOOTER_V1_MAGIC[8] = {
'M', 'C', 'E', 'M', 'A', 'P', '1', 0
};
static const uint8_t FOOTER_V2_MAGIC[8] = {
'M', 'C', 'E', 'M', 'A', 'P', '2', 0
};
if (data == nullptr || size < HEADER_SIZE + FOOTER_V1_SIZE) {
return false;
}
size_t footer_size = FOOTER_V1_SIZE;
const uint8_t* footer = data + size - footer_size;
if (size >= HEADER_SIZE + FOOTER_V2_SIZE
&& memcmp(data + size - FOOTER_V2_SIZE, FOOTER_V2_MAGIC,
sizeof(FOOTER_V2_MAGIC)) == 0) {
footer_size = FOOTER_V2_SIZE;
footer = data + size - footer_size;
} else if (memcmp(footer, FOOTER_V1_MAGIC,
sizeof(FOOTER_V1_MAGIC)) != 0) {
return false;
}
uint32_t glyph_count = readBigEndian32(data);
uint32_t map_offset = readLittleEndian32(footer + 8);
if (glyph_count == 0 || glyph_count > 10000
|| map_offset < HEADER_SIZE
|| map_offset > size - footer_size
|| glyph_count > (map_offset - HEADER_SIZE) / RECORD_SIZE) {
return false;
}
size_t bitmap_offset = HEADER_SIZE + (size_t)glyph_count * RECORD_SIZE;
uint32_t previous_codepoint = 0;
for (uint32_t i = 0; i < glyph_count; ++i) {
const uint8_t* record = data + HEADER_SIZE + (size_t)i * RECORD_SIZE;
uint32_t codepoint = readBigEndian32(record);
uint32_t height = readBigEndian32(record + 4);
uint32_t width = readBigEndian32(record + 8);
uint32_t advance = readBigEndian32(record + 12);
if ((i != 0 && codepoint <= previous_codepoint)
|| codepoint > 0xFFFF
|| width > 64
|| height > 64
|| advance > 64
|| width > SIZE_MAX / (height == 0 ? 1 : height)
|| bitmap_offset > map_offset
|| width * height > map_offset - bitmap_offset) {
return false;
}
previous_codepoint = codepoint;
bitmap_offset += width * height;
}
return bitmap_offset == map_offset;
}
LGFXDisplay* activeEmojiDisplay = nullptr;
static const uint32_t UI_PALETTE[16] = {
0x000000, 0xFFFFFF, 0x0000FF, 0x929292,
0xFFAA00, 0x00FFFF, 0x0000D6, 0xE53935,
0x43A047, 0x1E88E5, 0x8E24AA, 0xFDD835,
0x6D4C41, 0x00ACC1, 0xF06292, 0xFF00FF,
};
static constexpr uint8_t TRANSPARENT_EMOJI_PALETTE_INDEX = 15;
static constexpr uint32_t TRANSPARENT_EMOJI_COLOR = 0xFF00FF;
bool isSequence(const uint8_t* text, size_t remaining,
const uint8_t* sequence, size_t sequence_size) {
return remaining >= sequence_size
&& memcmp(text, sequence, sequence_size) == 0;
}
} // namespace
// Color scheme
ColorVal UIColor::window_bkg = 0xFFFF;
ColorVal UIColor::title_bkg = 0x001F;
@@ -12,31 +115,44 @@ ColorVal UIColor::popup_txt = 0x0000;
ColorVal UIColor::corp_blue = 0x001A;
bool LGFXDisplay::begin() {
turnOn();
display->init();
display->setRotation(1);
display->setBrightness(64);
if (!display->init()) return false;
_isOn = true;
display->setRotation(DISPLAY_ROTATION);
display->setBrightness(DISPLAY_BRIGHTNESS);
display->setColorDepth(8);
display->setTextColor(TFT_WHITE);
buffer.setColorDepth(8);
buffer.setPsram(true);
buffer.createSprite(width(), height());
buffer.setColorDepth(UI_BUFFER_COLOR_DEPTH);
// The RGB scanout framebuffer must live in PSRAM. Keep this much smaller
// logical UI sprite in internal DMA-capable RAM so page redraws do not
// compete with scanout and permanently shift the panel after an underflow.
buffer.setPsram(false);
if (buffer.createSprite(width() * UI_COORD_SCALE,
height() * UI_COORD_SCALE) == nullptr) {
return false;
}
configurePalette();
return true;
}
void LGFXDisplay::turnOn() {
// display->wakeup();
if (!_isOn) {
display->wakeup();
// Keep the panel initialized while blanked so touch remains available as
// a wake source. Restoring brightness is sufficient for RGB panels.
display->setBrightness(DISPLAY_BRIGHTNESS);
#ifdef HAS_TOUCH
if (display->touch() != nullptr) display->touch()->wakeup();
#endif
}
_isOn = true;
}
void LGFXDisplay::turnOff() {
if (_isOn) {
display->sleep();
// Do not use LGFX sleep here: a sleeping touch controller cannot provide
// the event that wakes a touch-capable UI. Blank only the backlight.
display->setBrightness(0);
}
_isOn = false;
}
@@ -44,68 +160,433 @@ void LGFXDisplay::turnOff() {
void LGFXDisplay::clear() {
// display->clearDisplay();
buffer.clearDisplay();
_presentedEmojiOverlayCount = 0;
_hasLastFrame = false;
}
void LGFXDisplay::startFrame(ColorVal bkg) {
// display->startWrite();
// display->getScanLine();
_color = bkg;
_emojiOverlayCount = 0;
_hasTransparentEmojiPixels = false;
_color = renderColor(bkg);
buffer.setBaseColor(_color);
buffer.fillScreen(_color);
buffer.setTextColor(_color = UIColor::primary_txt);
setColor(UIColor::primary_txt);
}
void LGFXDisplay::setTextSize(int sz) {
buffer.setTextSize(sz);
int scaled = (sz * UI_COORD_SCALE + _fontNativeScale / 2)
/ _fontNativeScale;
if (scaled < 1) scaled = 1;
buffer.setTextSize(scaled);
}
void LGFXDisplay::setColor(ColorVal c) {
buffer.setTextColor(_color = c);
_color = renderColor(c);
// Every frame starts from a freshly cleared canvas, so transparent glyph
// backgrounds are both sufficient and unambiguous. In particular,
// primary_txt and popup_txt are both black and cannot be distinguished by
// their RGB565 value alone.
buffer.setTextColor(_color);
}
void LGFXDisplay::setCursor(int x, int y) {
buffer.setCursor(x, y);
buffer.setCursor(x * UI_COORD_SCALE, y * UI_COORD_SCALE);
}
void LGFXDisplay::print(const char* str) {
buffer.println(str);
String mapped = mapText(str);
buffer.println(mapped.c_str());
// Serial.println(str);
}
void LGFXDisplay::fillRect(int x, int y, int w, int h) {
buffer.fillRect(x, y, w, h, _color);
buffer.fillRect(x * UI_COORD_SCALE, y * UI_COORD_SCALE,
w * UI_COORD_SCALE, h * UI_COORD_SCALE, _color);
}
void LGFXDisplay::drawRect(int x, int y, int w, int h) {
buffer.drawRect(x, y, w, h, _color);
if (w <= 0 || h <= 0) return;
if (UI_COORD_SCALE == 1) {
buffer.drawRect(x, y, w, h, _color);
return;
}
int left = x * UI_COORD_SCALE;
int top = y * UI_COORD_SCALE;
int scaled_width = w * UI_COORD_SCALE;
int scaled_height = h * UI_COORD_SCALE;
buffer.fillRect(left, top, scaled_width, UI_COORD_SCALE, _color);
buffer.fillRect(left, top + scaled_height - UI_COORD_SCALE,
scaled_width, UI_COORD_SCALE, _color);
buffer.fillRect(left, top, UI_COORD_SCALE, scaled_height, _color);
buffer.fillRect(left + scaled_width - UI_COORD_SCALE, top,
UI_COORD_SCALE, scaled_height, _color);
}
void LGFXDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
buffer.drawBitmap(x, y, bits, w, h, _color);
if (UI_COORD_SCALE == 1) {
buffer.drawBitmap(x, y, bits, w, h, _color);
return;
}
const int row_bytes = (w + 7) / 8;
for (int row = 0; row < h; ++row) {
int column = 0;
while (column < w) {
while (column < w
&& !(bits[row * row_bytes + column / 8]
& (0x80 >> (column & 7)))) {
++column;
}
int first = column;
while (column < w
&& (bits[row * row_bytes + column / 8]
& (0x80 >> (column & 7)))) {
++column;
}
if (first != column) {
buffer.fillRect((x + first) * UI_COORD_SCALE,
(y + row) * UI_COORD_SCALE,
(column - first) * UI_COORD_SCALE,
UI_COORD_SCALE, _color);
}
}
}
}
uint16_t LGFXDisplay::getTextWidth(const char* str) {
return buffer.textWidth(str);
String mapped = mapText(str);
return (buffer.textWidth(mapped.c_str()) + UI_COORD_SCALE - 1)
/ UI_COORD_SCALE;
}
void LGFXDisplay::translateUTF8ToBlocks(char* dest, const char* src,
size_t dest_size) {
if (!_emojiMap.isReady()) {
DisplayDriver::translateUTF8ToBlocks(dest, src, dest_size);
return;
}
if (dest_size == 0) return;
size_t output = 0;
for (size_t input = 0; src[input] != 0 && output + 1 < dest_size;) {
uint8_t first = (uint8_t)src[input];
size_t length = 1;
if (first >= 0xC2 && first <= 0xDF) {
length = 2;
} else if (first >= 0xE0 && first <= 0xEF) {
length = 3;
} else if (first >= 0xF0 && first <= 0xF4) {
length = 4;
} else if (first < 32 || first > 126) {
++input;
continue;
}
bool valid = true;
for (size_t i = 1; i < length; ++i) {
if (src[input + i] == 0
|| ((uint8_t)src[input + i] & 0xC0) != 0x80) {
valid = false;
break;
}
}
if (!valid || output + length >= dest_size) break;
memcpy(dest + output, src + input, length);
output += length;
input += length;
}
dest[output] = 0;
}
void LGFXDisplay::endFrame() {
uint32_t hash = frameHash();
if (_hasLastFrame && hash == _lastFrameHash) return;
_lastFrameHash = hash;
_hasLastFrame = true;
display->startWrite();
if (UI_ZOOM != 1) {
buffer.pushRotateZoom(display, display->width()/2, display->height()/2 , 0, UI_ZOOM, UI_ZOOM);
if (_hasTransparentEmojiPixels) {
buffer.pushRotateZoom(display, display->width() / 2,
display->height() / 2, 0, UI_ZOOM, UI_ZOOM,
TRANSPARENT_EMOJI_COLOR);
} else {
buffer.pushRotateZoom(display, display->width() / 2,
display->height() / 2, 0, UI_ZOOM, UI_ZOOM);
}
} else {
buffer.pushSprite(display, 0, 0);
if (_hasTransparentEmojiPixels) {
buffer.pushSprite(display, 0, 0, TRANSPARENT_EMOJI_COLOR);
} else {
buffer.pushSprite(display, 0, 0);
}
}
if (_emojiAtlas.isReady()) {
const float source_x = (_emojiAtlas.width() - 1) * 0.5f;
const float source_y = (_emojiAtlas.height() - 1) * 0.5f;
for (size_t i = 0; i < _emojiOverlayCount; ++i) {
const EmojiOverlay& overlay = _emojiOverlays[i];
const uint8_t* pixels = _emojiAtlas.glyph(overlay.codepoint);
if (pixels == nullptr) continue;
float size = overlay.size * UI_ZOOM;
float zoom_x = size / _emojiAtlas.width();
float zoom_y = size / _emojiAtlas.height();
float x = overlay.x * UI_ZOOM + (size - 1) * 0.5f;
float y = overlay.y * UI_ZOOM + (size - 1) * 0.5f;
display->pushImageRotateZoom(
x, y, source_x, source_y, 0, zoom_x, zoom_y,
_emojiAtlas.width(), _emojiAtlas.height(),
reinterpret_cast<const lgfx::rgb332_t*>(pixels),
lgfx::rgb332_t(_emojiAtlas.transparent()));
}
}
display->endWrite();
_presentedEmojiOverlayCount = _emojiOverlayCount;
memcpy(_presentedEmojiOverlays, _emojiOverlays,
_emojiOverlayCount * sizeof(EmojiOverlay));
}
bool LGFXDisplay::getTouch(int *x, int *y) {
lgfx::v1::touch_point_t point;
display->getTouch(&point);
if (UI_ZOOM != 1) {
*x = point.x / UI_ZOOM;
*y = point.y / UI_ZOOM;
bool LGFXDisplay::getTouch(int* x, int* y) {
lgfx::v1::touch_point_t point = {};
if (display->getTouch(&point) == 0) return false;
if (UI_ZOOM * UI_COORD_SCALE != 1) {
*x = point.x / (UI_ZOOM * UI_COORD_SCALE);
*y = point.y / (UI_ZOOM * UI_COORD_SCALE);
} else {
*x = point.x;
*y = point.y;
}
return (*x >= 0) && (*y >= 0);
}
return *x >= 0 && *x < width() && *y >= 0 && *y < height();
}
bool LGFXDisplay::installRuntimeFont(uint8_t* data, size_t size) {
mesh::ui::EmojiFontMap map;
mesh::ui::ColorEmojiAtlas atlas;
if (!validateVlw(data, size)
|| !map.begin(data, size)
|| !buffer.loadFont(data)) {
free(data);
return false;
}
if (_fontData != nullptr) free(_fontData);
_fontData = data;
_fontDataSize = size;
_emojiMap = map;
if (atlas.begin(data, size)) {
_emojiAtlas = atlas;
_fontNativeScale = atlas.textScale();
activeEmojiDisplay = this;
buffer.setEmojiCallback(drawEmoji);
} else {
_emojiAtlas.reset();
_fontNativeScale = 1;
buffer.setEmojiCallback(nullptr);
if (activeEmojiDisplay == this) activeEmojiDisplay = nullptr;
}
return true;
}
uint32_t LGFXDisplay::renderColor(ColorVal color) const {
#if UI_BUFFER_COLOR_DEPTH < 8
if (color == UIColor::primary_txt) return 0;
if (color == UIColor::window_bkg || color == UIColor::title_txt) return 1;
if (color == UIColor::title_bkg) return 2;
if (color == UIColor::secondary_txt) return 3;
if (color == UIColor::warning_txt) return 4;
if (color == UIColor::popup_bkg) return 5;
if (color == UIColor::popup_txt) return 0;
if (color == UIColor::corp_blue) return 6;
return color & 0x0F;
#else
return color;
#endif
}
void LGFXDisplay::configurePalette() {
#if UI_BUFFER_COLOR_DEPTH < 8
buffer.createPalette(UI_PALETTE,
sizeof(UI_PALETTE) / sizeof(UI_PALETTE[0]));
#endif
}
uint32_t LGFXDisplay::frameHash() const {
const uint8_t* data = static_cast<const uint8_t*>(buffer.getBuffer());
size_t length = buffer.bufferLength();
uint32_t hash = 2166136261UL;
for (size_t i = 0; i < length; ++i) {
hash = (hash ^ data[i]) * 16777619UL;
}
for (size_t i = 0; i < _emojiOverlayCount; ++i) {
const EmojiOverlay& overlay = _emojiOverlays[i];
const uint16_t values[] = {
overlay.codepoint,
static_cast<uint16_t>(overlay.x),
static_cast<uint16_t>(overlay.y),
overlay.size,
};
for (uint16_t value : values) {
hash = (hash ^ static_cast<uint8_t>(value)) * 16777619UL;
hash = (hash ^ static_cast<uint8_t>(value >> 8)) * 16777619UL;
}
}
return hash;
}
bool LGFXDisplay::wasEmojiPresented(const EmojiOverlay& overlay) const {
for (size_t i = 0; i < _presentedEmojiOverlayCount; ++i) {
const EmojiOverlay& presented = _presentedEmojiOverlays[i];
if (presented.codepoint == overlay.codepoint
&& presented.x == overlay.x
&& presented.y == overlay.y
&& presented.size == overlay.size) {
return true;
}
}
return false;
}
void LGFXDisplay::drawEmojiMask(int32_t x, int32_t y, int32_t size,
const uint8_t* pixels) {
#if UI_BUFFER_COLOR_DEPTH < 8
if (pixels == nullptr || size <= 0) return;
const int source_width = _emojiAtlas.width();
const int source_height = _emojiAtlas.height();
const uint8_t transparent = _emojiAtlas.transparent();
for (int source_y = 0; source_y < source_height; ++source_y) {
int top = y + source_y * size / source_height;
int bottom = y + (source_y + 1) * size / source_height;
for (int source_x = 0; source_x < source_width; ++source_x) {
if (pixels[source_y * source_width + source_x] == transparent) continue;
int left = x + source_x * size / source_width;
int right = x + (source_x + 1) * size / source_width;
buffer.fillRect(left, top, right - left, bottom - top,
TRANSPARENT_EMOJI_PALETTE_INDEX);
}
}
_hasTransparentEmojiPixels = true;
#else
(void)x;
(void)y;
(void)size;
(void)pixels;
#endif
}
void LGFXDisplay::drawEmojiUnderlay(int32_t x, int32_t y, int32_t size,
const uint8_t* pixels) {
#if UI_BUFFER_COLOR_DEPTH < 8
if (pixels == nullptr || size <= 0) return;
const int source_width = _emojiAtlas.width();
const int source_height = _emojiAtlas.height();
const uint8_t transparent = _emojiAtlas.transparent();
for (int source_y = 0; source_y < source_height; ++source_y) {
int top = y + source_y * size / source_height;
int bottom = y + (source_y + 1) * size / source_height;
for (int source_x = 0; source_x < source_width; ++source_x) {
uint8_t color = pixels[source_y * source_width + source_x];
if (color == transparent) continue;
int red = ((color >> 5) & 7) * 255 / 7;
int green = ((color >> 2) & 7) * 255 / 7;
int blue = (color & 3) * 255 / 3;
int best_index = 0;
uint32_t best_distance = UINT32_MAX;
// The final palette entry is reserved as the transparent color used to
// preserve an unchanged full-color emoji in the panel framebuffer.
for (int index = 0; index < TRANSPARENT_EMOJI_PALETTE_INDEX; ++index) {
int delta_red = red - (int)((UI_PALETTE[index] >> 16) & 0xFF);
int delta_green = green - (int)((UI_PALETTE[index] >> 8) & 0xFF);
int delta_blue = blue - (int)(UI_PALETTE[index] & 0xFF);
uint32_t distance = delta_red * delta_red
+ delta_green * delta_green + delta_blue * delta_blue;
if (distance < best_distance) {
best_distance = distance;
best_index = index;
}
}
int left = x + source_x * size / source_width;
int right = x + (source_x + 1) * size / source_width;
buffer.fillRect(left, top, right - left, bottom - top, best_index);
}
}
#else
(void)x;
(void)y;
(void)size;
(void)pixels;
#endif
}
int32_t LGFXDisplay::queueEmoji(lgfx::v1::LGFXBase* gfx,
int32_t x, int32_t y,
uint32_t codepoint,
int32_t font_height) {
if (gfx != &buffer || codepoint > 0xFFFF || font_height <= 0
|| _emojiAtlas.glyph((uint16_t)codepoint) == nullptr) {
return 0;
}
if (_emojiOverlayCount >= MAX_EMOJI_OVERLAYS) return font_height;
EmojiOverlay& overlay = _emojiOverlays[_emojiOverlayCount++];
overlay.codepoint = (uint16_t)codepoint;
overlay.x = x;
overlay.y = y;
overlay.size = (uint16_t)font_height;
const uint8_t* pixels = _emojiAtlas.glyph(overlay.codepoint);
if (wasEmojiPresented(overlay)) {
// Do not overwrite an unchanged full-color emoji with the indexed canvas
// during periodic message-age redraws. The following direct overlay is
// then idempotent instead of visibly blinking once per second.
drawEmojiMask(x, y, font_height, pixels);
} else {
drawEmojiUnderlay(x, y, font_height, pixels);
}
return font_height;
}
int32_t LGFXDisplay::drawEmoji(lgfx::v1::LGFXBase* gfx,
int32_t x, int32_t y,
uint32_t codepoint,
int32_t font_height) {
if (activeEmojiDisplay == nullptr) return 0;
return activeEmojiDisplay->queueEmoji(gfx, x, y, codepoint, font_height);
}
String LGFXDisplay::mapText(const char* str) const {
if (str == nullptr || !_emojiMap.isReady()) return String(str == nullptr ? "" : str);
const uint8_t* text = (const uint8_t*)str;
size_t length = strlen(str);
String mapped;
mapped.reserve(length + 8);
static const uint8_t VS15[] = {0xEF, 0xB8, 0x8E};
static const uint8_t VS16[] = {0xEF, 0xB8, 0x8F};
static const uint8_t ZWJ[] = {0xE2, 0x80, 0x8D};
for (size_t offset = 0; offset < length;) {
uint16_t codepoint;
size_t match = _emojiMap.longestMatch(text + offset, length - offset, codepoint);
if (match != 0) {
mapped += (char)(0xE0 | (codepoint >> 12));
mapped += (char)(0x80 | ((codepoint >> 6) & 0x3F));
mapped += (char)(0x80 | (codepoint & 0x3F));
offset += match;
continue;
}
if (text[offset] == ' ') {
mapped += (char)0xC2;
mapped += (char)0xA0;
++offset;
continue;
}
if (isSequence(text + offset, length - offset, VS15, sizeof(VS15))
|| isSequence(text + offset, length - offset, VS16, sizeof(VS16))
|| isSequence(text + offset, length - offset, ZWJ, sizeof(ZWJ))) {
offset += 3;
continue;
}
mapped += (char)text[offset++];
}
return mapped;
}
+48 -2
View File
@@ -1,6 +1,8 @@
#pragma once
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/ColorEmojiAtlas.h>
#include <helpers/ui/EmojiFontMap.h>
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
@@ -8,6 +10,12 @@
#ifndef UI_ZOOM
#define UI_ZOOM 1
#endif
#ifndef UI_COORD_SCALE
#define UI_COORD_SCALE 1
#endif
#ifndef UI_BUFFER_COLOR_DEPTH
#define UI_BUFFER_COLOR_DEPTH 8
#endif
class LGFXDisplay : public DisplayDriver {
protected:
@@ -16,10 +24,45 @@ protected:
bool _isOn = false;
int _color = TFT_WHITE;
uint8_t* _fontData = nullptr;
size_t _fontDataSize = 0;
mesh::ui::EmojiFontMap _emojiMap;
mesh::ui::ColorEmojiAtlas _emojiAtlas;
uint8_t _fontNativeScale = 1;
struct EmojiOverlay {
uint16_t codepoint;
int16_t x;
int16_t y;
uint16_t size;
};
static constexpr size_t MAX_EMOJI_OVERLAYS = 64;
EmojiOverlay _emojiOverlays[MAX_EMOJI_OVERLAYS];
size_t _emojiOverlayCount = 0;
EmojiOverlay _presentedEmojiOverlays[MAX_EMOJI_OVERLAYS];
size_t _presentedEmojiOverlayCount = 0;
bool _hasTransparentEmojiPixels = false;
uint32_t _lastFrameHash = 0;
bool _hasLastFrame = false;
String mapText(const char* str) const;
uint32_t renderColor(ColorVal color) const;
void configurePalette();
uint32_t frameHash() const;
bool wasEmojiPresented(const EmojiOverlay& overlay) const;
void drawEmojiMask(int32_t x, int32_t y, int32_t size,
const uint8_t* pixels);
void drawEmojiUnderlay(int32_t x, int32_t y, int32_t size,
const uint8_t* pixels);
int32_t queueEmoji(lgfx::v1::LGFXBase* gfx, int32_t x, int32_t y,
uint32_t codepoint, int32_t font_height);
static int32_t drawEmoji(lgfx::v1::LGFXBase* gfx, int32_t x, int32_t y,
uint32_t codepoint, int32_t font_height);
public:
LGFXDisplay(int w, int h, LGFX_Device &disp)
: DisplayDriver(w/UI_ZOOM, h/UI_ZOOM), display(&disp) {}
: DisplayDriver(w/(UI_ZOOM*UI_COORD_SCALE),
h/(UI_ZOOM*UI_COORD_SCALE)), display(&disp) {}
bool begin();
bool isOn() override { return _isOn; }
void turnOn() override;
@@ -34,6 +77,9 @@ public:
void drawRect(int x, int y, int w, int h) override;
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
uint16_t getTextWidth(const char* str) override;
void translateUTF8ToBlocks(char* dest, const char* src,
size_t dest_size) override;
void endFrame() override;
virtual bool getTouch(int *x, int *y);
bool getTouch(int* x, int* y) override;
bool installRuntimeFont(uint8_t* data, size_t size);
};
+135
View File
@@ -0,0 +1,135 @@
#pragma once
#include <stdint.h>
namespace mesh {
namespace ui {
enum class TouchAction : uint8_t {
None,
Previous,
Next,
Select,
VerticalPrevious,
VerticalNext,
};
// Converts one-finger touch samples into the three actions understood by the
// button-oriented companion UI. An action is emitted only after release so a
// drag cannot activate the page under the user's finger.
class TouchInput {
bool _active = false;
bool _reverse_swipes;
bool _separate_vertical_swipes;
uint8_t _center_zone_percent;
uint8_t _touch_samples = 0;
uint8_t _release_samples = 0;
int _start_x = 0;
int _start_y = 0;
int _last_x = 0;
int _last_y = 0;
static int magnitude(int value) { return value < 0 ? -value : value; }
TouchAction swipeAction(bool negative_direction) const {
const bool next = negative_direction != _reverse_swipes;
return next ? TouchAction::Next : TouchAction::Previous;
}
TouchAction verticalSwipeAction(bool negative_direction) const {
if (!_separate_vertical_swipes) return swipeAction(negative_direction);
const bool next = negative_direction != _reverse_swipes;
return next ? TouchAction::VerticalNext
: TouchAction::VerticalPrevious;
}
public:
explicit TouchInput(bool reverse_swipes = false,
bool separate_vertical_swipes = false,
uint8_t center_zone_percent = 34)
: _reverse_swipes(reverse_swipes),
_separate_vertical_swipes(separate_vertical_swipes),
_center_zone_percent(center_zone_percent > 100
? 100
: center_zone_percent) {}
TouchAction update(bool touched, int x, int y, int width, int height,
bool bottom_selector = false) {
if (touched) {
_release_samples = 0;
if (!_active) {
_active = true;
_start_x = x;
_start_y = y;
_touch_samples = 0;
}
if (_touch_samples != UINT8_MAX) ++_touch_samples;
_last_x = x;
_last_y = y;
return TouchAction::None;
}
if (!_active) return TouchAction::None;
// A moving FT5x06-family controller can briefly report no points between
// two valid samples. Require a stable release so that gap cannot split one
// swipe into a swipe followed by an endpoint tap.
if (++_release_samples < 2) return TouchAction::None;
_active = false;
_release_samples = 0;
// A contact seen only once has no measurable direction. Ignoring it is
// safer than treating the endpoint of a fast swipe as an opposite tap.
if (_touch_samples < 2 || width <= 0 || height <= 0) {
return TouchAction::None;
}
const int dx = _last_x - _start_x;
const int dy = _last_y - _start_y;
const int abs_dx = magnitude(dx);
const int abs_dy = magnitude(dy);
const int horizontal_threshold = width / 8 > 8 ? width / 8 : 8;
const int vertical_threshold = height / 8 > 8 ? height / 8 : 8;
if (abs_dx >= abs_dy && abs_dx >= horizontal_threshold) {
return swipeAction(dx < 0);
}
if (abs_dy > abs_dx && abs_dy >= vertical_threshold) {
return verticalSwipeAction(dy < 0);
}
// Message screens may reserve the otherwise empty ends of their bottom
// status bar as forgiving arrow buttons. Keep the label in the middle
// inert so an imprecise arrow tap cannot accidentally close the screen.
if (bottom_selector && _separate_vertical_swipes
&& _start_y >= (height * 3) / 4) {
if (_start_x < width / 4) {
return TouchAction::VerticalPrevious;
}
if (_start_x >= (width * 3) / 4) {
return TouchAction::VerticalNext;
}
return TouchAction::None;
}
// Taps use three broad zones so every companion action remains available:
// left = previous, center = select, right = next.
// Use the initial position for a tap. If a short swipe falls just below
// the movement threshold, its endpoint may be in the opposite tap zone on
// displays whose touch X axis is inverted.
const int side_percent = (100 - _center_zone_percent) / 2;
const int center_left = (width * side_percent) / 100;
const int center_right = width - center_left;
if (_start_x < center_left) return TouchAction::Previous;
if (_start_x >= center_right) return TouchAction::Next;
return TouchAction::Select;
}
void reset() {
_active = false;
_touch_samples = 0;
_release_samples = 0;
}
};
} // namespace ui
} // namespace mesh
@@ -0,0 +1,93 @@
#include <gtest/gtest.h>
#include <helpers/ui/ColorEmojiAtlas.h>
#include <stdint.h>
#include <vector>
using mesh::ui::ColorEmojiAtlas;
namespace {
void append16(std::vector<uint8_t>& data, uint16_t value) {
data.push_back((uint8_t)value);
data.push_back((uint8_t)(value >> 8));
}
void append32(std::vector<uint8_t>& data, uint32_t value) {
data.push_back((uint8_t)value);
data.push_back((uint8_t)(value >> 8));
data.push_back((uint8_t)(value >> 16));
data.push_back((uint8_t)(value >> 24));
}
uint32_t crc32(const uint8_t* data, size_t size) {
uint32_t crc = 0xFFFFFFFFUL;
for (size_t i = 0; i < size; ++i) {
crc ^= data[i];
for (uint8_t bit = 0; bit < 8; ++bit) {
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
}
}
return ~crc;
}
std::vector<uint8_t> sampleFont() {
std::vector<uint8_t> font(24, 0x55);
const uint32_t map_offset = font.size();
std::vector<uint8_t> map = {
'E', 'M', '0', '1', 1, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
};
font.insert(font.end(), map.begin(), map.end());
const uint32_t atlas_offset = font.size();
std::vector<uint8_t> atlas = {
'C', 'E', '0', '1',
2, 0, 2, 2, 0xE3, 3, 4, 0,
1, 2, 3, 4,
5, 6, 7, 8,
};
font.insert(font.end(), atlas.begin(), atlas.end());
font.insert(font.end(), {'M', 'C', 'E', 'M', 'A', 'P', '2', 0});
append32(font, map_offset);
append32(font, map.size());
append32(font, crc32(map.data(), map.size()));
append32(font, atlas_offset);
append32(font, atlas.size());
append32(font, crc32(atlas.data(), atlas.size()));
append32(font, 0);
append32(font, 0);
return font;
}
} // namespace
TEST(ColorEmojiAtlas, ReadsGlyphsAndMetrics) {
std::vector<uint8_t> font = sampleFont();
ColorEmojiAtlas atlas;
ASSERT_TRUE(atlas.begin(font.data(), font.size()));
EXPECT_EQ(2, atlas.width());
EXPECT_EQ(2, atlas.height());
EXPECT_EQ(3, atlas.textScale());
EXPECT_EQ(0xE3, atlas.transparent());
ASSERT_NE(nullptr, atlas.glyph(0xE000));
EXPECT_EQ(1, atlas.glyph(0xE000)[0]);
EXPECT_EQ(8, atlas.glyph(0xE001)[3]);
EXPECT_EQ(nullptr, atlas.glyph(0xDFFF));
EXPECT_EQ(nullptr, atlas.glyph(0xE002));
}
TEST(ColorEmojiAtlas, RejectsCorruptAtlas) {
std::vector<uint8_t> font = sampleFont();
font[50] ^= 0x80;
ColorEmojiAtlas atlas;
EXPECT_FALSE(atlas.begin(font.data(), font.size()));
EXPECT_FALSE(atlas.isReady());
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,83 @@
#include <gtest/gtest.h>
#include <helpers/ui/DisplayDriver.h>
#include <string>
namespace {
bool isValidUTF8(const char* text) {
for (size_t offset = 0; text[offset] != 0;) {
uint8_t first = (uint8_t)text[offset];
size_t length = 1;
if (first >= 0xC2 && first <= 0xDF) {
length = 2;
} else if (first >= 0xE0 && first <= 0xEF) {
length = 3;
} else if (first >= 0xF0 && first <= 0xF4) {
length = 4;
} else if (first >= 0x80) {
return false;
}
for (size_t i = 1; i < length; ++i) {
if (text[offset + i] == 0
|| ((uint8_t)text[offset + i] & 0xC0) != 0x80) {
return false;
}
}
offset += length;
}
return true;
}
class TestDisplay : public DisplayDriver {
public:
std::string printed;
TestDisplay() : DisplayDriver(100, 100) {}
bool isOn() override { return true; }
void turnOn() override {}
void turnOff() override {}
void clear() override {}
void startFrame(ColorVal) override {}
void setTextSize(int) override {}
void setColor(ColorVal) override {}
void setCursor(int, int) override {}
void print(const char* str) override { printed = str; }
void fillRect(int, int, int, int) override {}
void drawRect(int, int, int, int) override {}
void drawXbm(int, int, const uint8_t*, int, int) override {}
uint16_t getTextWidth(const char* str) override {
uint16_t width = 0;
for (size_t i = 0; str[i] != 0; ++i) {
if (((uint8_t)str[i] & 0xC0) != 0x80) ++width;
}
return width;
}
void endFrame() override {}
};
} // namespace
TEST(DisplayDriver, EllipsizesOnlyAtUTF8CodepointBoundaries) {
TestDisplay display;
display.drawTextEllipsized(0, 0, 5, "AB\xF0\x9F\x98\x80" "CDE");
EXPECT_EQ("AB...", display.printed);
EXPECT_TRUE(isValidUTF8(display.printed.c_str()));
}
TEST(DisplayDriver, FixedBufferDoesNotSplitUTF8Codepoint) {
TestDisplay display;
std::string text(254, 'A');
text += "\xF0\x9F\x98\x80";
text += 'B';
display.drawTextEllipsized(0, 0, 255, text.c_str());
EXPECT_EQ(std::string(254, 'A'), display.printed);
EXPECT_TRUE(isValidUTF8(display.printed.c_str()));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,131 @@
#include <gtest/gtest.h>
#include <helpers/ui/EmojiFontMap.h>
#include <stdint.h>
#include <string.h>
#include <vector>
using mesh::ui::EmojiFontMap;
namespace {
void append16(std::vector<uint8_t>& data, uint16_t value) {
data.push_back((uint8_t)value);
data.push_back((uint8_t)(value >> 8));
}
void append32(std::vector<uint8_t>& data, uint32_t value) {
data.push_back((uint8_t)value);
data.push_back((uint8_t)(value >> 8));
data.push_back((uint8_t)(value >> 16));
data.push_back((uint8_t)(value >> 24));
}
uint32_t crc32(const uint8_t* data, size_t size) {
uint32_t crc = 0xFFFFFFFFUL;
for (size_t i = 0; i < size; ++i) {
crc ^= data[i];
for (uint8_t bit = 0; bit < 8; ++bit) {
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
}
}
return ~crc;
}
std::vector<uint8_t> sampleFont() {
std::vector<uint8_t> font(32, 0x55);
const uint32_t map_offset = font.size();
std::vector<uint8_t> map;
map.insert(map.end(), {'E', 'M', '0', '1'});
append16(map, 6); // nodes
append16(map, 5); // edges
append32(map, 0);
// Root: F0 -> 1
append16(map, 0); append16(map, 1); append16(map, 0);
// F0: 9F -> 2
append16(map, 1); append16(map, 1); append16(map, 0);
// F0 9F: 98 -> 3
append16(map, 2); append16(map, 1); append16(map, 0);
// F0 9F 98: 80 -> 4
append16(map, 3); append16(map, 1); append16(map, 0);
// Grinning face, also prefix of the test sequence below.
append16(map, 4); append16(map, 1); append16(map, 0xE000);
// Grinning face followed by '!'.
append16(map, 5); append16(map, 0); append16(map, 0xE001);
map.push_back(0xF0); append16(map, 1);
map.push_back(0x9F); append16(map, 2);
map.push_back(0x98); append16(map, 3);
map.push_back(0x80); append16(map, 4);
map.push_back('!'); append16(map, 5);
font.insert(font.end(), map.begin(), map.end());
font.insert(font.end(), {'M', 'C', 'E', 'M', 'A', 'P', '1', 0});
append32(font, map_offset);
append32(font, map.size());
append32(font, crc32(map.data(), map.size()));
append32(font, 0);
return font;
}
std::vector<uint8_t> sampleVersion2Font() {
std::vector<uint8_t> font = sampleFont();
font.resize(font.size() - 24);
const uint32_t map_offset = 32;
const uint32_t map_size = font.size() - map_offset;
const uint32_t map_crc = crc32(font.data() + map_offset, map_size);
font.insert(font.end(), {'M', 'C', 'E', 'M', 'A', 'P', '2', 0});
append32(font, map_offset);
append32(font, map_size);
append32(font, map_crc);
for (int i = 0; i < 5; ++i) append32(font, 0);
return font;
}
} // namespace
TEST(EmojiFontMap, UsesLongestSequenceMatch) {
std::vector<uint8_t> font = sampleFont();
EmojiFontMap map;
ASSERT_TRUE(map.begin(font.data(), font.size()));
const uint8_t text[] = {0xF0, 0x9F, 0x98, 0x80, '!', 'x'};
uint16_t output = 0;
EXPECT_EQ(5U, map.longestMatch(text, sizeof(text), output));
EXPECT_EQ(0xE001, output);
EXPECT_EQ(4U, map.longestMatch(text, 4, output));
EXPECT_EQ(0xE000, output);
}
TEST(EmojiFontMap, RejectsCorruptMappingData) {
std::vector<uint8_t> font = sampleFont();
font[40] ^= 0x80;
EmojiFontMap map;
EXPECT_FALSE(map.begin(font.data(), font.size()));
EXPECT_FALSE(map.isReady());
}
TEST(EmojiFontMap, RejectsMissingFooter) {
uint8_t data[64] = {};
EmojiFontMap map;
EXPECT_FALSE(map.begin(data, sizeof(data)));
}
TEST(EmojiFontMap, AcceptsVersion2Footer) {
std::vector<uint8_t> font = sampleVersion2Font();
EmojiFontMap map;
ASSERT_TRUE(map.begin(font.data(), font.size()));
const uint8_t text[] = {0xF0, 0x9F, 0x98, 0x80};
uint16_t output = 0;
EXPECT_EQ(sizeof(text), map.longestMatch(text, sizeof(text), output));
EXPECT_EQ(0xE000, output);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+181
View File
@@ -0,0 +1,181 @@
#include <gtest/gtest.h>
#include <helpers/ui/TouchInput.h>
using mesh::ui::TouchAction;
using mesh::ui::TouchInput;
namespace {
TouchAction release(TouchInput& input, int width = 137, int height = 137) {
EXPECT_EQ(input.update(false, -1, -1, width, height), TouchAction::None);
return input.update(false, -1, -1, width, height);
}
} // namespace
TEST(TouchInput, EmitsOnlyAfterRelease) {
TouchInput input;
EXPECT_EQ(input.update(true, 70, 50, 137, 137), TouchAction::None);
EXPECT_EQ(input.update(true, 72, 51, 137, 137), TouchAction::None);
EXPECT_EQ(release(input), TouchAction::Select);
EXPECT_EQ(input.update(false, -1, -1, 137, 137), TouchAction::None);
}
TEST(TouchInput, MapsTapZonesToAllActions) {
TouchInput input;
input.update(true, 10, 60, 137, 137);
input.update(true, 10, 60, 137, 137);
EXPECT_EQ(release(input), TouchAction::Previous);
input.update(true, 68, 60, 137, 137);
input.update(true, 68, 60, 137, 137);
EXPECT_EQ(release(input), TouchAction::Select);
input.update(true, 125, 60, 137, 137);
input.update(true, 125, 60, 137, 137);
EXPECT_EQ(release(input), TouchAction::Next);
}
TEST(TouchInput, CanUseAWiderCenterTapZone) {
TouchInput input(false, false, 70);
input.update(true, 25, 60, 137, 137);
input.update(true, 25, 60, 137, 137);
EXPECT_EQ(release(input), TouchAction::Select);
input.update(true, 10, 60, 137, 137);
input.update(true, 10, 60, 137, 137);
EXPECT_EQ(release(input), TouchAction::Previous);
input.update(true, 127, 60, 137, 137);
input.update(true, 127, 60, 137, 137);
EXPECT_EQ(release(input), TouchAction::Next);
}
TEST(TouchInput, MapsHorizontalSwipesToPageNavigation) {
TouchInput input;
input.update(true, 110, 60, 137, 137);
input.update(true, 40, 62, 137, 137);
EXPECT_EQ(release(input), TouchAction::Next);
input.update(true, 30, 60, 137, 137);
input.update(true, 105, 58, 137, 137);
EXPECT_EQ(release(input), TouchAction::Previous);
}
TEST(TouchInput, MapsVerticalSwipesToPageNavigation) {
TouchInput input;
input.update(true, 60, 110, 137, 137);
input.update(true, 62, 35, 137, 137);
EXPECT_EQ(release(input), TouchAction::Next);
input.update(true, 60, 25, 137, 137);
input.update(true, 58, 105, 137, 137);
EXPECT_EQ(release(input), TouchAction::Previous);
}
TEST(TouchInput, CanRouteVerticalSwipesToASeparateSelector) {
TouchInput input(true, true);
input.update(true, 60, 110, 137, 137);
input.update(true, 62, 35, 137, 137);
EXPECT_EQ(release(input), TouchAction::VerticalPrevious);
input.update(true, 60, 25, 137, 137);
input.update(true, 58, 105, 137, 137);
EXPECT_EQ(release(input), TouchAction::VerticalNext);
input.update(true, 110, 60, 137, 137);
input.update(true, 40, 62, 137, 137);
EXPECT_EQ(release(input), TouchAction::Previous);
}
TEST(TouchInput, BottomSelectorHasLargeArrowTapTargets) {
TouchInput input(true, true, 70);
input.update(true, 15, 125, 137, 137, true);
input.update(true, 15, 125, 137, 137, true);
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
TouchAction::None);
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
TouchAction::VerticalPrevious);
input.update(true, 122, 125, 137, 137, true);
input.update(true, 122, 125, 137, 137, true);
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
TouchAction::None);
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
TouchAction::VerticalNext);
input.update(true, 68, 125, 137, 137, true);
input.update(true, 68, 125, 137, 137, true);
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
TouchAction::None);
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
TouchAction::None);
}
TEST(TouchInput, CanReverseSwipesWithoutReversingTapZones) {
TouchInput input(true);
input.update(true, 110, 60, 137, 137);
input.update(true, 40, 62, 137, 137);
EXPECT_EQ(release(input), TouchAction::Previous);
input.update(true, 30, 60, 137, 137);
input.update(true, 105, 58, 137, 137);
EXPECT_EQ(release(input), TouchAction::Next);
input.update(true, 10, 60, 137, 137);
input.update(true, 10, 60, 137, 137);
EXPECT_EQ(release(input), TouchAction::Previous);
input.update(true, 125, 60, 137, 137);
input.update(true, 125, 60, 137, 137);
EXPECT_EQ(release(input), TouchAction::Next);
}
TEST(TouchInput, KeepsGestureAcrossOneMissingTouchSample) {
TouchInput input;
input.update(true, 110, 60, 137, 137);
input.update(true, 80, 61, 137, 137);
EXPECT_EQ(input.update(false, -1, -1, 137, 137), TouchAction::None);
input.update(true, 40, 62, 137, 137);
EXPECT_EQ(release(input), TouchAction::Next);
}
TEST(TouchInput, IgnoresContactWithNoDirectionSample) {
TouchInput input;
input.update(true, 120, 60, 137, 137);
EXPECT_EQ(release(input), TouchAction::None);
}
TEST(TouchInput, UsesStartZoneForShortAmbiguousMovement) {
TouchInput input(true);
input.update(true, 40, 60, 137, 137);
input.update(true, 52, 60, 137, 137);
EXPECT_EQ(release(input), TouchAction::Previous);
}
TEST(TouchInput, RepeatedSwipesKeepTheSameDirection) {
TouchInput input(true);
for (int attempt = 0; attempt < 10; ++attempt) {
input.update(true, 110, 60, 137, 137);
input.update(true, 72, 61, 137, 137);
input.update(true, 35, 62, 137, 137);
EXPECT_EQ(release(input), TouchAction::Previous);
}
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+94
View File
@@ -0,0 +1,94 @@
Copyright 2013 Google LLC
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font Software,
subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+35
View File
@@ -0,0 +1,35 @@
# SenseCAP Indicator SD font
`generate_font.py` creates the native-resolution font used by the SenseCAP
Indicator. Text is Noto Sans Mono Bold rendered into an 18x24 monospaced cell.
Every text pixel is binary (`0` or `255`), so the font remains sharp and has no
antialiasing.
The complete Unicode Emoji 17.0 RGI set is rendered from Noto Color Emoji into
12x12 RGB332 cells. Transparency is binary, and each cell is enlarged 2x with
nearest-neighbor scaling for a crisp 24x24 result on the 480x480 panel. Emoji
sequences are mapped into the BMP private-use range by a trie appended to the
font file because the display library's runtime font interface accepts 16-bit
glyph identifiers. The private-use glyph is intercepted at draw time and the
corresponding color cell is overlaid after the UI canvas is copied.
On later UI refreshes, unchanged emoji pixels are excluded from the indexed
canvas transfer. This preserves the already-presented color cell and avoids a
visible fallback-to-color flash.
The generated `ui-font.vlw` is stored on the Indicator's SD card. The RP2040
font service streams it to the ESP32-S3 at boot; the application falls back to
its built-in font if the card, service, file, checksum, or mapping is invalid.
To reproduce the checked-in asset:
```bash
python3 -m venv /tmp/meshcore-font-venv
/tmp/meshcore-font-venv/bin/pip install -r tools/sensecap_indicator_font/requirements.txt
/tmp/meshcore-font-venv/bin/python tools/sensecap_indicator_font/generate_font.py \
--output variants/sensecap_indicator-espnow/sd/ui-font.vlw \
--preview /tmp/meshcore-indicator-font-preview.png
```
The source fonts are licensed under the SIL Open Font License 1.1; see
`OFL-1.1.txt`. The Unicode data source, pinned color-font revision, and exact
input hashes are recorded in the generated JSON manifest.
@@ -0,0 +1,459 @@
#!/usr/bin/env python3
"""Build the Indicator's native-resolution text and color emoji font."""
from __future__ import annotations
import argparse
import hashlib
import json
import struct
import urllib.request
import zlib
from dataclasses import dataclass, field
from pathlib import Path
from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw, ImageFont
SOURCES = {
"NotoColorEmoji.ttf": (
"https://raw.githubusercontent.com/googlefonts/noto-emoji/"
"8998f5dd683424a73e2314a8c1f1e359c19e8742/fonts/"
"NotoColorEmoji.ttf",
"72a635cb3d2f3524c51620cdde406b217204e8a6a06c6a096ff8ed4b5fd6e27b",
),
"NotoSansMono-wdth-wght.ttf": (
"https://raw.githubusercontent.com/google/fonts/main/ofl/notosansmono/"
"NotoSansMono%5Bwdth%2Cwght%5D.ttf",
"2cb2adb378a8f574213e23df697050b83c54c27df465a2015552740b2769a081",
),
"emoji-test-17.0.txt": (
"https://www.unicode.org/Public/17.0.0/emoji/emoji-test.txt",
"1d8a944f88d7952f7ef7c5167fef3c67995bcae24543949710231b03a201acda",
),
}
TEXT_CELL_WIDTH = 18
EMOJI_CELL_WIDTH = 12
CELL_HEIGHT = 24
EMOJI_CELL_HEIGHT = 12
BASELINE = 18
TEXT_NATIVE_SCALE = 3
FIRST_EMOJI_GLYPH = 0xE000
LAST_EMOJI_GLYPH = 0xF8FF
FOOTER_MAGIC = b"MCEMAP2\0"
ATLAS_MAGIC = b"CE01"
TRANSPARENT_RGB332 = 0xE3
PIXEL_OFF = 0
PIXEL_ON = 255
TEXT_THRESHOLD = 96
EMOJI_ALPHA_THRESHOLD = 48
EMOJI_REGRESSION_MIN_ON_PIXELS = {
"\U0001f44b": 60, # waving hand
"\U0001f642": 70, # slightly smiling face
"\U0001f60a": 70, # smiling face with smiling eyes
"\U0001f44d": 60, # thumbs up
}
def digest(path: Path) -> str:
sha = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
sha.update(block)
return sha.hexdigest()
def fetch_sources(cache_dir: Path) -> dict[str, Path]:
cache_dir.mkdir(parents=True, exist_ok=True)
paths: dict[str, Path] = {}
for name, (url, expected_sha) in SOURCES.items():
path = cache_dir / name
if not path.exists() or digest(path) != expected_sha:
print(f"Downloading {name}")
with urllib.request.urlopen(url, timeout=60) as response:
contents = response.read()
if hashlib.sha256(contents).hexdigest() != expected_sha:
raise RuntimeError(f"source checksum mismatch: {name}")
path.write_bytes(contents)
paths[name] = path
return paths
def parse_emoji_test(path: Path) -> tuple[list[tuple[str, int, str]], dict[bytes, int]]:
glyphs: list[tuple[str, int, str]] = []
aliases: dict[bytes, int] = {}
last_output = 0
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
data, comment = line.split("#", 1)
codepoints, status = data.split(";", 1)
sequence = "".join(chr(int(value, 16)) for value in codepoints.split())
status = status.strip()
description_fields = comment.strip().split(" ", 2)
description = description_fields[2] if len(description_fields) == 3 else sequence
encoded = sequence.encode("utf-8")
if status in ("fully-qualified", "component"):
output = FIRST_EMOJI_GLYPH + len(glyphs)
if output > LAST_EMOJI_GLYPH:
raise RuntimeError("emoji glyphs exceed the BMP private-use range")
glyphs.append((sequence, output, description))
aliases[encoded] = output
last_output = output
elif status in ("minimally-qualified", "unqualified"):
if last_output == 0:
raise RuntimeError("emoji qualification alias has no canonical glyph")
aliases[encoded] = last_output
# Accept clients that omit the emoji presentation selector even when the
# qualification file does not list that spelling separately.
for sequence, output, _ in glyphs:
without_vs16 = sequence.replace("\ufe0f", "").encode("utf-8")
aliases.setdefault(without_vs16, output)
return glyphs, aliases
def text_codepoints(font_path: Path) -> list[int]:
font = TTFont(font_path, lazy=True)
supported: set[int] = set()
for table in font["cmap"].tables:
if table.isUnicode():
supported.update(table.cmap)
font.close()
ranges = (
(0x21, 0x7E), # ASCII (space is encoded as NBSP at runtime)
(0x00A0, 0x024F), # Latin and phonetic extensions
(0x0370, 0x052F), # Greek and Cyrillic
(0x2000, 0x206F), # punctuation and format marks
(0x20A0, 0x20CF), # currency
(0x2100, 0x214F), # letter-like symbols
(0x2190, 0x21FF), # arrows
(0x2500, 0x259F), # box drawing, blocks, and geometric fill
)
return [
codepoint
for first, last in ranges
for codepoint in range(first, last + 1)
if codepoint in supported and not 0xE000 <= codepoint <= 0xF8FF
]
def make_binary(image: Image.Image, threshold: int) -> Image.Image:
return image.point(
lambda value: PIXEL_ON if value >= threshold else PIXEL_OFF,
mode="L",
)
def render_text_glyph(font: ImageFont.FreeTypeFont, codepoint: int) -> bytes:
image = Image.new("L", (TEXT_CELL_WIDTH, CELL_HEIGHT), 0)
draw = ImageDraw.Draw(image)
draw.text(
(TEXT_CELL_WIDTH // 2, BASELINE - 1),
chr(codepoint),
font=font,
fill=255,
anchor="ms",
)
return make_binary(image, TEXT_THRESHOLD).tobytes()
def render_empty_emoji_fallback(sequence: str) -> Image.Image:
image = Image.new(
"RGBA", (EMOJI_CELL_WIDTH, EMOJI_CELL_HEIGHT), (0, 0, 0, 0)
)
if sequence == "\u2796":
ImageDraw.Draw(image).line(
(
1,
EMOJI_CELL_HEIGHT // 2,
EMOJI_CELL_WIDTH - 2,
EMOJI_CELL_HEIGHT // 2,
),
fill=(50, 120, 220, 255),
width=2,
)
return image
raise RuntimeError(f"emoji rendered blank: {sequence!r}")
def render_emoji_image(font: ImageFont.FreeTypeFont, sequence: str) -> Image.Image:
bbox = font.getbbox(sequence, anchor="lt")
if bbox is None or bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
return render_empty_emoji_fallback(sequence)
source = Image.new(
"RGBA", (bbox[2] - bbox[0], bbox[3] - bbox[1]), (0, 0, 0, 0)
)
ImageDraw.Draw(source).text(
(-bbox[0], -bbox[1]),
sequence,
font=font,
embedded_color=True,
)
cropped_bbox = source.getchannel("A").getbbox()
if cropped_bbox is None:
return render_empty_emoji_fallback(sequence)
source = source.crop(cropped_bbox)
scale = min(
EMOJI_CELL_WIDTH / source.width,
EMOJI_CELL_HEIGHT / source.height,
)
width = max(1, round(source.width * scale))
height = max(1, round(source.height * scale))
source = source.resize((width, height), Image.Resampling.LANCZOS)
image = Image.new(
"RGBA", (EMOJI_CELL_WIDTH, EMOJI_CELL_HEIGHT), (0, 0, 0, 0)
)
image.paste(
source,
(
(EMOJI_CELL_WIDTH - width) // 2,
(EMOJI_CELL_HEIGHT - height) // 2,
),
source,
)
if image.getchannel("A").getbbox() is None:
return render_empty_emoji_fallback(sequence)
return image
def pack_rgb332(image: Image.Image) -> bytes:
packed = bytearray()
for red, green, blue, alpha in image.getdata():
if alpha < EMOJI_ALPHA_THRESHOLD:
packed.append(TRANSPARENT_RGB332)
continue
value = (red & 0xE0) | ((green >> 3) & 0x1C) | (blue >> 6)
if value == TRANSPARENT_RGB332:
value ^= 1
packed.append(value)
return bytes(packed)
@dataclass
class TrieNode:
children: dict[int, "TrieNode"] = field(default_factory=dict)
output: int = 0
index: int = 0
def build_map(aliases: dict[bytes, int]) -> bytes:
root = TrieNode()
for sequence, output in sorted(aliases.items()):
node = root
for value in sequence:
node = node.children.setdefault(value, TrieNode())
if node.output not in (0, output):
raise RuntimeError("conflicting emoji aliases")
node.output = output
nodes: list[TrieNode] = [root]
for node in nodes:
for _, child in sorted(node.children.items()):
if child.index == 0 and child is not root:
child.index = len(nodes)
nodes.append(child)
if len(nodes) > 0xFFFF:
raise RuntimeError("emoji trie has too many nodes")
node_records = bytearray()
edge_records = bytearray()
edge_index = 0
for node in nodes:
children = sorted(node.children.items())
node_records += struct.pack("<HHH", edge_index, len(children), node.output)
for value, child in children:
edge_records += struct.pack("<BH", value, child.index)
edge_index += len(children)
if edge_index > 0xFFFF:
raise RuntimeError("emoji trie has too many edges")
return (
b"EM01"
+ struct.pack("<HHI", len(nodes), edge_index, 0)
+ node_records
+ edge_records
)
def glyph_record(codepoint: int, width: int) -> bytes:
return struct.pack(
">7I",
codepoint,
CELL_HEIGHT,
width,
width,
BASELINE,
0,
0,
)
def rgb332_to_rgba(value: int) -> tuple[int, int, int, int]:
if value == TRANSPARENT_RGB332:
return (0, 0, 0, 0)
red = (value >> 5) & 7
green = (value >> 2) & 7
blue = value & 3
return (
(red * 255 + 3) // 7,
(green * 255 + 3) // 7,
(blue * 255 + 1) // 3,
255,
)
def make_preview(path: Path, emoji_bitmaps: list[tuple[str, bytes]]) -> None:
scale = 6
columns = 16
rows = (len(emoji_bitmaps) + columns - 1) // columns
preview = Image.new(
"RGBA",
(
columns * EMOJI_CELL_WIDTH * scale,
rows * EMOJI_CELL_HEIGHT * scale,
),
(28, 28, 28, 255),
)
for index, (_, bitmap) in enumerate(emoji_bitmaps):
glyph = Image.new("RGBA", (EMOJI_CELL_WIDTH, EMOJI_CELL_HEIGHT))
glyph.putdata([rgb332_to_rgba(value) for value in bitmap])
glyph = glyph.resize(
(EMOJI_CELL_WIDTH * scale, EMOJI_CELL_HEIGHT * scale),
Image.Resampling.NEAREST,
)
x = (index % columns) * EMOJI_CELL_WIDTH * scale
y = (index // columns) * EMOJI_CELL_HEIGHT * scale
preview.alpha_composite(glyph, (x, y))
preview.save(path)
def build_font(output: Path, cache_dir: Path, preview: Path | None) -> None:
sources = fetch_sources(cache_dir)
emoji_glyphs, aliases = parse_emoji_test(sources["emoji-test-17.0.txt"])
text_points = text_codepoints(sources["NotoSansMono-wdth-wght.ttf"])
text_font = ImageFont.truetype(
str(sources["NotoSansMono-wdth-wght.ttf"]), 20
)
text_font.set_variation_by_name("Bold")
emoji_font = ImageFont.truetype(str(sources["NotoColorEmoji.ttf"]), 109)
glyphs: list[tuple[int, int, bytes]] = [
(
codepoint,
TEXT_CELL_WIDTH,
render_text_glyph(text_font, codepoint),
)
for codepoint in text_points
]
rendered_emoji: list[tuple[str, bytes]] = []
color_emoji: list[bytes] = []
for index, (sequence, _, _) in enumerate(emoji_glyphs, start=1):
bitmap = pack_rgb332(render_emoji_image(emoji_font, sequence))
minimum_pixels = EMOJI_REGRESSION_MIN_ON_PIXELS.get(sequence)
if minimum_pixels is not None:
on_pixels = sum(pixel != TRANSPARENT_RGB332 for pixel in bitmap)
if on_pixels < minimum_pixels:
raise RuntimeError(
f"emoji lost identifying pixels: {sequence!r} "
f"has {on_pixels}, expected at least {minimum_pixels}"
)
color_emoji.append(bitmap)
if preview is not None and (index <= 128 or index % 97 == 0):
rendered_emoji.append((sequence, bitmap))
if index % 500 == 0:
print(f"Rendered {index}/{len(emoji_glyphs)} emoji")
if any(
pixel not in (PIXEL_OFF, PIXEL_ON)
for _, _, bitmap in glyphs
for pixel in bitmap
):
raise RuntimeError("font contains antialiased pixels")
glyphs.sort(key=lambda item: item[0])
header = struct.pack(">6I", len(glyphs), 11, CELL_HEIGHT, 0, BASELINE, 1)
records = b"".join(
glyph_record(codepoint, width) for codepoint, width, _ in glyphs
)
bitmaps = b"".join(bitmap for _, _, bitmap in glyphs)
mapping = build_map(aliases)
map_offset = len(header) + len(records) + len(bitmaps)
atlas_header = ATLAS_MAGIC + struct.pack(
"<HBBBBH",
len(color_emoji),
EMOJI_CELL_WIDTH,
EMOJI_CELL_HEIGHT,
TRANSPARENT_RGB332,
TEXT_NATIVE_SCALE,
EMOJI_CELL_WIDTH * EMOJI_CELL_HEIGHT,
)
atlas = atlas_header + b"".join(color_emoji)
atlas_offset = map_offset + len(mapping)
footer = FOOTER_MAGIC + struct.pack(
"<IIIIIIII",
map_offset,
len(mapping),
zlib.crc32(mapping),
atlas_offset,
len(atlas),
zlib.crc32(atlas),
0,
0,
)
contents = header + records + bitmaps + mapping + atlas + footer
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(contents)
manifest = {
"format": "MeshCore SD UI font 2",
"text_cell": [TEXT_CELL_WIDTH, CELL_HEIGHT],
"emoji_cell": [EMOJI_CELL_WIDTH, EMOJI_CELL_HEIGHT],
"pixel_font": True,
"antialiasing": False,
"text_pixel_values": [PIXEL_OFF, PIXEL_ON],
"emoji_encoding": {
"color": "RGB332",
"transparent_key": TRANSPARENT_RGB332,
"alpha_threshold": EMOJI_ALPHA_THRESHOLD,
},
"text_source": "Noto Sans Mono Bold, binary-thresholded",
"text_native_scale": TEXT_NATIVE_SCALE,
"unicode_emoji_version": "17.0",
"text_glyphs": len(text_points),
"emoji_glyphs": len(emoji_glyphs),
"emoji_aliases": len(aliases),
"trie_bytes": len(mapping),
"color_atlas_bytes": len(atlas),
"font_bytes": len(contents),
"sha256": hashlib.sha256(contents).hexdigest(),
"sources": {name: expected for name, (_, expected) in SOURCES.items()},
}
output.with_suffix(output.suffix + ".json").write_text(
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
)
if preview is not None:
make_preview(preview, rendered_emoji)
print(json.dumps(manifest, indent=2))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, required=True)
parser.add_argument(
"--cache-dir",
type=Path,
default=Path.home() / ".cache" / "meshcore-indicator-font",
)
parser.add_argument("--preview", type=Path)
args = parser.parse_args()
build_font(args.output, args.cache_dir, args.preview)
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
fonttools==4.59.1
Pillow==10.2.0
+33
View File
@@ -0,0 +1,33 @@
# SenseCAP Indicator SD font service
The Indicator connects its SD card to the RP2040 rather than the ESP32-S3.
This small RP2040 image owns the card and streams `/meshcore/ui-font.vlw` to
the main application over the internal UART at boot. The ESP32-S3 verifies the
whole-file CRC and falls back to its built-in font if the service, card, file,
or emoji map is unavailable or invalid.
Build and flash the service through the RP2040 USB connector:
```bash
pio run -d tools/sensecap_indicator_rp2040
pio run -d tools/sensecap_indicator_rp2040 -t upload --upload-port /dev/ttyACM2
```
Then upload the generated font to the SD card through that same USB serial
port. The update is checksummed and uses temporary and backup files so an
interrupted upload keeps the last complete font:
```bash
python3 -m pip install pyserial
python3 tools/sensecap_indicator_rp2040/upload_font.py --port /dev/ttyACM2
```
The checked-in asset contains binary 18x24 Noto Sans Mono Bold text and a
12x12 RGB332 Unicode Emoji 17.0 color atlas. Text and emoji transparency have
no antialiasing; the ESP32-S3 renders the asset at the panel's native 480x480
resolution. See
`../sensecap_indicator_font/README.md` to regenerate the asset.
For diagnostics, send `MCFONT STATUS` over RP2040 USB serial. The response
reports internal-UART INFO requests, GET attempts, completed streams, and
bytes sent since the RP2040 last booted.
@@ -0,0 +1,13 @@
[platformio]
default_envs = sensecap_indicator_rp2040_font
src_dir = src
[env:sensecap_indicator_rp2040_font]
platform = https://github.com/maxgerhardt/platform-raspberrypi.git#4e22a0dcdd94eead72fdf56ea59a3d7c8aa2f379
board = seeed_indicator_rp2040
framework = arduino
monitor_speed = 115200
upload_protocol = picotool
build_flags =
-Wall
-Wextra
@@ -0,0 +1,385 @@
#include <Arduino.h>
#include <SD.h>
#include <SPI.h>
namespace {
constexpr uint32_t FONT_UART_BAUD = 1000000;
constexpr uint32_t SD_CLOCK_HZ = 1000000;
constexpr uint32_t RECEIVE_IDLE_TIMEOUT_MS = 10000;
constexpr size_t MAX_FONT_BYTES = 1536 * 1024;
constexpr int FONT_UART_TX = 16;
constexpr int FONT_UART_RX = 17;
constexpr int SD_SCK = 10;
constexpr int SD_MOSI = 11;
constexpr int SD_MISO = 12;
constexpr int SD_CS = 13;
constexpr const char* FONT_DIRECTORY = "/meshcore";
constexpr const char* FONT_PATH = "/meshcore/ui-font.vlw";
constexpr const char* FONT_META_PATH = "/meshcore/ui-font.meta";
constexpr const char* TEMP_FONT_PATH = "/meshcore/ui-font.tmp";
constexpr const char* TEMP_META_PATH = "/meshcore/ui-font-meta.tmp";
constexpr const char* BACKUP_FONT_PATH = "/meshcore/ui-font.bak";
constexpr const char* BACKUP_META_PATH = "/meshcore/ui-font-meta.bak";
bool sdReady = false;
size_t fontSize = 0;
uint32_t fontCrc = 0;
uint32_t espInfoRequests = 0;
uint32_t espGetAttempts = 0;
uint32_t espGetCompleted = 0;
size_t espLastBytes = 0;
uint32_t updateCrc32(uint32_t crc, const uint8_t* data, size_t size) {
for (size_t i = 0; i < size; ++i) {
crc ^= data[i];
for (uint8_t bit = 0; bit < 8; ++bit) {
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
}
}
return crc;
}
bool readLine(File& file, char* line, size_t capacity) {
size_t length = 0;
while (file.available()) {
int value = file.read();
if (value < 0) break;
if (value == '\n') {
line[length] = 0;
return true;
}
if (value != '\r' && length + 1 < capacity) {
line[length++] = (char)value;
}
}
line[length] = 0;
return length != 0;
}
bool parseMetadata(const char* line, size_t& size, uint32_t& crc) {
unsigned long parsedSize = 0;
unsigned long parsedCrc = 0;
if (sscanf(line, "MCFONT 1 %lu %lx", &parsedSize, &parsedCrc) != 2
|| parsedSize < 64
|| parsedSize > MAX_FONT_BYTES) {
return false;
}
size = (size_t)parsedSize;
crc = (uint32_t)parsedCrc;
return true;
}
bool validatePair(const char* fontPath, const char* metadataPath,
size_t& size, uint32_t& crc) {
File metadata = SD.open(metadataPath, FILE_READ);
if (!metadata) return false;
char line[64];
bool valid = readLine(metadata, line, sizeof(line))
&& parseMetadata(line, size, crc);
metadata.close();
if (!valid) return false;
File font = SD.open(fontPath, FILE_READ);
if (!font) return false;
valid = font.size() == size;
font.close();
return valid;
}
void removeIfPresent(const char* path) {
if (SD.exists(path)) SD.remove(path);
}
void cleanTransactionFiles() {
removeIfPresent(TEMP_FONT_PATH);
removeIfPresent(TEMP_META_PATH);
removeIfPresent(BACKUP_FONT_PATH);
removeIfPresent(BACKUP_META_PATH);
}
bool promotePair(const char* sourceFont, const char* sourceMetadata) {
removeIfPresent(FONT_PATH);
removeIfPresent(FONT_META_PATH);
if (!SD.rename(sourceFont, FONT_PATH)) return false;
if (SD.rename(sourceMetadata, FONT_META_PATH)) return true;
removeIfPresent(FONT_PATH);
return false;
}
void recoverFontTransaction() {
size_t size;
uint32_t crc;
if (validatePair(FONT_PATH, FONT_META_PATH, size, crc)) {
cleanTransactionFiles();
return;
}
// A reset between the two final renames can leave the new font next to its
// temporary metadata. Complete that transaction when the sizes still agree.
if (validatePair(FONT_PATH, TEMP_META_PATH, size, crc)) {
removeIfPresent(FONT_META_PATH);
if (SD.rename(TEMP_META_PATH, FONT_META_PATH)) {
cleanTransactionFiles();
return;
}
}
if (validatePair(TEMP_FONT_PATH, TEMP_META_PATH, size, crc)
&& promotePair(TEMP_FONT_PATH, TEMP_META_PATH)) {
cleanTransactionFiles();
return;
}
if (validatePair(BACKUP_FONT_PATH, BACKUP_META_PATH, size, crc)
&& promotePair(BACKUP_FONT_PATH, BACKUP_META_PATH)) {
cleanTransactionFiles();
return;
}
removeIfPresent(FONT_PATH);
removeIfPresent(FONT_META_PATH);
cleanTransactionFiles();
}
bool refreshFontInfo() {
fontSize = 0;
fontCrc = 0;
return sdReady && validatePair(FONT_PATH, FONT_META_PATH, fontSize, fontCrc);
}
void sendInfo(Print& output, bool trackEspRequest) {
if (trackEspRequest) ++espInfoRequests;
if (!refreshFontInfo()) {
output.print("MCFONT 0 0 00000000\n");
return;
}
output.printf("MCFONT 1 %lu %08lx\n", (unsigned long)fontSize,
(unsigned long)fontCrc);
}
void sendFont(Print& output, bool trackEspRequest) {
if (trackEspRequest) {
++espGetAttempts;
espLastBytes = 0;
}
if (!refreshFontInfo()) {
output.print("MCFONT 0 0 00000000\n");
return;
}
File font = SD.open(FONT_PATH, FILE_READ);
if (!font) {
output.print("MCFONT 0 0 00000000\n");
return;
}
output.printf("MCFONT 1 %lu %08lx\n", (unsigned long)fontSize,
(unsigned long)fontCrc);
uint8_t buffer[512];
size_t sent = 0;
while (font.available()) {
size_t count = font.read(buffer, sizeof(buffer));
if (count == 0) break;
size_t written = output.write(buffer, count);
sent += written;
if (written != count) break;
}
font.close();
if (trackEspRequest) {
espLastBytes = sent;
if (sent == fontSize) ++espGetCompleted;
}
}
bool writeMetadata(const char* path, size_t size, uint32_t crc) {
removeIfPresent(path);
File metadata = SD.open(path, "w");
if (!metadata) return false;
metadata.printf("MCFONT 1 %lu %08lx\n", (unsigned long)size,
(unsigned long)crc);
metadata.flush();
metadata.close();
return true;
}
bool moveCurrentToBackup() {
removeIfPresent(BACKUP_FONT_PATH);
removeIfPresent(BACKUP_META_PATH);
bool movedFont = false;
if (SD.exists(FONT_PATH)) {
if (!SD.rename(FONT_PATH, BACKUP_FONT_PATH)) return false;
movedFont = true;
}
if (SD.exists(FONT_META_PATH)
&& !SD.rename(FONT_META_PATH, BACKUP_META_PATH)) {
if (movedFont) SD.rename(BACKUP_FONT_PATH, FONT_PATH);
return false;
}
return true;
}
bool installTemporaryPair(size_t expectedSize, uint32_t expectedCrc) {
if (!moveCurrentToBackup()) return false;
if (!SD.rename(TEMP_FONT_PATH, FONT_PATH)) {
recoverFontTransaction();
return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc;
}
if (!SD.rename(TEMP_META_PATH, FONT_META_PATH)) {
recoverFontTransaction();
return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc;
}
cleanTransactionFiles();
return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc;
}
void receiveFont(Stream& input, Print& output, size_t expectedSize,
uint32_t expectedCrc) {
if (!sdReady) {
output.print("ERROR SD\n");
return;
}
removeIfPresent(TEMP_FONT_PATH);
removeIfPresent(TEMP_META_PATH);
File font = SD.open(TEMP_FONT_PATH, "w");
if (!font) {
output.print("ERROR OPEN\n");
return;
}
output.print("READY\n");
uint8_t buffer[512];
size_t received = 0;
uint32_t crc = 0xFFFFFFFFUL;
uint32_t lastProgress = millis();
bool writeFailed = false;
while (received < expectedSize
&& millis() - lastProgress < RECEIVE_IDLE_TIMEOUT_MS) {
int available = input.available();
if (available <= 0) {
delay(1);
continue;
}
size_t count = (size_t)available;
if (count > sizeof(buffer)) count = sizeof(buffer);
if (count > expectedSize - received) count = expectedSize - received;
size_t actual = input.readBytes(buffer, count);
if (actual == 0) continue;
if (font.write(buffer, actual) != actual) {
writeFailed = true;
break;
}
crc = updateCrc32(crc, buffer, actual);
received += actual;
lastProgress = millis();
}
font.flush();
font.close();
if (writeFailed || received != expectedSize || ~crc != expectedCrc) {
removeIfPresent(TEMP_FONT_PATH);
output.print(writeFailed ? "ERROR WRITE\n" : "ERROR CHECKSUM\n");
return;
}
if (!writeMetadata(TEMP_META_PATH, expectedSize, expectedCrc)
|| !installTemporaryPair(expectedSize, expectedCrc)) {
recoverFontTransaction();
output.print("ERROR INSTALL\n");
return;
}
output.print("OK\n");
}
struct CommandReader {
Stream& input;
Print& output;
bool allowUpload;
char line[96] = {};
size_t length = 0;
};
void processCommand(CommandReader& reader) {
if (strcmp(reader.line, "MCFONT INFO") == 0) {
sendInfo(reader.output, !reader.allowUpload);
return;
}
if (strcmp(reader.line, "MCFONT STATUS") == 0) {
reader.output.printf("MCFONT STATUS %lu %lu %lu %lu\n",
(unsigned long)espInfoRequests,
(unsigned long)espGetAttempts,
(unsigned long)espGetCompleted,
(unsigned long)espLastBytes);
return;
}
if (strcmp(reader.line, "MCFONT GET") == 0) {
sendFont(reader.output, !reader.allowUpload);
return;
}
unsigned long size = 0;
unsigned long crc = 0;
if (sscanf(reader.line, "MCFONT PUT %lu %lx", &size, &crc) == 2) {
if (!reader.allowUpload) {
reader.output.print("ERROR READONLY\n");
} else if (size < 64 || size > MAX_FONT_BYTES) {
reader.output.print("ERROR SIZE\n");
} else {
receiveFont(reader.input, reader.output, (size_t)size, (uint32_t)crc);
}
return;
}
reader.output.print("ERROR COMMAND\n");
}
void pollCommands(CommandReader& reader) {
while (reader.input.available()) {
int value = reader.input.read();
if (value < 0) break;
if (value == '\n') {
reader.line[reader.length] = 0;
processCommand(reader);
reader.length = 0;
} else if (value != '\r') {
if (reader.length + 1 < sizeof(reader.line)) {
reader.line[reader.length++] = (char)value;
} else {
reader.length = 0;
}
}
}
}
CommandReader usbCommands = {Serial, Serial, true};
CommandReader espCommands = {Serial1, Serial1, false};
} // namespace
void setup() {
Serial.begin(115200);
Serial1.setRX(FONT_UART_RX);
Serial1.setTX(FONT_UART_TX);
Serial1.setFIFOSize(1024);
Serial1.begin(FONT_UART_BAUD);
SPI1.setSCK(SD_SCK);
SPI1.setTX(SD_MOSI);
SPI1.setRX(SD_MISO);
sdReady = SD.begin(SD_CS, SD_CLOCK_HZ, SPI1);
if (sdReady) {
if (!SD.exists(FONT_DIRECTORY)) SD.mkdir(FONT_DIRECTORY);
recoverFontTransaction();
refreshFontInfo();
}
}
void loop() {
pollCommands(espCommands);
pollCommands(usbCommands);
delay(1);
}
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Upload a checked font asset to the Indicator RP2040 SD service."""
from __future__ import annotations
import argparse
import sys
import time
import zlib
from pathlib import Path
import serial
MAX_FONT_BYTES = 1536 * 1024
def read_line(port: serial.Serial, timeout: float) -> str:
deadline = time.monotonic() + timeout
line = bytearray()
while time.monotonic() < deadline:
value = port.read(1)
if not value:
continue
if value == b"\n":
return line.rstrip(b"\r").decode("ascii", errors="replace")
line += value
raise TimeoutError("font service did not respond")
def upload(port_name: str, font_path: Path, timeout: float) -> None:
data = font_path.read_bytes()
if not 64 <= len(data) <= MAX_FONT_BYTES:
raise ValueError(f"font size must be between 64 and {MAX_FONT_BYTES} bytes")
crc = zlib.crc32(data) & 0xFFFFFFFF
with serial.Serial(
port_name,
115200,
timeout=0.2,
write_timeout=timeout,
) as port:
time.sleep(0.25)
# Terminate any partial command left by an interrupted terminal or
# uploader before beginning the transactional transfer.
port.write(b"\n")
port.flush()
time.sleep(0.05)
port.reset_input_buffer()
port.write(f"MCFONT PUT {len(data)} {crc:08x}\n".encode("ascii"))
port.flush()
response = read_line(port, timeout)
if response != "READY":
raise RuntimeError(f"font service refused upload: {response}")
sent = 0
while sent < len(data):
sent += port.write(data[sent : sent + 4096])
port.flush()
response = read_line(port, timeout)
if response != "OK":
raise RuntimeError(f"font service failed to install font: {response}")
port.write(b"MCFONT INFO\n")
port.flush()
expected = f"MCFONT 1 {len(data)} {crc:08x}"
response = read_line(port, timeout)
if response != expected:
raise RuntimeError(
f"font verification failed: expected {expected!r}, got {response!r}"
)
print(f"Installed {font_path} ({len(data)} bytes, CRC32 {crc:08x})")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--port", required=True, help="RP2040 USB serial port")
parser.add_argument(
"--font",
type=Path,
default=Path("variants/sensecap_indicator-espnow/sd/ui-font.vlw"),
)
parser.add_argument("--timeout", type=float, default=30.0)
args = parser.parse_args()
try:
upload(args.port, args.font, args.timeout)
except (OSError, ValueError, RuntimeError, TimeoutError, serial.SerialException) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,133 @@
#include "IndicatorFontClient.h"
#include <Arduino.h>
#include <esp32-hal-psram.h>
namespace {
static const uint32_t FONT_UART_BAUD = 1000000;
static const int FONT_UART_RX = 20;
static const int FONT_UART_TX = 19;
static const size_t MAX_FONT_BYTES = 1536 * 1024;
uint32_t updateCrc32(uint32_t crc, const uint8_t* data, size_t size) {
for (size_t i = 0; i < size; ++i) {
crc ^= data[i];
for (uint8_t bit = 0; bit < 8; ++bit) {
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
}
}
return crc;
}
bool readLine(HardwareSerial& serial, char* line, size_t capacity,
uint32_t timeout_millis) {
size_t length = 0;
uint32_t started = millis();
while (millis() - started < timeout_millis) {
while (serial.available()) {
int value = serial.read();
if (value < 0) break;
if (value == '\n') {
line[length] = 0;
return true;
}
if (value != '\r' && length + 1 < capacity) {
line[length++] = (char)value;
}
}
delay(1);
}
line[0] = 0;
return false;
}
bool parseInfo(const char* line, size_t& size, uint32_t& crc) {
unsigned long parsed_size = 0;
unsigned long parsed_crc = 0;
int present = 0;
if (sscanf(line, "MCFONT 1 %lu %lx", &parsed_size, &parsed_crc) != 2) {
if (sscanf(line, "MCFONT %d", &present) == 1 && present == 0) return false;
return false;
}
if (parsed_size < 64 || parsed_size > MAX_FONT_BYTES) return false;
size = (size_t)parsed_size;
crc = (uint32_t)parsed_crc;
return true;
}
bool requestInfo(HardwareSerial& serial, size_t& size, uint32_t& crc) {
char line[64];
for (int attempt = 0; attempt < 3; ++attempt) {
while (serial.available()) serial.read();
serial.print("MCFONT INFO\n");
serial.flush();
if (readLine(serial, line, sizeof(line), 1000)
&& parseInfo(line, size, crc)) {
return true;
}
delay(100);
}
return false;
}
bool receiveFont(HardwareSerial& serial, uint8_t* data, size_t size,
uint32_t expected_crc) {
char line[64];
serial.print("MCFONT GET\n");
serial.flush();
size_t response_size;
uint32_t response_crc;
if (!readLine(serial, line, sizeof(line), 1500)
|| !parseInfo(line, response_size, response_crc)
|| response_size != size
|| response_crc != expected_crc) {
return false;
}
size_t received = 0;
uint32_t crc = 0xFFFFFFFFUL;
uint32_t last_progress = millis();
while (received < size && millis() - last_progress < 2000) {
int available = serial.available();
if (available <= 0) {
delay(1);
continue;
}
size_t count = (size_t)available;
if (count > size - received) count = size - received;
size_t actual = serial.readBytes(data + received, count);
if (actual == 0) continue;
crc = updateCrc32(crc, data + received, actual);
received += actual;
last_progress = millis();
}
return received == size && ~crc == expected_crc;
}
} // namespace
uint8_t* IndicatorFontClient::load(size_t& size) {
size = 0;
if (!psramFound()) return nullptr;
Serial2.begin(FONT_UART_BAUD, SERIAL_8N1, FONT_UART_RX, FONT_UART_TX);
delay(50);
uint32_t expected_crc;
if (!requestInfo(Serial2, size, expected_crc)) {
Serial2.end();
size = 0;
return nullptr;
}
uint8_t* data = (uint8_t*)ps_malloc(size);
if (data == nullptr || !receiveFont(Serial2, data, size, expected_crc)) {
free(data);
data = nullptr;
size = 0;
}
Serial2.end();
return data;
}
@@ -0,0 +1,9 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
class IndicatorFontClient {
public:
static uint8_t* load(size_t& size);
};
@@ -0,0 +1,166 @@
#include "IndicatorRadioHal.h"
#ifdef SENSECAP_INDICATOR_LORA
IndicatorRadioHal::IndicatorRadioHal(SPIClass& spi) : ArduinoHal(spi) {}
bool IndicatorRadioHal::isExpanderPin(uint32_t pin) {
return pin != RADIOLIB_NC && (pin & EXPANDER_FLAG) != 0;
}
uint8_t IndicatorRadioHal::expanderIndex(uint32_t pin) {
return static_cast<uint8_t>(pin & ~EXPANDER_FLAG);
}
bool IndicatorRadioHal::readRegister(uint8_t reg, uint16_t& value) {
uint8_t data[2];
if (!lgfx::i2c::readRegister(I2C_PORT, EXPANDER_ADDRESS, reg, data,
sizeof(data), I2C_FREQUENCY)
.has_value()) {
return false;
}
value = data[0];
value |= static_cast<uint16_t>(data[1]) << 8;
return true;
}
bool IndicatorRadioHal::writeRegister(uint8_t reg, uint16_t value) {
uint8_t data[] = {
reg,
static_cast<uint8_t>(value),
static_cast<uint8_t>(value >> 8),
};
return lgfx::i2c::transactionWrite(I2C_PORT, EXPANDER_ADDRESS, data,
sizeof(data), I2C_FREQUENCY)
.has_value();
}
bool IndicatorRadioHal::beginExpander() {
if (_expanderReady) return true;
uint16_t inputs;
if (!readRegister(OUTPUT_REGISTER, _output)
|| !readRegister(CONFIG_REGISTER, _config)
|| !readRegister(INPUT_REGISTER, inputs)) {
// The display normally initializes this shared bus first. Reinitialize it
// here as a fallback when display or touch probing ended early.
if (!lgfx::i2c::init(I2C_PORT, PIN_BOARD_SDA, PIN_BOARD_SCL).has_value()
|| !readRegister(OUTPUT_REGISTER, _output)
|| !readRegister(CONFIG_REGISTER, _config)
|| !readRegister(INPUT_REGISTER, inputs)) {
return false;
}
}
::pinMode(EXPANDER_INTERRUPT_GPIO, INPUT_PULLUP);
_irqLevel = (inputs & (1U << RADIO_IRQ_INDEX)) != 0;
_expanderReady = true;
return true;
}
bool IndicatorRadioHal::readInputs(uint16_t& value) {
if (!beginExpander() || !readRegister(INPUT_REGISTER, value)) return false;
processInterruptLevel(value);
return true;
}
void IndicatorRadioHal::processInterruptLevel(uint16_t inputs) {
bool level = (inputs & (1U << RADIO_IRQ_INDEX)) != 0;
if (level == _irqLevel) return;
_irqLevel = level;
if (_irqCallback == nullptr) return;
if ((_irqMode == GpioInterruptRising && level)
|| (_irqMode == GpioInterruptFalling && !level)) {
_irqCallback();
}
}
void IndicatorRadioHal::serviceInterrupt() {
if (_irqCallback == nullptr || !_expanderReady
|| ::digitalRead(EXPANDER_INTERRUPT_GPIO) != LOW) {
return;
}
uint16_t inputs;
readInputs(inputs);
}
void IndicatorRadioHal::pinMode(uint32_t pin, uint32_t mode) {
if (!isExpanderPin(pin)) {
ArduinoHal::pinMode(pin, mode);
return;
}
uint8_t index = expanderIndex(pin);
if (index >= 16 || !beginExpander()) return;
uint16_t next = _config;
if (mode == GpioModeOutput) {
next &= ~(1U << index);
} else {
next |= 1U << index;
}
if (next != _config && writeRegister(CONFIG_REGISTER, next)) _config = next;
}
void IndicatorRadioHal::digitalWrite(uint32_t pin, uint32_t value) {
if (!isExpanderPin(pin)) {
ArduinoHal::digitalWrite(pin, value);
return;
}
uint8_t index = expanderIndex(pin);
if (index >= 16 || !beginExpander()) return;
uint16_t next = value == GpioLevelHigh ? _output | (1U << index)
: _output & ~(1U << index);
if (next != _output && writeRegister(OUTPUT_REGISTER, next)) _output = next;
}
uint32_t IndicatorRadioHal::digitalRead(uint32_t pin) {
if (!isExpanderPin(pin)) return ArduinoHal::digitalRead(pin);
uint8_t index = expanderIndex(pin);
uint16_t inputs;
if (index >= 16 || !readInputs(inputs)) {
// A failed BUSY read must prevent an unsafe SPI command. Other failed
// reads are treated as inactive.
return pin == P_LORA_BUSY ? GpioLevelHigh : GpioLevelLow;
}
return (inputs & (1U << index)) != 0 ? GpioLevelHigh : GpioLevelLow;
}
void IndicatorRadioHal::attachInterrupt(uint32_t interrupt_num,
void (*callback)(void), uint32_t mode) {
if (!isExpanderPin(interrupt_num)) {
ArduinoHal::attachInterrupt(interrupt_num, callback, mode);
return;
}
if (expanderIndex(interrupt_num) != RADIO_IRQ_INDEX
|| !beginExpander()) {
return;
}
_irqCallback = callback;
_irqMode = mode;
uint16_t inputs;
if (readRegister(INPUT_REGISTER, inputs)) {
_irqLevel = (inputs & (1U << RADIO_IRQ_INDEX)) != 0;
}
}
void IndicatorRadioHal::detachInterrupt(uint32_t interrupt_num) {
if (!isExpanderPin(interrupt_num)) {
ArduinoHal::detachInterrupt(interrupt_num);
return;
}
if (expanderIndex(interrupt_num) == RADIO_IRQ_INDEX) {
_irqCallback = nullptr;
}
}
uint32_t IndicatorRadioHal::pinToInterrupt(uint32_t pin) {
return isExpanderPin(pin) ? pin : ArduinoHal::pinToInterrupt(pin);
}
#endif
@@ -0,0 +1,52 @@
#pragma once
#include <Arduino.h>
#include <SPI.h>
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
#include <hal/Arduino/ArduinoHal.h>
// RadioLib HAL for the Indicator's SX1262. Four radio signals are routed
// through a TCA9535 instead of ordinary ESP32 GPIOs. The expander's shared,
// active-low interrupt output is polled from normal task context so I2C is
// never touched by an ISR.
class IndicatorRadioHal : public ArduinoHal {
public:
explicit IndicatorRadioHal(SPIClass& spi);
bool beginExpander();
void serviceInterrupt();
void pinMode(uint32_t pin, uint32_t mode) override;
void digitalWrite(uint32_t pin, uint32_t value) override;
uint32_t digitalRead(uint32_t pin) override;
void attachInterrupt(uint32_t interrupt_num, void (*callback)(void),
uint32_t mode) override;
void detachInterrupt(uint32_t interrupt_num) override;
uint32_t pinToInterrupt(uint32_t pin) override;
private:
static constexpr uint8_t EXPANDER_ADDRESS = 0x20;
static constexpr uint8_t I2C_PORT = 0;
static constexpr uint32_t I2C_FREQUENCY = 400000;
static constexpr uint32_t EXPANDER_FLAG = 0x40;
static constexpr uint8_t INPUT_REGISTER = 0x00;
static constexpr uint8_t OUTPUT_REGISTER = 0x02;
static constexpr uint8_t CONFIG_REGISTER = 0x06;
static constexpr uint8_t RADIO_IRQ_INDEX = 3;
static constexpr uint8_t EXPANDER_INTERRUPT_GPIO = 42;
bool _expanderReady = false;
uint16_t _output = 0xFFFF;
uint16_t _config = 0xFFFF;
bool _irqLevel = false;
void (*_irqCallback)(void) = nullptr;
uint32_t _irqMode = RISING;
static bool isExpanderPin(uint32_t pin);
static uint8_t expanderIndex(uint32_t pin);
bool readRegister(uint8_t reg, uint16_t& value);
bool writeRegister(uint8_t reg, uint16_t value);
bool readInputs(uint16_t& value);
void processInterruptLevel(uint16_t inputs);
};
@@ -0,0 +1,19 @@
#pragma once
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include "IndicatorRadioHal.h"
class IndicatorSX1262Wrapper : public CustomSX1262Wrapper {
public:
IndicatorSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board,
IndicatorRadioHal& hal)
: CustomSX1262Wrapper(radio, board), _hal(hal) {}
void loop() override {
_hal.serviceInterrupt();
CustomSX1262Wrapper::loop();
}
private:
IndicatorRadioHal& _hal;
};
@@ -0,0 +1,91 @@
#pragma once
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
#include <string.h>
// This FT5x06-compatible controller exposes valid point data at register 0x02,
// but some units return zero for all optional identity registers. Probe the
// point register instead of rejecting those units by vendor ID.
class IndicatorTouch : public lgfx::Touch_FT5x06 {
static constexpr size_t POINT_DATA_SIZE = 5;
bool readPointData(uint8_t* data, size_t size) {
const uint8_t point_count_reg = 0x02;
return lgfx::i2c::transactionWriteRead(
_cfg.i2c_port, _cfg.i2c_addr, &point_count_reg, 1,
data, size, _cfg.freq)
.has_value();
}
bool readStablePointData(uint8_t* data) {
uint8_t samples[2][POINT_DATA_SIZE] = {};
if (!readPointData(samples[0], sizeof(samples[0]))) return false;
if ((samples[0][0] & 0x0F) == 0) {
memcpy(data, samples[0], POINT_DATA_SIZE);
return true;
}
// Coordinates may change while the controller is being read. Match two
// consecutive snapshots, as the standard driver does, so X/Y bytes from
// different positions cannot reverse a gesture. If the finger keeps
// moving through every retry, use the newest complete snapshot.
uint8_t current = 0;
for (uint8_t attempt = 0; attempt < 5; ++attempt) {
const uint8_t next = current ^ 1U;
if (!readPointData(samples[next], sizeof(samples[next]))) return false;
if (memcmp(samples[current], samples[next], POINT_DATA_SIZE) == 0) {
memcpy(data, samples[next], POINT_DATA_SIZE);
return true;
}
current = next;
}
memcpy(data, samples[current], POINT_DATA_SIZE);
return true;
}
public:
bool init() override {
_inited = false;
if (!lgfx::i2c::init(_cfg.i2c_port, _cfg.pin_sda, _cfg.pin_scl)
.has_value()) {
return false;
}
uint8_t point_count = 0;
if (!readPointData(&point_count, 1)) {
return false;
}
// Select normal operation and polling mode, matching the generic driver.
if (!lgfx::i2c::writeRegister8(
_cfg.i2c_port, _cfg.i2c_addr, 0x00, 0x00, 0, _cfg.freq)
.has_value()
|| !lgfx::i2c::writeRegister8(
_cfg.i2c_port, _cfg.i2c_addr, 0xA4, 0x00, 0, _cfg.freq)
.has_value()) {
return false;
}
_inited = true;
return true;
}
uint_fast8_t getTouchRaw(lgfx::touch_point_t* point,
uint_fast8_t count) override {
if (point == nullptr || count == 0 || (!_inited && !init())) return 0;
uint8_t data[POINT_DATA_SIZE] = {};
if (!readStablePointData(data)) {
_inited = false;
return 0;
}
if ((data[0] & 0x0F) == 0) return 0;
point[0].x = ((data[1] & 0x0F) << 8) | data[2];
point[0].y = ((data[3] & 0x0F) << 8) | data[4];
point[0].size = 1;
point[0].id = data[3] >> 4;
return 1;
}
};
@@ -0,0 +1,102 @@
# SenseCAP Indicator controls
The Indicator uses its full 480x480 panel resolution while preserving the
existing 160x160 UI coordinate system. A 4-bit internal canvas avoids PSRAM
contention with RGB scanout; text and icons are rendered directly at 3x detail
instead of enlarging a completed 160x160 frame. The screen blanks after five
minutes on USB power; the hardware button or a touch wakes it without
selecting anything.
## Touch navigation
- Swipe horizontally to move between home pages. The dots under the status bar
show the current page.
- The middle 70% of the panel is the Select tap target. The outer 15% on each
side performs Previous or Next; horizontal swipes remain available anywhere.
- A gesture is committed after a stable release. Brief missing touch samples
are ignored so one swipe cannot become a second, opposite endpoint tap.
## Reading messages
The message preview is a non-destructive local inbox, newest first:
- The first home page shows the retained local `INBOX` count, which remains
useful when a USB host has already drained the protocol's unread count. Tap
the center to open the inbox and channel selector even when the count is 0.
- `Message 1/N` is the newest buffered preview.
- Each preview retains the complete 160-byte maximum chat payload. Long UTF-8
messages wrap through the available middle of the screen instead of being
cut at the small-display 78-byte limit.
- When a USB host drains the radio queue, the displayed preview is retained
for five minutes rather than disappearing immediately.
- Swipe left for an older preview and right for a newer preview. Navigation
stops at the oldest and newest entries instead of wrapping.
- The bottom bar shows the active inbox filter. Its large `<` and `>` end
buttons select the previous or next filter; swipe up or down does the same.
Filters include `All channels`, `Direct`, and configured channels such as
`Ch 1 #testing`. A newly received message automatically selects its source
channel.
- Tap the center to return home without deleting the previews.
- On the first home page, tap the wide center target to reopen the previews,
including when `INBOX` is zero.
Channel messages identify both the configured slot and name, such as
`Ch 0 Public` or `Ch 1 #testing`. The value in brackets after it is the route,
for example `[0h]` for a zero-hop flood or `[direct]` for a direct route.
The bottom selector filters received messages; it does not change the outbound
channel because the device UI does not include a text composer. Select the
outbound channel in the connected application, or list and address channel
slots from the CLI with `get_channels`, `public <message>`, or `chan <slot>
<message>`. Channel 0 is conventionally Public; always use `get_channels`
before assuming the other slot numbers.
## Radio page
The radio page redraws once per second. Noise-floor calibration publishes a
new 64-sample average about every two seconds, so a faster redraw would usually
repeat the same value. The display shows the fractional sample mean to one
decimal place; individual SX1262 instantaneous RSSI samples are still
quantized to 0.5 dB, so the decimals are useful for comparing averages rather
than claiming hundredth-dB measurement accuracy.
## USB + WiFi Companion
Build `SenseCapIndicator-LoRa_comp_radio_usb_wifi` to retain the USB Companion
link and expose the same framed Companion protocol over WiFi TCP port `5000`.
Fresh devices advertise an open `MeshCore-Setup-XXXX` access point, where the
four-character suffix identifies the device; join it and open
`http://192.168.4.1/` to save WiFi credentials. Saved credentials and the WiFi
on/off preference survive application-only reflashes. Setup mode explicitly
restores standard 2.4 GHz b/g/n operation, so a previously installed ESP-NOW
image cannot leave the setup AP hidden behind its long-range protocol setting.
The combined profile uses minimum WiFi modem power saving by default. USB
remains available for recovery and management if the configured network is
unavailable. TCP port `5001` serves host-backed LoRa OTA files, while port
`5002` provides the bounded OTA management console. The USB text terminal also
accepts `get wifi.status`, `get wifi.ssid`, `get wifi.cli`, and
`start webconfig ap`; enter it with `+++MESHCORE-TERM-START` and return to the
framed Companion protocol with `+++MESHCORE-TERM-STOP`.
If a Companion client does not supply device time over USB or WiFi, this build
starts evaluating LoRa time after two minutes. It requires three independent,
agreeing sources from signed adverts or authenticated Public-channel messages;
one repeated sender cannot set the clock alone. A successful Companion or
local CLI time update always wins and disables the LoRa fallback until reboot.
The combined image uses two 2.5 MiB OTA application slots. Its partition map
keeps the prior NVS and SPIFFS addresses, so installing the partition table and
application over an existing Indicator preserves identity, channels, radio
settings, and saved WiFi credentials.
## SD-backed font
The RP2040 streams the checked-in pixel-font asset from the SD card to the
ESP32-S3 at boot. Text is binary 18x24 Noto Sans Mono Bold. Emoji use 12x12
RGB332 color cells with binary transparency and are enlarged 2x with
nearest-neighbor scaling. Periodic status updates preserve unchanged emoji in
the panel framebuffer so the full-color cell is not replaced and redrawn once
per second. See
[`../../tools/sensecap_indicator_rp2040/README.md`](../../tools/sensecap_indicator_rp2040/README.md)
for installation and diagnostics.
@@ -1,6 +1,9 @@
#pragma once
#include <helpers/ui/LGFXDisplay.h>
#include "IndicatorFontClient.h"
#include "IndicatorTouch.h"
#include <driver/gpio.h>
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
@@ -13,7 +16,7 @@ class LGFX : public lgfx::LGFX_Device
lgfx::Panel_ST7701 _panel_instance;
lgfx::Bus_RGB _bus_instance;
lgfx::Light_PWM _light_instance;
lgfx::Touch_FT5x06 _touch_instance;
IndicatorTouch _touch_instance;
public:
const uint16_t screenWidth = 480;
@@ -37,7 +40,8 @@ public:
{
auto cfg = _panel_instance.config_detail();
cfg.pin_cs = 4 | IO_EXPANDER;
// Chip-select is driven through the board's I2C expander below.
cfg.pin_cs = GPIO_NUM_NC;
cfg.pin_sclk = 41;
cfg.pin_mosi = 48;
cfg.use_psram = 1;
@@ -105,8 +109,8 @@ public:
cfg.y_max = 479;
cfg.pin_int = GPIO_NUM_NC;
cfg.pin_rst = GPIO_NUM_NC;
cfg.bus_shared = true;
cfg.offset_rotation = 0;
cfg.bus_shared = false;
cfg.offset_rotation = 2;
cfg.i2c_port = 0;
cfg.i2c_addr = 0x48;
@@ -123,6 +127,131 @@ public:
class SCIndicatorDisplay : public LGFXDisplay {
LGFX disp;
uint8_t expander_address = 0;
uint16_t expander_output = 0;
static constexpr gpio_num_t BACKLIGHT_PIN = GPIO_NUM_45;
static constexpr uint8_t I2C_PORT = 0;
static constexpr uint32_t I2C_FREQUENCY = 400000;
static constexpr uint8_t EXPANDER_OUTPUT_REGISTER = 0x02;
static constexpr uint8_t EXPANDER_CONFIG_REGISTER = 0x06;
static constexpr uint16_t PANEL_CS_MASK = 1U << 4;
static constexpr uint16_t PANEL_RESET_MASK = 1U << 5;
static constexpr uint16_t TOUCH_INTERRUPT_MASK = 1U << 6;
static constexpr uint16_t TOUCH_RESET_MASK = 1U << 7;
static void setBacklight(bool enabled) {
// Detach any stale LEDC routing before changing the level. Reasserting a
// plain GPIO level makes wake reliable even if a previous image left the
// backlight PWM channel stopped or assigned elsewhere.
gpio_reset_pin(BACKLIGHT_PIN);
gpio_set_direction(BACKLIGHT_PIN, GPIO_MODE_OUTPUT);
gpio_set_level(BACKLIGHT_PIN, enabled ? 1 : 0);
}
static bool readExpanderRegister(uint8_t address, uint8_t reg,
uint16_t& value) {
uint8_t data[2];
if (!lgfx::i2c::readRegister(I2C_PORT, address, reg, data, sizeof(data),
I2C_FREQUENCY)
.has_value()) {
return false;
}
value = data[0];
value |= static_cast<uint16_t>(data[1]) << 8;
return true;
}
static bool writeExpanderRegister(uint8_t address, uint8_t reg,
uint16_t value) {
uint8_t data[] = {
reg,
static_cast<uint8_t>(value),
static_cast<uint8_t>(value >> 8),
};
return lgfx::i2c::transactionWrite(I2C_PORT, address, data, sizeof(data),
I2C_FREQUENCY)
.has_value();
}
bool prepareControllersAt(uint8_t address) {
uint16_t config;
if (!readExpanderRegister(address, EXPANDER_OUTPUT_REGISTER,
expander_output)
|| !readExpanderRegister(address, EXPANDER_CONFIG_REGISTER, config)) {
return false;
}
// Set safe output latches before changing pin directions. Keep LCD CS
// inactive while both controllers are held in reset.
expander_output |= PANEL_CS_MASK;
expander_output &= ~(PANEL_RESET_MASK | TOUCH_RESET_MASK);
if (!writeExpanderRegister(address, EXPANDER_OUTPUT_REGISTER,
expander_output)) {
return false;
}
config &= ~(PANEL_CS_MASK | PANEL_RESET_MASK | TOUCH_RESET_MASK);
config |= TOUCH_INTERRUPT_MASK;
if (!writeExpanderRegister(address, EXPANDER_CONFIG_REGISTER, config)) {
return false;
}
delay(10);
expander_output |= PANEL_RESET_MASK | TOUCH_RESET_MASK;
if (!writeExpanderRegister(address, EXPANDER_OUTPUT_REGISTER,
expander_output)) {
return false;
}
delay(120);
// Hold LCD CS active while the graphics driver emits the ST7701 init
// sequence over the shared clock and data pins.
expander_output &= ~PANEL_CS_MASK;
if (!writeExpanderRegister(address, EXPANDER_OUTPUT_REGISTER,
expander_output)) {
return false;
}
expander_address = address;
return true;
}
bool prepareControllers() {
if (!lgfx::i2c::init(I2C_PORT, PIN_BOARD_SDA, PIN_BOARD_SCL).has_value()) {
return false;
}
return prepareControllersAt(0x20) || prepareControllersAt(0x39);
}
bool releasePanelChipSelect() {
if (expander_address == 0) return false;
expander_output |= PANEL_CS_MASK;
return writeExpanderRegister(expander_address, EXPANDER_OUTPUT_REGISTER,
expander_output);
}
public:
SCIndicatorDisplay() : LGFXDisplay(480, 480, disp) {}
bool begin() {
if (!prepareControllers()) return false;
const bool initialized = LGFXDisplay::begin();
const bool released = releasePanelChipSelect();
if (!initialized || !released) return false;
setBacklight(true);
size_t fontSize;
uint8_t* fontData = IndicatorFontClient::load(fontSize);
if (fontData != nullptr) installRuntimeFont(fontData, fontSize);
return true;
}
void turnOn() override {
setBacklight(true);
_isOn = true;
}
void turnOff() override {
setBacklight(false);
_isOn = false;
}
};
@@ -0,0 +1,10 @@
# Two 2.5 MiB OTA slots for the 8 MiB SenseCAP Indicator.
# NVS, SPIFFS, and coredump retain Arduino default.csv's addresses so an
# in-place partition-table migration does not discard configured node data.
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x280000,
spiffs, data, spiffs, 0x290000, 0x160000,
coredump, data, coredump,0x3f0000, 0x10000,
app1, app, ota_1, 0x400000, 0x280000,
1 # Two 2.5 MiB OTA slots for the 8 MiB SenseCAP Indicator.
2 # NVS, SPIFFS, and coredump retain Arduino default.csv's addresses so an
3 # in-place partition-table migration does not discard configured node data.
4 # Name, Type, SubType, Offset, Size, Flags
5 nvs, data, nvs, 0x9000, 0x5000,
6 otadata, data, ota, 0xe000, 0x2000,
7 app0, app, ota_0, 0x10000, 0x280000,
8 spiffs, data, spiffs, 0x290000, 0x160000,
9 coredump, data, coredump,0x3f0000, 0x10000,
10 app1, app, ota_1, 0x400000, 0x280000,
@@ -11,17 +11,41 @@ build_flags =
${esp32_base.build_flags}
-D PIN_BOARD_SDA=39
-D PIN_BOARD_SCL=40
-D BOARD_HAS_PSRAM
-D DISPLAY_CLASS=SCIndicatorDisplay
-D DISPLAY_LINES=21
-D LINE_LENGTH=53
-D DISABLE_WIFI_OTA=1
-D IO_EXPANDER=0x40
-D IO_EXPANDER_IRQ=42
-D UI_ZOOM=3.5
-D DISPLAY_ROTATION=1
; Render at the panel's native 480x480 resolution while preserving the
; established 160x160 UI coordinate system. A 4-bit internal canvas avoids
; competing with the RGB scanout framebuffer in PSRAM.
-D UI_ZOOM=1
-D UI_COORD_SCALE=3
-D UI_BUFFER_COLOR_DEPTH=4
-D AUTO_OFF_MILLIS=300000UL
-D USB_MESSAGE_PREVIEW_MILLIS=300000UL
; Retain every byte of a maximum-length 160-byte chat message. The generic
; 78-byte preview is intended for small displays and left most of this panel
; unused while silently truncating longer text.
-D UI_MSG_PREVIEW_SIZE=161
-D UI_RADIO_REFRESH_MILLIS=1000UL
; Use the configured five-minute timeout literally on this USB-powered LCD.
-D UI_USB_AUTO_OFF_MULTIPLIER=1UL
; GPIO 38 cannot wake the ESP32-S3 from the no-wakeup hibernate path.
; Keep this USB-powered target on and use the display timeout instead.
-D UI_NO_HIBERNATE
-D UI_RECENT_LIST_SIZE=9
-D UI_SENSORS_PAGE=1
-D PIN_USER_BTN=38
-D HAS_TOUCH
-D TOUCH_REVERSE_SWIPE
-D TOUCH_SEPARATE_VERTICAL_SWIPES
; The outer 15% remains available for Previous/Next taps; the middle 70%
; is Select. This makes the primary tap target much easier on a 480px panel.
-D TOUCH_CENTER_ZONE_PERCENT=70
-I variants/sensecap_indicator-espnow
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/sensecap_indicator-espnow/*.cpp>
@@ -32,6 +56,35 @@ lib_deps=${esp32_base.lib_deps}
adafruit/Adafruit BusIO @ ^1.17.2
lovyan03/LovyanGFX @ ^1.2.7
; D1L and D1Pro route the onboard SX1262's control pins through the TCA9535
; expander. Keep the original ESP-NOW target above for Indicators without the
; LoRa hardware, and use this target for the LoRa-capable models.
[SenseCapIndicator-LoRa]
extends = SenseCapIndicator-ESPNow
build_flags =
${SenseCapIndicator-ESPNow.build_flags}
-D SENSECAP_INDICATOR_LORA
-D USE_SX1262
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=IndicatorSX1262Wrapper
-D P_LORA_SCLK=41
-D P_LORA_MISO=47
-D P_LORA_MOSI=48
-D P_LORA_NSS=64
-D P_LORA_RESET=65
-D P_LORA_BUSY=66
-D P_LORA_DIO_1=67
-D SX126X_DIO2_AS_RF_SWITCH=true
-D SX126X_DIO3_TCXO_VOLTAGE=2.4
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
-D LORA_TX_POWER=22
-D MAX_LORA_TX_POWER=22
-D RECOVERABLE_EXTERNAL_RADIO
build_src_filter = ${SenseCapIndicator-ESPNow.build_src_filter}
-<helpers/esp32/ESPNOWRadio.cpp>
lib_deps = ${SenseCapIndicator-ESPNow.lib_deps}
[env:SenseCapIndicator-ESPNow_comp_radio_usb]
extends = SenseCapIndicator-ESPNow
build_flags =
@@ -39,6 +92,7 @@ build_flags =
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D ENABLE_USB_INTERFACE
; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1
; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1
; NOTE: DO NOT ENABLE --> -D ESPNOW_DEBUG_LOGGING=1
@@ -48,3 +102,48 @@ build_src_filter = ${SenseCapIndicator-ESPNow.build_src_filter}
lib_deps =
${SenseCapIndicator-ESPNow.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:SenseCapIndicator-LoRa_comp_radio_usb]
extends = SenseCapIndicator-LoRa
build_flags =
${SenseCapIndicator-LoRa.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D ENABLE_USB_INTERFACE
; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1
; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1
build_src_filter = ${SenseCapIndicator-LoRa.build_src_filter}
+<../examples/companion_radio/ui-new/*.cpp>
+<../examples/companion_radio/*.cpp>
lib_deps =
${SenseCapIndicator-LoRa.lib_deps}
densaugeo/base64 @ ~1.4.0
; Keep the recoverable USB Companion link while also exposing the same framed
; protocol over WiFi TCP port 5000. Placeholder credentials deliberately start
; WebConfig in setup-AP mode on a fresh device; saved NVS credentials win on
; later boots.
[env:SenseCapIndicator-LoRa_comp_radio_usb_wifi]
extends = env:SenseCapIndicator-LoRa_comp_radio_usb
; Two 2.5 MiB OTA slots fit the combined display/WiFi image. The custom map
; deliberately leaves the existing NVS and SPIFFS offsets unchanged so moving
; an already-configured USB Indicator to this build preserves its identity,
; channels, radio settings, and WiFi credentials.
board_build.partitions = variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv
build_flags =
${env:SenseCapIndicator-LoRa_comp_radio_usb.build_flags}
-D WIFI_DEBUG_LOGGING=1
-D WIFI_SSID='"myssid"'
-D WIFI_PWD='"mypwd"'
-D DEFAULT_WIFI_POWER_SAVE_MODE=0
; If neither the USB nor WiFi Companion link supplies authoritative time,
; begin evaluating independently sourced LoRa timestamps after two minutes.
-D COMPANION_MESH_CLOCK_SYNC=1
-D MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS=120000ULL
-D MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT=3
build_src_filter =
${env:SenseCapIndicator-LoRa_comp_radio_usb.build_src_filter}
+<helpers/esp32/*.cpp>
lib_deps =
${env:SenseCapIndicator-LoRa_comp_radio_usb.lib_deps}
Binary file not shown.
@@ -0,0 +1,37 @@
{
"format": "MeshCore SD UI font 2",
"text_cell": [
18,
24
],
"emoji_cell": [
12,
12
],
"pixel_font": true,
"antialiasing": false,
"text_pixel_values": [
0,
255
],
"emoji_encoding": {
"color": "RGB332",
"transparent_key": 227,
"alpha_threshold": 48
},
"text_source": "Noto Sans Mono Bold, binary-thresholded",
"text_native_scale": 3,
"unicode_emoji_version": "17.0",
"text_glyphs": 1357,
"emoji_glyphs": 3953,
"emoji_aliases": 5225,
"trie_bytes": 109080,
"color_atlas_bytes": 569244,
"font_bytes": 1302608,
"sha256": "61bce9662db314054e7bcfaa26147a28ad7b500b51baac4cae1caacce90b7421",
"sources": {
"NotoColorEmoji.ttf": "72a635cb3d2f3524c51620cdde406b217204e8a6a06c6a096ff8ed4b5fd6e27b",
"NotoSansMono-wdth-wght.ttf": "2cb2adb378a8f574213e23df697050b83c54c27df465a2015552740b2769a081",
"emoji-test-17.0.txt": "1d8a944f88d7952f7ef7c5167fef3c67995bcae24543949710231b03a201acda"
}
}
+35 -1
View File
@@ -5,7 +5,16 @@
ESP32Board board;
#ifdef SENSECAP_INDICATOR_LORA
static SPIClass radio_spi(FSPI);
static IndicatorRadioHal radio_hal(radio_spi);
RADIO_CLASS radio = new Module(&radio_hal, P_LORA_NSS, P_LORA_DIO_1,
P_LORA_RESET, P_LORA_BUSY);
WRAPPER_CLASS radio_driver(radio, board, radio_hal);
static bool target_radio_available = false;
#else
ESPNOWRadio radio_driver;
#endif
ESP32RTCClock rtc_clock;
#if defined(ENV_INCLUDE_GPS)
@@ -18,16 +27,28 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager();
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
#ifdef PIN_USER_BTN
MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true);
// Touch supplies the other navigation actions, so dispatch the physical
// button's single click immediately instead of waiting for a multi-click.
MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true, false);
#endif
#endif
bool radio_init() {
rtc_clock.begin();
#ifdef SENSECAP_INDICATOR_LORA
if (!radio_hal.beginExpander()) {
mesh::usbLoggingPort().println("ERROR: radio I/O expander unavailable");
target_radio_available = false;
return false;
}
target_radio_available = radio.std_init(&radio_spi);
return target_radio_available;
#else
radio_driver.init();
return true; // success
#endif
}
// Combine the normal software source with true entropy captured before RF/ADC
@@ -42,7 +63,20 @@ public:
}
};
#ifdef RECOVERABLE_EXTERNAL_RADIO
uint32_t radio_fallback_rng_seed() { return esp_random(); }
#endif
mesh::LocalIdentity radio_new_identity() {
#ifdef SENSECAP_INDICATOR_LORA
if (target_radio_available) {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng);
}
ESP_RNG rng;
return mesh::LocalIdentity(&rng);
#else
ESP_RNG rng;
return mesh::LocalIdentity(&rng); // create new random identity
#endif
}
+17 -2
View File
@@ -1,9 +1,17 @@
#pragma once
#include <helpers/ESP32Board.h>
#include <helpers/esp32/ESPNOWRadio.h>
#include <helpers/SensorManager.h>
#include <helpers/sensors/EnvironmentSensorManager.h>
#ifdef SENSECAP_INDICATOR_LORA
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include "IndicatorRadioHal.h"
#include "IndicatorSX1262Wrapper.h"
#else
#include <helpers/esp32/ESPNOWRadio.h>
#endif
#ifdef ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
#endif
@@ -13,7 +21,11 @@
#endif
extern ESP32Board board;
extern ESPNOWRadio radio_driver;
#ifdef SENSECAP_INDICATOR_LORA
extern WRAPPER_CLASS radio_driver;
#else
extern ESPNOWRadio radio_driver;
#endif
extern ESP32RTCClock rtc_clock;
extern EnvironmentSensorManager sensors;
@@ -23,4 +35,7 @@ extern EnvironmentSensorManager sensors;
#endif
bool radio_init();
#ifdef RECOVERABLE_EXTERNAL_RADIO
uint32_t radio_fallback_rng_seed();
#endif
mesh::LocalIdentity radio_new_identity();